diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 023640c..5546c73 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -16,5 +16,5 @@ Closes #
- [ ] I have tested this change manually
- [ ] I have added or updated tests as appropriate
-- [ ] All checks pass (`npm test`, `npm run lint`, `npm run format:check`)
+- [ ] All checks pass (`npm test`, `npm run lint`, `npm run format:check`, `npm run typecheck --workspaces`)
- [ ] I have updated documentation as needed
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a9df8bd..5de2ddd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,7 +9,7 @@ on:
jobs:
build:
runs-on: ubuntu-latest
- timeout-minutes: 20
+ timeout-minutes: 30
steps:
- uses: actions/checkout@v6
@@ -24,10 +24,10 @@ jobs:
- name: Check for uncommitted generated files
run: git diff --exit-code
+ # The end-to-end suite builds the Spring Boot image and boots Operaton
+ # through testcontainers; the runner ships Docker.
- run: npm test
if: success() || failure()
- env:
- SKIP_DOCKER_TESTS: 'true'
lint:
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 6a09bae..f7205ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,8 @@ Thumbs.db
# VS Code extension packaging
*.vsix
+packages/extension/LICENSE
+packages/extension/CHANGELOG.md
# Environment
.env
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11b4b9d..204e61e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,5 @@
# Changelog
-This file follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
-[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+This file follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Nothing has been released yet.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index c24f969..2847e6e 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -20,7 +20,8 @@ Examples of unacceptable behavior:
## Enforcement
-Instances of unacceptable behavior may be reported to the project maintainer at dev@marlonkranz.com. All complaints will be reviewed.
+Instances of unacceptable behavior may be reported to the project maintainer at dev@marlonkranz.com.
+All complaints will be reviewed.
## Attribution
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9251d08..cbcca93 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,13 +2,15 @@
> This project is being developed as part of a bachelor's thesis.
> External contributions are not accepted until after thesis submission.
+> Bug reports are welcome now.
+> Pull requests are welcome after submission.
> The guidelines below will apply once the project opens up.
## Development setup
### Prerequisites
-- [Node.js](https://nodejs.org/) >= 22 (the project uses Node 24 via `.nvmrc`; [nvm](https://github.com/nvm-sh/nvm) picks this up automatically)
+- [Node.js](https://nodejs.org/) >= 22 (the project uses Node 24 via `.nvmrc`; [nvm](https://github.com/nvm-sh/nvm)'s `nvm use` reads it)
- [VS Code](https://code.visualstudio.com/) with the recommended extensions (VS Code will prompt you on first open)
- [Docker](https://www.docker.com/) for end-to-end tests (alternatives: [Podman](https://podman.io/), [Rancher Desktop](https://rancherdesktop.io/))
@@ -22,7 +24,9 @@ npm run build
npm test
```
-`npm run build` runs the Langium code generator and compiles TypeScript across all packages. `npm test` runs the full test suite including Docker-based end-to-end tests. To skip those (no Docker installed, or just iterating on unit tests):
+`npm run build` runs the Langium code generator and compiles TypeScript across all packages.
+`npm test` runs the full test suite including Docker-based end-to-end tests.
+To skip those (no Docker installed, or just iterating on unit tests):
```sh
SKIP_DOCKER_TESTS=true npm test
@@ -30,11 +34,17 @@ SKIP_DOCKER_TESTS=true npm test
### Trying it out in VS Code
-After building, press F5 in VS Code. This opens a second VS Code window with the BPMNscript extension loaded. In that window you can create or open `.bpmnscript` files and get syntax highlighting, inline diagnostics, and autocompletion. To compile or decompile a file, use the command palette (`BPMNscript: Compile to BPMN` / `BPMNscript: Decompile to BPMNscript`) or the "Convert" panel in the BPMNscript activity-bar sidebar. The CLI alternative (`npx bpmns build `) still works if you prefer the terminal.
+After building, press F5 in VS Code.
+This opens a second VS Code window with the BPMNscript extension loaded.
+In that window you can create or open `.bpmnscript` files and get syntax highlighting, inline diagnostics, autocompletion, hover, and rename.
+To compile or decompile a file, use the command palette (`BPMNscript: Compile to BPMN` / `BPMNscript: Decompile to BPMNscript`) or the "Convert" panel in the BPMNscript activity-bar sidebar.
+The CLI alternative (`npx bpmns build `) still works if you prefer the terminal.
+For a normal install instead of the development host, see [packages/extension/README.md](packages/extension/README.md#installing).
### Editing the grammar
-The Langium grammar lives at `packages/language/src/bpmn-script.langium`. After editing it, regenerate the AST types before TypeScript can see the changes:
+The Langium grammar lives at `packages/language/src/bpmn-script.langium`.
+After editing it, regenerate the AST types before TypeScript can see the changes:
```sh
npm run langium:generate
@@ -48,29 +58,38 @@ npm run langium:watch
Both commands run from the repo root.
+The generated AST types land under `src/generated/`, which is git-ignored, so there is nothing to commit after regenerating.
+CI's build job still runs `git diff --exit-code` right after `npm run build`; that step fails if the build leaves any tracked file changed, not because of the generated output.
+
### Running tests
```sh
-npm test # Full suite (all packages + e2e)
-npm test --workspace packages/language # Language package only
-npm test --workspace packages/transform # Transform package only
-npm test --workspace packages/cli # CLI package only
-npm test --workspace packages/extension # Extension package only
+npm test # Full suite (all packages + e2e)
+npm test --workspace packages/language # Language package only
+npm test --workspace packages/transform # Transform package only
+npm test --workspace packages/cli # CLI package only
+npm test --workspace packages/extension # Extension package only
+FUZZ_N=2000 FUZZ_OUT=/tmp/fuzz npm test --workspace tests -- fuzz.test.ts # Round-trip fuzz over 2000 generated programs
```
-The extension tests require `@bpmn-script/language` and `@bpmn-script/transform` to be built first (they are consumed as compiled `out/` directories). Run `npm run build` from the repo root before running the extension suite in isolation.
+`npm test` runs the round-trip fuzz over the first 200 generated programs as part of the tests workspace.
+The extension tests require `@bpmn-script/language` and `@bpmn-script/transform` to be built first (they are consumed as compiled `out/` directories).
+Run `npm run build` from the repo root before running the extension suite in isolation.
### Code style
-The project uses [Prettier](https://prettier.io/) for formatting and [ESLint](https://eslint.org/) (via `eslint-plugin-bpmn-io`) for linting. Both run from the repo root:
+The project uses [Prettier](https://prettier.io/) for formatting and [ESLint](https://eslint.org/) (via `eslint-plugin-bpmn-io`) for linting.
+Both run from the repo root:
```sh
-npm run format # Auto-format all source files
-npm run format:check # Check formatting without writing (used in CI)
-npm run lint # Run ESLint
+npm run format # Auto-format all source files
+npm run format:check # Check formatting without writing (used in CI)
+npm run lint # Run ESLint
+npm run typecheck --workspaces # Typecheck test sources; vitest does not (used in CI)
```
-Formatting happens on save if you use VS Code with the recommended Prettier extension. The `.editorconfig` handles basics (indentation, line endings) for other editors.
+Formatting happens on save if you use VS Code with the recommended Prettier extension.
+The `.editorconfig` handles basics (indentation, line endings) for other editors.
## Guidelines (post-thesis)
@@ -86,9 +105,10 @@ Use the [feature request template](https://github.com/BPMNscript/bpmnscript/issu
- Reference the related issue with `Closes #...`
- Include a brief description of what changed and why
-- Make sure `npm test`, `npm run lint`, and `npm run format:check` all pass
+- Make sure `npm test`, `npm run lint`, `npm run format:check`, and `npm run typecheck --workspaces` all pass
- Update `CHANGELOG.md` if the change is user-facing
### Architectural decisions
-Non-trivial technical decisions are documented as [Markdown ADRs](docs/decisions/) using [MADR 4.0.0](https://adr.github.io/madr/). If your change involves an architectural decision, include a new ADR.
+Non-trivial technical decisions are documented as [Markdown ADRs](docs/decisions/) using [MADR 4.0.0](https://adr.github.io/madr/).
+If your change involves an architectural decision, include a new ADR.
diff --git a/README.md b/README.md
index cffd055..08442b0 100644
--- a/README.md
+++ b/README.md
@@ -31,13 +31,13 @@ process invoice-approval {
}
```
-Compiling this gives you a valid BPMN 2.0 file that visualizes the workflow and deploys to Operaton.
+Compiling this gives you a valid BPMN 2.0 file that a graphical modeler opens as a diagram and Operaton deploys.
## Who it's for
-Developers who'd rather work in a text editor than a diagram canvas, and who want processes to integrate in their existing code workflows like git repositories and diffs, autocompletion and compile-time error checking.
+Developers who'd rather work in a text editor than a diagram canvas, and who want processes to fit into their existing code workflow: git and diffs, autocompletion, and compile-time error checking.
-You don't need to know BPMN to read or write it.
+You don't need to know BPMN to read or write BPMNscript.
The keywords say what a step does rather than mirroring the BPMN element behind them.
The diagram comes for free: describe a valid workflow and the tool draws it, so there are no lines to route, nothing to align, and no redoing a layout because one changed step spoiled it.
@@ -61,7 +61,17 @@ npm test
```
The test run includes end-to-end tests that boot a real Operaton engine in Docker via [testcontainers](https://testcontainers.com/), so it needs a running Docker daemon.
-Without one, set `SKIP_DOCKER_TESTS=true` to skip them (CI does this automatically).
+Without one, set `SKIP_DOCKER_TESTS=true` to skip them; CI runs them.
+
+## Documentation
+
+- [packages/language/README.md](packages/language/README.md): the language reference, every statement and the keys it takes
+- [packages/transform/README.md](packages/transform/README.md#the-import-contract): the import contract, what a decompile refuses, drops, or warns about
+- [docs/bpmn-coverage.md](docs/bpmn-coverage.md): the BPMN coverage table, every element kind against the statement that writes it and what the importer does with it
+- [packages/cli/README.md](packages/cli/README.md): the `bpmns` command, its flags and exit codes
+- [packages/extension/README.md](packages/extension/README.md): the VS Code extension, how it is built, installed, and packaged
+- [docs/decisions/](docs/decisions/): the architectural decision records, why things are the way they are
+- [docs/glossary.md](docs/glossary.md): the shared vocabulary
## Using the CLI
@@ -73,25 +83,29 @@ After a build, run it with `npx`:
npx bpmns build examples/spring-boot/processes/invoice-approval.bpmnscript
# Choose the output path
+cd examples/spring-boot/processes
npx bpmns build invoice-approval.bpmnscript -o out/invoice-approval.bpmn
# Decompile BPMN XML back to the DSL
-npx bpmns parse invoice-approval.bpmn -o invoice-approval.bpmnscript
+npx bpmns parse out/invoice-approval.bpmn -o out/invoice-approval.bpmnscript
```
-Exit codes are `0` for success, `1` for validation or parse errors, `2` for I/O errors.
+Exit codes are `0` for success, `1` for validation errors on `build` or a refused BPMN construct on `parse`, and `2` for an I/O problem or input that cannot be read as BPMN XML.
Both directions print non-fatal warnings to stderr without changing the exit code, so an undeclared variable reference on compile, or a dropped lane on import is warned about but still produces a file.
+`parse` also re-validates the script it wrote and lists every error `build` would draw from it, still with exit `0`.
+Both commands read a file from disk, never stdin, refuse to overwrite an existing output unless you pass `--force`, and refuse the input path itself even then; see [packages/cli/README.md](packages/cli/README.md) for the exact rules.
## Using the VS Code extension
Press F5 from the repo root.
VS Code opens a second window with the extension loaded, where `.bpmnscript` files get:
-- syntax highlighting, including inside a `script` task's fenced body, which is highlighted in its own language
-- autocompletion and hover, from the same grammar that drives the compiler
-- errors and warnings inline as you type, so a `goto` that can't reach its target or a `string` variable compared against a number is flagged before you ever run the compiler
+- syntax highlighting, including inside a `script` task's fenced body, which is highlighted in its own language (JS, Groovy, Python and Ruby; no FEEL grammar is wired in, so it stays plain)
+- completion, hover, go to definition, find all references and rename, for steps, codes and variables, plus an outline, from the same grammar that drives the compiler
+- errors and warnings inline as you type, so a `goto` that can't reach its target, a `string` variable under an ordered comparison, or a `boolean` in arithmetic is flagged before you ever run the compiler
- a **Convert** panel in the sidebar: compile the open file, jump to its counterpart when one exists, or pick a `.bpmn` from disk to decompile
+For a normal install instead of the development host, see the extension README's [Installing](packages/extension/README.md#installing) section.
Compiling and decompiling are the same two operations as `bpmns build` and `bpmns parse` above, without leaving the editor.
Both are in the command palette too, under "BPMNscript".
See [packages/extension/README.md](packages/extension/README.md) for how the pieces fit together.
@@ -100,53 +114,14 @@ See [packages/extension/README.md](packages/extension/README.md) for how the pie
One `process` block per file.
Steps run top to bottom, so you never write a sequence flow; control flow is expressed with the structured statements you'd expect from a programming language.
-Every step carries an id (`user ReviewInvoice`), which is what `goto` and boundary events refer to, and which becomes the BPMN element's name (`ReviewInvoice` -> "Review Invoice") unless a `label` setting gives one instead.
-The event statements `on`, `throw`, `emit` and `await` are the exception: their id is a jump target only, so they take no `label`, and the diagram shows no name on them, except on the two ends of a link pair, which carry their link name.
-
-| BPMNscript | What it means | BPMN element |
-| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
-| `process X { }` | the bpmn process, one per file | `bpmn:process` |
-| `var x: number` | declare a variable so its uses get type-checked | none; authoring-time only |
-| `start X` / `end X` | where the flow begins and ends | start / end event |
-| `start X (...)` | start when an event arrives | start event with a trigger |
-| `end X terminate` | stop every running path | terminate end event |
-| `end X cancel` | give up the block of steps around this end | cancel end event |
-| `form { x: number "Label" }` | part of start element, pre-fills variables | form fields on the start event |
-| `user X(assignee: "...")` | a step a person performs | user task |
-| `service X(class: "...")` | a step the system performs, in the engine | service task |
-| `service X(topic: "...")` | a step an external worker picks up by topic | service task, external type |
-| `service X(type: "shell")` | a step the engine runs itself, a shell command or a mail | service task, built-in type |
-| `script X` + a fenced body | an inline script, in JS, Groovy, Python, Ruby or FEEL | script task |
-| `step X` | a step nothing in the engine automates | task |
-| `send X(class: "...")` | a step that sends something out | send task |
-| `receive X(message: "...")` | wait here until that message arrives | receive task |
-| `decide X(decision: "...")` | a step a decision table answers | business rule task |
-| `if` / `else if` / `else` | a decision | exclusive gateway |
-| `while` / `do ... while` | a loop | exclusive gateway loop |
-| `parallel { { } { } }` | branches that run at the same time | parallel gateway fork and join |
-| `parallel { if (c) { } else { } }` | the branches whose condition holds run, or the `else` branch when none does | inclusive gateway fork and join |
-| `subprocess X { }` | a group of steps as one unit | embedded sub-process |
-| `attempt X { }` | a group of steps that can be given up as one | transaction sub-process |
-| `call X(process: "other")` | start another process and wait for it | call activity |
-| `goto X` | jump to a named step in the same container | sequence flow |
-| `on { }` | catch an event anywhere in this body | event sub-process |
-| `on Host: { }` | catch an event only while `Host` runs | boundary event |
-| `await (...)` | stop here until the event arrives | intermediate catch event |
-| `await { (...) { } (...) { } }` | wait on several triggers, continue down whichever fires first | event-based gateway, a catch event per branch, exclusive join |
-| `throw` / `emit ` | raise an event, ending the path or continuing | throw event |
-| `emit link("X")` / `await link("X")` | jump to the catch of the same name, a diagram's off-page connector | intermediate throw and catch event, with no flow between them |
-| `for each x in c` | run a step once per item, or a set number of times | multi-instance marker |
-| `asyncBefore: true` | engine settings: async, exclusive, priority, retries, or, with a `run` prefix, on each run of a repetition | `operaton:` attribute/element |
-| `input x = "..."` | data into a step's execution, and `output` back out | `operaton:inputOutput` |
-| `on create(class: "...")` | code on a step's lifecycle, not a caught event | execution / task listener |
-
-The `for` row is a modifier rather than a statement: every activity in the table takes it between the id and the parens, meaning `user`, `service`, `script`, `step`, `send`, `receive`, `decide`, `subprocess`, `attempt`, and `call`.
-The last three rows are members of an element rather than statements: an engine setting joins the rest in the `( )`, a parameter and a listener go in the `{ }`, and not every element takes every one.
-The process header's own settings are written with the other process declarations.
-The listener row reuses `on` instead of opening a new event sub-process; the body block a handler always carries and a listener never does is what tells the two apart ([ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md)).
-The `attempt` head and the `cancel` end are one construct: the end gives up the block it sits in, `on : cancel` beside the block catches it, and the block's finished steps that carry an undo block are undone in between.
-
-[packages/language/README.md](packages/language/README.md) is the full specification: which keys each element takes, which elements take parameters and listeners, and what a conditioned `parallel` branch compiles to.
+Every step carries an id (`user ReviewInvoice`), which is what `goto` and boundary events refer to.
+A step, a call and a sub-process take their diagram name from that id (`ReviewInvoice` -> "Review Invoice") unless a `label` setting gives one instead; a `start` and an `end` are named by `label` alone, and their id is never humanized into a name.
+The event statements `on`, `throw`, `emit` and `await` are the exception: their id is a jump target only, so they take no `label` and show no name at all, except on the two ends of a link pair, which carry their link name.
+
+The [statement table](packages/language/README.md#statements) lists every statement, what it means, and the BPMN element it compiles to.
+The same page gives the keys each element takes, which elements take parameters and listeners, and what a conditioned `parallel` branch compiles to.
+
+The `attempt` head and the `cancel` end are one construct: the end gives up the block it sits in, `on : cancel` beside the block catches it, and the block's finished steps that carry an `on compensation` block (the undo block) are undone in between.
### Events
@@ -198,7 +173,7 @@ process order-handling {
`alongside` is the difference between interrupting the step and running beside it.
The four-hour timer nudges the reviewer while review stays open; the `AutoApproved` message cancels the review outright and jumps to payment.
-The declined-card error kills the payment sub-process and hands the customer to an agent, while the large-payment escalation pulls in a manager without stopping the capture.
+The declined-card error kills the payment sub-process and hands the customer to an agent, and the large-payment escalation pulls in a manager without stopping the capture.
### Compensation
@@ -248,26 +223,31 @@ The end-to-end suite deploys several of that directory's processes to an Operato
BPMN is a much larger language than this DSL, so a `.bpmn` file can hold more than a `.bpmnscript` has a form for.
A decompile deals with that in three ways.
-- A construct the DSL cannot express (a collaboration, an ad hoc subprocess, a compensation boundary event, an event definition the language doesn't model) is refused with an error and nothing is written.
-- Content the intermediate representation doesn't carry (a lane, a text annotation, a listener or an input/output parameter found on a gateway, a manual task, a standard loop, a data object) is dropped with a warning naming each item, and so is an `isExecutable="false"`, which imports as executable whatever the source said.
+- A construct the DSL cannot express (an ad hoc subprocess, a compensation boundary event, an event definition the language doesn't model) is refused with an error and nothing is written, and so is a shape Operaton refuses to deploy, such as an exclusive gateway with two unconditioned routes or an `operaton:inputOutput` on a gateway.
+- Content the intermediate representation doesn't carry (a pool, a lane, a text annotation, a listener found on a gateway, a manual task, a standard loop, a data object) is dropped with a warning naming each item, and so is an `isExecutable="false"`, which imports as executable whatever the source said.
The exceptions are attributes the importer doesn't read.
- What the print hop cannot carry into the script is warned about too, and those warnings are the ones to read before building the output.
A gateway's name merely drops, since the script derives its splits and merges from block structure and no statement is left to carry one.
A fork whose branches all carry conditions and that named no fallback changes what a recompiled document runs, because the printed block falls through where the model stops with a stuck execution.
A route with no `goto` target to jump to is left out entirely, replaced by a `// unstructured region: hand-repair required` comment naming the element it led into.
An `else` beside a branch that runs whatever the conditions do prints a fallback nothing can reach, and the validator then rejects the script `bpmns parse` just wrote.
+ An id the script cannot spell is respelled and the change is reported, since the engine keys history, a migration plan and a start-before-activity on that id, so a rebuilt document does not run as the same process.
+ A jump into a step reached from outside the `await` or `parallel` branch it printed in is still written and reported, and the validator then refuses it once `bpmns parse` reads the script back, because a branch's steps run only when the whole block is reached.
+ A split or wait left with no route out is dropped from the script outright, and the warning names what the engine does in its place, from ending the run the same way to refusing to deploy the model at all.
+
+A branch that only rejoins the block's tail may print after the block instead, reached by a `goto`, and the block's join may be replaced by direct edges into that tail; the set of steps and their order along each path is unchanged, so this restructuring prints with no warning at all.
-What each hop reports, item by item, is the import contract in [packages/transform/README.md](packages/transform/README.md#the-import-contract) and `PrintWarningCategory` with the warnings built beside it in [packages/transform/src/ir-to-dsl.ts](packages/transform/src/ir-to-dsl.ts).
-[ADR-0009](docs/decisions/0009-dominator-based-restructuring.md) covers which shapes degrade and why.
+What each hop reports, item by item, is the import contract in [packages/transform/README.md](packages/transform/README.md#the-import-contract) and the print warnings in [packages/transform/README.md](packages/transform/README.md#print-warnings).
+[ADR-0014](docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md) covers which shapes degrade and why.
-The engine settings a modeler tunes import warning-free: async continuation, exclusivity, job priority and the retry cycle on any event, activity, or gateway, the same settings minus priority on each run of a repetition, `operaton:inputOutput` on an activity in all four value forms, execution listeners on any event or activity and task listeners on a user task in all four binding forms, a step's repetition in either spelling of its collection and element variable, a call activity's variable mapping in either of the two attributes that name one, a form field's constraints, enum values, date pattern and properties, an external task's priority, properties and error mappings, and a mail or shell task's own fields ([ADR-0022](docs/decisions/0022-engine-attributes-as-named-ir-fields.md), [ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md), [ADR-0027](docs/decisions/0027-repetition-on-the-authoring-surface.md), [ADR-0033](docs/decisions/0033-call-activity-variable-mapping.md), [ADR-0037](docs/decisions/0037-form-field-constraints-values-and-properties.md), [ADR-0038](docs/decisions/0038-external-task-extras-ride-the-topic-binding.md), [ADR-0040](docs/decisions/0040-engine-settings-on-synthesized-gateways.md), [ADR-0041](docs/decisions/0041-per-run-job-settings-on-a-repetition.md), [ADR-0042](docs/decisions/0042-mail-and-shell-as-a-type-binding.md)).
-A user task assigned in BPMN's own words, a `bpmn:humanPerformer` or a `bpmn:potentialOwner`, imports onto the same `assignee`, `candidateUsers` and `candidateGroups` the engine reads it into, with a warning naming the rewrite ([ADR-0039](docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md)).
-A link throw is one exception: each engine setting and listener on one warns, because Operaton creates no activity for a link throw and so never reads them ([ADR-0035](docs/decisions/0035-link-events-for-import-round-trip-symmetry.md)).
-`asyncAfter` on an event-based gateway is the other: the import refuses it, as `BpmnParse.parseEventBasedGateway` refuses to deploy it ([ADR-0040](docs/decisions/0040-engine-settings-on-synthesized-gateways.md)).
+Every engine setting a modeler tunes imports warning-free; the IR shape in [packages/transform/README.md](packages/transform/README.md#ir-shape) lists each one.
+A user task assigned in BPMN's own words, a `bpmn:humanPerformer` or a `bpmn:potentialOwner`, imports onto the same `assignee`, `candidateUsers` and `candidateGroups` the engine reads it into, with a warning naming the rewrite ([ADR-0012](docs/decisions/0012-honest-bpmn-import.md)).
+A link throw is one exception: each engine setting and listener on one warns, because Operaton creates no activity for a link throw and so never reads them ([ADR-0030](docs/decisions/0030-link-events-for-import-round-trip-symmetry.md)).
+`asyncAfter` on an event-based gateway is the other: the import refuses it, as Operaton refuses to deploy it ([ADR-0021](docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md)).
What stays out of reach is a setting with nowhere to sit in the text.
-That is a listener or an input/output parameter on a synthesized gateway, a setting on the `bpmn:process` element, or a job priority on the repetition element, since `BpmnParse.createActivityOnScope` reads one off the step alone.
+That is a listener or an input/output parameter on a synthesized gateway, a setting on the `bpmn:process` element, or a job priority on the repetition element, which the engine reads off the step alone.
-The reasoning is in [ADR-0014](docs/decisions/0014-honest-bpmn-import-contract.md).
+The reasoning is in [ADR-0012](docs/decisions/0012-honest-bpmn-import.md).
The short version: a round trip that changes the model/workflow without warning is worse than one that refuses to run.
## Running a process on a real engine
@@ -294,10 +274,10 @@ flowchart LR
IR -- irToDsl --> SRC
```
-A source file is parsed into an AST, converted into the IR (a small set of plain TypeScript objects in `packages/transform/src/ir/types.ts` that describe a process without reference to any specific engine), and written out from there.
+A source file is parsed into an AST, converted into the IR (a small set of plain TypeScript objects in `packages/transform/src/ir/types.ts` that describe a process in the engine's own terms, tied to no file format), and written out from there.
Compiling is `.bpmnscript` -> AST -> IR -> `.bpmn`; decompiling is `.bpmn` -> IR -> `.bpmnscript`.
-The IR stays vendor-neutral: the engine's bindings, execution settings, input/output parameters and lifecycle listeners are all plain-named IR fields, and `operaton:` is applied only where `irToXml` builds the moddle element from a local [moddle extension](packages/transform/src/operaton-moddle.json), which keeps the engine's specifics out of the core data model ([ADR-0006](docs/decisions/0006-engine-agnostic-intermediate-representation.md), [ADR-0022](docs/decisions/0022-engine-attributes-as-named-ir-fields.md), [ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md)).
+The IR carries Operaton's semantics under names with no vendor prefix: the engine's bindings, execution settings, input/output parameters and lifecycle listeners are all plain-named IR fields, and `operaton:` is applied only where `irToXml` builds the moddle element from a local [moddle extension](packages/transform/src/operaton-moddle.json) ([ADR-0007](docs/decisions/0007-intermediate-representation-ast-bpmn.md), [ADR-0021](docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md), [ADR-0020](docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md)).
| Library | Role |
| --------------------------------------------------------------- | --------------------------------------------------- |
@@ -318,8 +298,23 @@ examples/
spring-boot/ Operaton + Spring Boot deployment, also the e2e fixture
docs/
decisions/ Architectural decision records
+ glossary.md Shared vocabulary
```
+## Limitations
+
+The tool writes one executable process per file: a collaboration with pools and message flows imports as that one process, with a warning per pool and per message flow, and a second executable process refuses the import ([coverage table](docs/bpmn-coverage.md#collaboration-and-root-elements)).
+Lanes have no textual form, so a lane is dropped with a warning and every step lands in the one process ([coverage table](docs/bpmn-coverage.md#collaboration-and-root-elements)).
+Data objects, data stores, and data associations are not authored and are dropped with a warning on import; a process variable is declared with `var` and reaches a step through `input` and `output` parameters instead ([coverage table](docs/bpmn-coverage.md#data)).
+Text annotations, associations, and groups go the same way ([coverage table](docs/bpmn-coverage.md#artifacts-and-diagram)).
+An ad hoc sub-process and a complex gateway have no statement, so either refuses the import ([activities](docs/bpmn-coverage.md#activities), [gateways](docs/bpmn-coverage.md#gateways)).
+A standard loop is dropped with a warning: the activity that carried it still imports, but runs once regardless ([coverage table](docs/bpmn-coverage.md#activities)).
+A manual task and a definition-less intermediate throw are not authored; each imports as a plain `step` instead, with a warning naming the rewrite ([activities](docs/bpmn-coverage.md#activities), [intermediate throw events](docs/bpmn-coverage.md#intermediate-throw-events)).
+A compensation handler is written on a `subprocess` only, so BPMN's compensation boundary event with its association refuses the import and the message names the rewrite ([coverage table](docs/bpmn-coverage.md#boundary-events)).
+Forms are the `operaton:formData` fields on a start or a user task, or a `formKey`/`formRef` naming one deployed elsewhere; a form field typed outside `string`, `long`, `boolean`, `date`, and `enum` refuses the import ([import contract](packages/transform/README.md#refusals)).
+A `decide` step names a deployed decision table, and the language has no form for the table itself ([packages/language/README.md](packages/language/README.md#send-receive-and-decision-tasks)).
+The diagram is generated on every compile, so hand-placed coordinates in a `.bpmn` file are discarded on import without a warning ([ADR-0009](docs/decisions/0009-auto-layout-and-expansion-hint.md)).
+
## Project status
BPMNscript is being built as a bachelor's thesis at [University of Hamburg](https://www.uni-hamburg.de/), supervised by Dr. Oliver Kopp.
diff --git a/docs/bpmn-coverage.md b/docs/bpmn-coverage.md
new file mode 100644
index 0000000..387c992
--- /dev/null
+++ b/docs/bpmn-coverage.md
@@ -0,0 +1,157 @@
+# BPMN coverage
+
+Which BPMN 2.0 elements BPMNscript writes, and what the importer does with each one it meets in a `.bpmn` file.
+The BPMNscript column is the statement that compiles to the element, or "not authored" when the language has no form for it; the statement's keys are in [packages/language/README.md](../packages/language/README.md).
+The import column says whether `xmlToIr` imports the element, imports it changed or drops it with a warning (the category in parentheses), or refuses the file (the error class in parentheses); the exact conditions are in the [import contract](../packages/transform/README.md#the-import-contract).
+Operaton's own extension elements (`operaton:` settings, input/output parameters, listeners, form fields) are not BPMN elements and are listed with the [IR shape](../packages/transform/README.md#ir-shape) instead.
+
+## Activities
+
+| BPMN element | BPMNscript | Import |
+| ------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `bpmn:task` | `step X` | imported |
+| `bpmn:manualTask` | not authored | imported as `step`, warned (`rewritten`): history reports `task` where the source wrote `manualTask` |
+| `bpmn:userTask` | `user X(...)` | imported; refused on a `bpmn:humanPerformer` beside `operaton:assignee` or a second `bpmn:humanPerformer` (`UnsupportedAssignmentError`) |
+| `bpmn:humanPerformer`, `bpmn:potentialOwner` on a user task | `assignee`, `candidateUsers`, `candidateGroups` keys | imported onto those keys, warned (`rewritten`) naming the rewrite |
+| `bpmn:performer`, `bpmn:resourceRole`, a role on any other activity | not authored | dropped, warned (`unmappedConstruct`) |
+| `bpmn:serviceTask` | `service X(...)` | imported; refused without a binding the engine reaches, with a `type` outside `external`, `mail`, and `shell`, or with an `operaton:connector` (`UnsupportedServiceTaskFormError`) |
+| `bpmn:sendTask` | `send X(...)` | imported, refused as a service task is |
+| `bpmn:businessRuleTask` | `decide X(...)` | imported, refused as a service task is, plus on a `decisionRefTenantId` or a `decisionRefBinding` of `versionTag` (`UnsupportedServiceTaskFormError`) |
+| `bpmn:receiveTask` | `receive X(...)` | imported |
+| `bpmn:scriptTask` | `script X` with a fenced body | imported; refused on an empty `scriptFormat`, on neither a body nor a resource, or on a `operaton:resource` in place of the body (`UnsupportedExtensionFormError`) |
+| `bpmn:callActivity` | `call X(process: "...")` | imported; refused on a `caseRef`, a `calledElementTenantId`, a `versionTag` binding, or an `in`/`out` mapping outside the recognized shapes (`UnsupportedCallActivityError`) |
+| `bpmn:subProcess` | `subprocess X { }` | imported; refused without a start event (`UnsupportedEventFeatureError`) |
+| `bpmn:subProcess` with `triggeredByEvent="true"` | `on { }` | imported as an event handler, see the start event rows |
+| `bpmn:transaction` | `attempt X { }` | imported, a missing start event added, warned (`behaviourChanged`); `method`, `protocol`, and `triggeredByEvent` dropped, warned (`unmappedConstruct`) |
+| `bpmn:adHocSubProcess` | not authored | refused (`UnsupportedElementError`) |
+| `bpmn:multiInstanceLoopCharacteristics` | `for each x in c`, `for 3`, with `sequentially` | imported; refused on an event handler, beside an `operaton:outputParameter`, or with a `loopCardinality` body the script cannot spell (`UnsupportedLoopCharacteristicsError`) |
+| `bpmn:standardLoopCharacteristics` | not authored | dropped, warned (`unmappedConstruct`): Operaton runs the activity once regardless |
+| `startQuantity`, `completionQuantity` other than `1` | not authored | dropped, warned (`unmappedConstruct`): Operaton never reads either |
+
+## Gateways
+
+| BPMN element | BPMNscript | Import |
+| ------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `bpmn:exclusiveGateway` | `if` / `else if` / `else`, `while`, `do ... while` | imported; refused on a shape `BpmnParse.validateExclusiveGateway` fails, such as two unconditioned routes (`UnsupportedGatewayShapeError`); a route with no condition listed before a conditioned one prints in its place as an `if (true)` head |
+| `bpmn:parallelGateway` | `parallel { { } { } }` | imported |
+| `bpmn:inclusiveGateway` | `parallel { if (c) { } else { } }` | imported |
+| `bpmn:eventBasedGateway` | `await { (...) { } (...) { } }` | imported; refused when a branch leads to anything but a catch event or the gateway carries `asyncAfter` (`UnsupportedEventFeatureError`), or without an id (`UnsupportedDocumentError`); `instantiate` and `eventGatewayType` dropped, warned (`unmappedConstruct`) |
+| `bpmn:complexGateway` | not authored | refused (`UnsupportedElementError`) |
+| `name` on any gateway | not authored | imported and then left out of the script, warned on print (`label`) |
+| a listener on any gateway | not authored | dropped, warned (`extensionAttribute`) |
+
+## Events
+
+### Start events
+
+| BPMN element | BPMNscript | Import |
+| -------------------------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
+| `bpmn:startEvent` with no definition | `start X` | imported; a second one on the process refused (`UnsupportedEventFeatureError`); any start without an id refused (`UnsupportedDocumentError`) |
+| message start | `start X message("Name")` | imported; refused when the name is an expression (`UnsupportedEventFeatureError`) |
+| signal start | `start X signal("Name")` | imported |
+| timer start | `start X timer("PT1H")`, `at:`, `every:` | imported; refused without an id (`UnsupportedDocumentError`) |
+| conditional start | `start X condition(expr)` | imported |
+| error, escalation, or compensation start on the process | not authored | refused, since Operaton ignores the trigger (`UnsupportedEventFeatureError`) |
+| any other definition on a process start | not authored | refused (`UnsupportedEventDefinitionError`) |
+| a start with a definition inside a sub-process or transaction | not authored | refused (`UnsupportedEventFeatureError`) |
+| a second start event inside a `bpmn:subProcess` or `bpmn:transaction` | not authored | refused (`UnsupportedEventFeatureError`) |
+| an event sub-process start: error, escalation, message, signal, timer, condition | `on (...) { }`, `alongside` when allowed | imported as the handler's trigger; refused with more or fewer than one definition (`UnsupportedEventFeatureError`) |
+| an event sub-process start carrying a cancel, link, or terminate definition | not authored | refused (`UnsupportedEventDefinitionError`) |
+| an event sub-process start: compensation | `on compensation { }` inside a `subprocess` | imported inside a sub-process or transaction; refused on the process or in another handler (`UnsupportedEventFeatureError`) |
+
+### End events
+
+| BPMN element | BPMNscript | Import |
+| ---------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `bpmn:endEvent` with no definition | `end X` | imported |
+| terminate end | `end X terminate` | imported |
+| cancel end | `end X cancel` inside an `attempt` | imported inside a `bpmn:transaction`; refused anywhere else (`UnsupportedEventFeatureError`) |
+| error end | `throw error(CODE)` | imported; refused without an `errorRef` or with a root carrying no code (`UnsupportedEventFeatureError`) |
+| escalation end | `throw escalation(CODE)` | imported; refused without an `escalationRef` or with a root carrying no code (`UnsupportedEventFeatureError`) |
+| message end | `throw message("Name")` | imported; refused with a connector (`UnsupportedEventFeatureError`), or a `type` other than `external`, or `external` with no `topic` (`UnsupportedServiceTaskFormError`); `type="external"` with a `topic` imports as a binding |
+| signal end | `throw signal("Name")` | imported |
+| compensation end | `throw compensation` | imported; refused with an `activityRef` naming one activity, or `waitForCompletion="false"` (`UnsupportedEventFeatureError`) |
+| any other definition on an end | not authored | refused (`UnsupportedEventDefinitionError`) |
+
+### Intermediate throw events
+
+| BPMN element | BPMNscript | Import |
+| ------------------------------------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `bpmn:intermediateThrowEvent` with no definition | not authored | imported as `step`, warned (`rewritten`): history reports `task` |
+| escalation throw | `emit escalation(CODE)` | imported |
+| message throw | `emit message("Name")` | imported; refused with a connector (`UnsupportedEventFeatureError`), or a `type` other than `external`, or `external` with no `topic` (`UnsupportedServiceTaskFormError`); `type="external"` with a `topic` imports as a binding, the same rule a message end follows |
+| signal throw | `emit signal("Name")` | imported |
+| compensation throw | `emit compensation` | imported; refused with an `activityRef` naming one activity, or `waitForCompletion="false"` (`UnsupportedEventFeatureError`) |
+| link throw | `emit link("Name")` | imported; refused when it names no link or a sequence flow leaves it (`UnsupportedEventFeatureError`) |
+| error throw | not authored | refused, BPMN has no intermediate error throw (`UnsupportedEventFeatureError`) |
+| any other definition on an intermediate throw | not authored | refused (`UnsupportedEventDefinitionError`) |
+
+### Intermediate catch events
+
+| BPMN element | BPMNscript | Import |
+| -------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------- |
+| message catch | `await message("Name")` | imported |
+| signal catch | `await signal("Name")` | imported |
+| timer catch | `await timer("PT1H")` | imported; refused without an id (`UnsupportedDocumentError`) |
+| conditional catch | `await condition(expr)` | imported |
+| link catch | `await link("Name")` | imported; refused when it names no link or a sequence flow enters it (`UnsupportedEventFeatureError`) |
+| a catch with no definition or any other kind | not authored | refused (`UnsupportedEventFeatureError`) |
+| `parallelMultiple="true"` | not authored | refused (`UnsupportedEventFeatureError`) |
+
+### Boundary events
+
+| BPMN element | BPMNscript | Import |
+| ---------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| error boundary | `on Host: error(CODE) { }` | imported; refused with `cancelActivity="false"` (`UnsupportedEventFeatureError`) |
+| escalation, message, signal, timer, or conditional boundary | `on Host: (...) { }`, `alongside` for `cancelActivity="false"` | imported |
+| cancel boundary | `on Block: cancel { }` beside an `attempt` | imported on a `bpmn:transaction` host; refused on any other host, with `cancelActivity="false"`, or as a second cancel boundary (`UnsupportedEventFeatureError`) |
+| compensation boundary with its `bpmn:association` and handler activity | not authored; write `on compensation { }` inside a `subprocess` | refused, the message names the rewrite (`UnsupportedEventFeatureError`) |
+| any other definition on a boundary | not authored | refused (`UnsupportedEventDefinitionError`) |
+| a boundary with no `attachedToRef`, an incoming flow, or no definition | not authored | refused (`UnsupportedEventFeatureError`) |
+| a boundary without an id, other than a compensation boundary | not authored | refused (`UnsupportedDocumentError`) |
+
+## Sequence flow
+
+| BPMN element | BPMNscript | Import |
+| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `bpmn:sequenceFlow` | never written: steps run top to bottom, `goto X` for a jump | imported, one without an id under an id minted from its ends; the printer rebuilds blocks from the flow graph and writes `goto` where no block fits; a route it cannot place is left out, warned on print (`droppedEdge`, `degradedSplit`) |
+| `bpmn:conditionExpression`, and a conditional event definition's `bpmn:condition` | the condition of `if`, `while`, `do ... while`, a `parallel` branch, and of `condition(expr)` | imported; refused with a `language` attribute or an `xsi:type` other than `tFormalExpression` (`UnsupportedConditionExpressionError`) |
+| `default` on a gateway | `else`, the fallback branch | imported as written (`mapDefaultingGateway`); on an exclusive gateway the shape check above can still refuse the gateway around it |
+| `default` on an activity | `else`, the fallback branch | imported when it names one of the activity's own outgoing flows; dropped, warned (`unmappedConstruct`) otherwise |
+| `name` on a sequence flow | not authored, derived from the condition | warned (`label`) when it differs from the derived label |
+| a flow into or out of an event sub-process | not authored | refused, `BpmnParse.parseSequenceFlow` fails on it (`UnsupportedEventFeatureError`) |
+
+## Data
+
+| BPMN element | BPMNscript | Import |
+| ------------------------------------------------------------------------ | --------------------------------------------------------------------- | ------------------------------------- |
+| `bpmn:dataObject`, `bpmn:dataObjectReference`, `bpmn:dataStoreReference` | not authored; `var x: type` declares a variable and writes no element | dropped, warned (`unmappedConstruct`) |
+| `bpmn:dataInputAssociation`, `bpmn:dataOutputAssociation` | not authored; `input` and `output` write `operaton:inputOutput` | dropped, warned (`unmappedConstruct`) |
+| `bpmn:ioSpecification`, `bpmn:property` | not authored | dropped, warned (`unmappedConstruct`) |
+| `bpmn:auditing`, `bpmn:monitoring` | not authored | dropped, warned (`unmappedConstruct`) |
+| `bpmn:dataStore`, `bpmn:itemDefinition` at the root | not authored | dropped, warned (`unmappedConstruct`) |
+
+## Collaboration and root elements
+
+| BPMN element | BPMNscript | Import |
+| ---------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `bpmn:process`, one marked `isExecutable="true"` | `process X { }`, one per file | imported |
+| a second executable `bpmn:process` | not authored | refused (`UnsupportedCollaborationError`) |
+| a `bpmn:process` not marked executable beside the executable one | not authored | dropped, warned (`unmappedConstruct`) |
+| a lone `bpmn:process` not marked executable | `process X { }` | imported as an executable process, warned (`behaviourChanged`) |
+| several `bpmn:process` elements, none marked executable | not authored | refused (`UnsupportedDocumentError`) |
+| `bpmn:collaboration`, `bpmn:participant` | not authored | dropped, warned (`unmappedConstruct`), one warning per pool |
+| `bpmn:messageFlow` | not authored | dropped, warned (`unmappedConstruct`), one warning per flow |
+| `bpmn:laneSet`, `bpmn:lane` | not authored | dropped, warned (`lane`), one warning per lane |
+| `bpmn:error`, `bpmn:escalation` | `error CODE(message: "...")`, `escalation CODE` in the process header | imported as declarations; refused on an error root with an `operaton:errorMessage` and no code, or two roots sharing a code with two messages (`UnsupportedEventFeatureError`); a root with no code warned (`unreferencedRoot`) |
+| `bpmn:message`, `bpmn:signal` | synthesized from the names the events use | imported by reference; one nothing references warned (`unreferencedRoot`); two signals sharing a name refused (`UnsupportedEventFeatureError`) |
+| `bpmn:category`, `bpmn:interface`, any other root | not authored | dropped, warned (`unmappedConstruct`) |
+| `bpmn:import` | not authored | refused (`UnsupportedDocumentError`) |
+
+## Artifacts and diagram
+
+| BPMN element | BPMNscript | Import |
+| ------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| `bpmn:textAnnotation`, `bpmn:association`, `bpmn:group` | not authored | dropped, warned (`unmappedConstruct`) |
+| `bpmn:documentation` | `documentation: "..."` on the process and on a step | imported at every position that reads a `name`; warned (`documentation`) on an event handler, a typed event, a sequence flow, and the roots |
+| `bpmndi:BPMNDiagram` and everything under it | not authored, generated by auto-layout on compile | discarded without a warning |
diff --git a/docs/decisions/0000-use-markdown-architectural-decision-records.md b/docs/decisions/0000-use-markdown-architectural-decision-records.md
index e8345f0..ef5a77a 100644
--- a/docs/decisions/0000-use-markdown-architectural-decision-records.md
+++ b/docs/decisions/0000-use-markdown-architectural-decision-records.md
@@ -12,11 +12,11 @@ Which format and structure should these records follow?
## Considered Options
-- [MADR](https://adr.github.io/madr/) 4.0.0 – The Markdown Architectural Decision Records
-- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR"
-- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements
+- [MADR](https://adr.github.io/madr/) 4.0.0: the Markdown Architectural Decision Records
+- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions): the first incarnation of the term "ADR"
+- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions): the Y-Statements
- Other templates listed at
-- Formless – No conventions for file format and structure
+- Formless, no conventions for file format and structure
## Decision Outcome
@@ -29,3 +29,6 @@ Chosen option: "MADR 4.0.0", because
- The MADR format is lean and fits our development style.
- The MADR structure is comprehensible and facilitates usage & maintenance.
- The MADR project is vivid.
+
+Every decision record in this directory follows the MADR 4.0.0 headings from `adr-template.md`, in that order: Context and Problem Statement, Decision Drivers, Considered Options, Decision Outcome, Consequences, Confirmation, Pros and Cons of the Options, More Information.
+Each optional heading is dropped when it has no content, and one sentence stays on one line.
diff --git a/docs/decisions/0004-use-apache-2-license.md b/docs/decisions/0001-use-apache-2-license.md
similarity index 100%
rename from docs/decisions/0004-use-apache-2-license.md
rename to docs/decisions/0001-use-apache-2-license.md
diff --git a/docs/decisions/0002-use-langium-as-language-workbench.md b/docs/decisions/0002-use-langium-as-language-workbench.md
deleted file mode 100644
index 8c9b1ab..0000000
--- a/docs/decisions/0002-use-langium-as-language-workbench.md
+++ /dev/null
@@ -1,119 +0,0 @@
----
-status: accepted
-date: 2026-04-13
-decision-makers: Marlon Kranz
----
-
-# Use Langium as Language Workbench
-
-## Context and Problem Statement
-
-BPMNscript is a textual DSL that requires a parser, AST, validation, scoping, and IDE integration (syntax highlighting, autocomplete, jump-to-definition, inline errors) delivered as a VS Code extension.
-Which language workbench should be used to implement the DSL infrastructure?
-
-## Decision Drivers
-
-- VS Code must be the primary IDE target, with first-class extension support
-- IDE features (syntax highlighting, autocomplete, jump-to-definition, inline errors) are a core value proposition of a textual DSL
-- A 15-week thesis timeline requires fast bootstrapping and minimal boilerplate
-- A post-thesis browser-based playground should be architecturally feasible
-- The workbench must support custom scoping, validation, and code generation to BPMN 2.0 XML
-- Error-tolerant incremental parsing is required for real-time editor feedback
-
-## Considered Options
-
-- Langium
-- Eclipse Xtext
-- MontiCore
-- ANTLR with manual LSP server
-- JetBrains MPS
-
-## Decision Outcome
-
-Chosen option: "Langium", because it is the only workbench that natively targets VS Code with TypeScript, generates an LSP server and VS Code extension from a grammar definition, and supports browser deployment for a future playground, all within a timeline-feasible learning curve.
-
-### Consequences
-
-- Good, because the entire stack is TypeScript, eliminating context-switching between languages
-- Good, because Langium generates parser, AST, syntax highlighting, completion, go-to-definition, and diagnostics from the grammar
-- Good, because the language server can run in a web worker, enabling a browser-based playground with approximately 95% code reuse
-- Good, because `bpmn-moddle` (the standard TypeScript library for BPMN 2.0 XML) integrates naturally
-- Good, because Langium is actively maintained by TypeFox as an Eclipse Foundation mature project
-- Bad, because Langium has no built-in AST-to-text serializer, requiring a handwritten emitter for the BPMN-to-DSL reverse transformation
-- Bad, because documentation has gaps for advanced topics such as custom scoping patterns
-- Neutral, because Langium is younger (5 years) than Xtext (20 years), meaning some edge cases are less tested
-
-## Pros and Cons of the Options
-
-### Langium
-
-
-
-- Good, because it generates a full LSP server and VS Code extension from a single grammar file
-- Good, because it uses TypeScript throughout: the same language for grammar processing, validation, code generation, and the VS Code extension
-- Good, because it natively supports browser deployment via web workers and Monaco Editor
-- Good, because it provides built-in scoping, cross-reference resolution, and validation infrastructure
-- Good, because it is actively developed (v4.2, February 2026) with TypeFox as primary maintainer
-- Good, because the Chevrotain parser uses the ALL(\*) algorithm for unbounded lookahead
-- Bad, because there is no built-in AST-to-text serializer for round-tripping
-- Bad, because documentation is thinner than Xtext's, especially for advanced scoping and Sprotty integration
-- Bad, because it has no grammar-level enum construct
-
-### Eclipse Xtext
-
-
-
-- Good, because it is the most mature DSL workbench (20 years, v2.42)
-- Good, because it has a built-in serializer for AST-to-text round-tripping
-- Good, because its EMF integration provides native access to the BPMN 2.0 metamodel
-- Good, because it has extensive documentation, books, and community resources
-- Bad, because its maintenance future is at risk (declining contributors, open sustainability discussion since 2020)
-- Bad, because VS Code support requires a hybrid Java/TypeScript stack with a JVM backend process
-- Bad, because no viable browser deployment path exists (Java server required)
-- Bad, because the learning curve is steep (EMF, Guice, Xtend, Eclipse, MWE2)
-- Bad, because the build system (Maven Tycho, Eclipse target platforms) is frequently reported as fragile
-
-### MontiCore
-
-
-
-- Good, because it has an existing BPMN Workflow DSL grammar
-- Good, because it auto-generates pretty printers from grammars
-- Good, because it has the strongest language composition capabilities (inheritance, embedding, aggregation)
-- Bad, because it has no LSP support and no VS Code integration path
-- Bad, because it has no browser deployment path (19 MB JAR, reflection-heavy)
-- Bad, because its community is effectively limited to RWTH Aachen
-- Bad, because documentation is academic and sparse outside the handbook
-
-### ANTLR with manual LSP server
-
-
-
-- Good, because ANTLR is the most widely used and cited parser generator
-- Good, because it provides full control with no framework lock-in
-- Good, because the `antlr4-c3` library provides grammar-aware code completion
-- Bad, because every IDE feature (scoping, validation, completion, go-to-definition) must be implemented manually
-- Bad, because the estimated time to a working LSP server is 3-5 weeks, consuming most of the implementation timeline
-- Bad, because there is no generated VS Code extension scaffolding
-
-### JetBrains MPS
-
-
-
-- Good, because it is backed by JetBrains and actively maintained
-- Good, because language composition is trivial in a projectional editor
-- Bad, because it is a projectional editor rather than a textual DSL workbench, storing files as XML instead of human-readable text
-- Bad, because it has no VS Code support and no LSP integration
-- Bad, because it is architecturally incompatible with the thesis goal of a textual DSL
-
-## More Information
-
-Twelve workbenches were evaluated in total.
-Beyond the five listed above, Spoofax, Rascal, Racket, Neverlang, textX, Chevrotain, and Kotlin+ANTLR+lsp4j were assessed and excluded for reasons including: lack of production-ready VS Code support, abandoned IDE tooling, architectural mismatch (S-expression syntax, projectional editing), or infeasible learning curves for a 15-week timeline.
-
-Key references:
-
-- [TypeFox: Xtext, Langium, what next?](https://www.typefox.io/blog/xtext-langium-what-next/)
-- [Langium 4.0 Release](https://www.typefox.io/blog/langium-release-4.0/)
-- [Call To Action: Secure the future maintenance of Xtext (GitHub #1721)](https://github.com/eclipse-xtext/xtext/issues/1721)
-- [Jordan & Zib: A Langium-based approach to BigER (TU Wien, 2024)](https://model-engineering.info/publications/theses/thesis-jordan-zib.pdf)
diff --git a/docs/decisions/0005-use-mono-repo-structure.md b/docs/decisions/0002-use-mono-repo-structure.md
similarity index 100%
rename from docs/decisions/0005-use-mono-repo-structure.md
rename to docs/decisions/0002-use-mono-repo-structure.md
diff --git a/docs/decisions/0003-auto-layout-for-diagram-interchange.md b/docs/decisions/0003-auto-layout-for-diagram-interchange.md
deleted file mode 100644
index cce4917..0000000
--- a/docs/decisions/0003-auto-layout-for-diagram-interchange.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-status: accepted
-date: 2026-04-13
-decision-makers: Marlon Kranz
----
-
-# Use Auto-layout for Diagram Interchange Data
-
-## Context and Problem Statement
-
-BPMN 2.0 XML includes Diagram Interchange (DI) data that specifies the graphical layout of process elements (positions, dimensions, edge waypoints).
-How should BPMNscript handle DI data during DSL-to-XML export and XML-to-DSL import?
-
-## Decision Drivers
-
-- A textual DSL has no inherent coordinate system for graphical layout
-- Preserving original DI data through a text-based round-trip is impractical
-- Generated BPMN XML must be renderable by standard BPMN tools
-
-## Considered Options
-
-- Auto-layout on export, discard DI on import
-- Preserve DI data in a side-channel (annotations or separate file)
-- Include layout hints in the DSL syntax
-
-## Decision Outcome
-
-Chosen option: "Auto-layout on export, discard DI on import", because a textual DSL has no meaningful way to represent or preserve graphical coordinates, and auto-layout produces adequate results for generated BPMN XML.
-
-### Consequences
-
-- Good, because the DSL syntax remains clean and focused on process semantics
-- Good, because `bpmn-auto-layout` (the bpmn.io layout library) produces consistent, readable diagrams
-- Bad, because manually arranged layouts in imported BPMN files are lost after a round-trip
-- Neutral, because users who need precise layout control can adjust the generated BPMN in a graphical editor after export
diff --git a/docs/decisions/0003-use-langium-as-language-workbench.md b/docs/decisions/0003-use-langium-as-language-workbench.md
new file mode 100644
index 0000000..02294a0
--- /dev/null
+++ b/docs/decisions/0003-use-langium-as-language-workbench.md
@@ -0,0 +1,61 @@
+---
+status: accepted
+date: 2026-04-13
+decision-makers: Marlon Kranz
+---
+
+# Use Langium as Language Workbench
+
+## Context and Problem Statement
+
+BPMNscript is a textual DSL that requires a parser, AST, validation, scoping, and IDE integration (syntax highlighting, autocomplete, jump-to-definition, inline errors) delivered as a VS Code extension.
+Which language workbench should be used to implement the DSL infrastructure?
+
+## Decision Drivers
+
+- The primary IDE target had to be Visual Studio Code or IntelliJ, the author's two familiar, high-adoption editors, with first-class extension support
+- IDE features (syntax highlighting, autocomplete, jump-to-definition, inline errors) are a core value proposition of a textual DSL
+- A 15-week thesis timeline requires fast bootstrapping and minimal boilerplate
+- A post-thesis browser-based playground should be architecturally feasible
+- The workbench must support custom scoping, validation, and code generation to BPMN 2.0 XML
+- Error-tolerant incremental parsing is required for real-time editor feedback
+
+## Considered Options
+
+- Langium: TypeScript throughout, with built-in scoping, cross-reference resolution and validation infrastructure, and a Chevrotain parser with unbounded (ALL(\*)) lookahead.
+- Eclipse Xtext: the most mature workbench (20 years), with EMF integration giving native access to the BPMN 2.0 metamodel and a built-in AST-to-text serializer, but VS Code support needs a hybrid Java/TypeScript stack with a JVM backend, no browser deployment path exists, the learning curve is steep (EMF, Guice, Xtend, Eclipse, MWE2), and its maintenance future has been in question since a 2020 sustainability discussion.
+- MontiCore: has an existing BPMN Workflow DSL grammar, auto-generates pretty printers from grammars, and has strong language composition (inheritance, embedding, aggregation).
+ A language server and IntelliJ and VS Code plugins can be generated from a MontiCore grammar (Charles, Hellwig and Rumpe, Modellierung 2026), but its toolchain is Java, and the BPMN libraries this project builds on are TypeScript.
+- ANTLR with a manual LSP server: the most widely used parser generator, with full control and no framework lock-in, and grammar-aware completion via `antlr4-c3`, but every IDE feature would need to be implemented by hand, an estimated 3-5 weeks of the timeline, with no generated VS Code extension scaffolding.
+- JetBrains MPS: backed by JetBrains, with trivial language composition in its projectional editor, but it stores files as XML rather than human-readable text, has no VS Code or LSP integration, and is architecturally incompatible with a textual DSL.
+
+## Decision Outcome
+
+Chosen option: "Langium", primarily for its own ergonomics: a smaller, more approachable toolchain than Xtext's, and native access to the existing TypeScript BPMN tooling this project builds on: `bpmn-moddle` to read and write BPMN 2.0 XML, and `bpmn-auto-layout` to generate the Diagram Interchange (DI) data a textual syntax has no coordinate system to produce by hand.
+The IDE target followed from this choice rather than preceding it: `langium-cli` generates the AST types and a TextMate grammar from the grammar, the language server builds its parser, completion, scoping and validation on the same grammar at run time, and a one-time project template supplies the VS Code extension that hosts the server.
+That made Visual Studio Code the easier of the two viable targets to reach, whereas an IntelliJ plugin for Xtext would not have benefited from the same path.
+Browser deployment for a future playground was a secondary factor, all within a timeline-feasible learning curve.
+
+### Consequences
+
+- Good, because the entire stack is TypeScript, eliminating context-switching between languages
+- Good, because the grammar yields the AST types and syntax highlighting at build time, and the parser, completion, go-to-definition, and diagnostics at run time
+- Good, because the language server can run in a web worker, enabling a browser-based playground with approximately 95% code reuse
+- Good, because `bpmn-moddle` (the standard TypeScript library for BPMN 2.0 XML) integrates naturally
+- Good, because Langium is actively maintained by TypeFox as an Eclipse Foundation mature project
+- Bad, because Langium has no built-in AST-to-text serializer, requiring a handwritten emitter for the BPMN-to-DSL reverse transformation
+- Bad, because documentation has gaps for advanced topics such as custom scoping patterns
+- Neutral, because Langium is younger (5 years) than Xtext (20 years), meaning some edge cases are less tested
+
+## More Information
+
+Twelve workbenches were evaluated in total.
+Beyond the five listed above, Spoofax, Rascal, Racket, Neverlang, textX, Chevrotain, and Kotlin+ANTLR+lsp4j were assessed and excluded for reasons including: lack of production-ready VS Code support, abandoned IDE tooling, architectural mismatch (S-expression syntax, projectional editing), or infeasible learning curves for a 15-week timeline.
+
+Sources for the comparison:
+
+- [TypeFox: Xtext, Langium, what next?](https://www.typefox.io/blog/xtext-langium-what-next/)
+- [Langium 4.0 Release](https://www.typefox.io/blog/langium-release-4.0/)
+- [Call To Action: Secure the future maintenance of Xtext (GitHub #1721)](https://github.com/eclipse-xtext/xtext/issues/1721)
+- [Jordan & Zib: A Langium-based approach to BigER (TU Wien, 2024)](https://model-engineering.info/publications/theses/thesis-jordan-zib.pdf)
+- [Charles, Hellwig & Rumpe: Generating Language Servers for MontiCore-based DSLs (Modellierung 2026)](https://doi.org/10.18420/modellierung2026-08)
diff --git a/docs/decisions/0001-vscode-as-primary-ide-target.md b/docs/decisions/0004-use-vscode-as-primary-ide-target.md
similarity index 91%
rename from docs/decisions/0001-vscode-as-primary-ide-target.md
rename to docs/decisions/0004-use-vscode-as-primary-ide-target.md
index e482094..ef7911d 100644
--- a/docs/decisions/0001-vscode-as-primary-ide-target.md
+++ b/docs/decisions/0004-use-vscode-as-primary-ide-target.md
@@ -14,7 +14,7 @@ Which IDE should be the primary target?
## Decision Drivers
- IDE support is the core value proposition of a textual DSL
-- The chosen language workbench (Langium, see [ADR-0002](0002-use-langium-as-language-workbench.md)) natively targets VS Code
+- The chosen language workbench (ADR-0003) natively targets VS Code
- VS Code is the most widely used editor among developers
- The Language Server Protocol (LSP) enables secondary IDE support without full reimplementation
diff --git a/docs/decisions/0011-in-editor-conversion-sidebar.md b/docs/decisions/0005-in-editor-conversion-webview-sidebar.md
similarity index 86%
rename from docs/decisions/0011-in-editor-conversion-sidebar.md
rename to docs/decisions/0005-in-editor-conversion-webview-sidebar.md
index c680e6d..85cd107 100644
--- a/docs/decisions/0011-in-editor-conversion-sidebar.md
+++ b/docs/decisions/0005-in-editor-conversion-webview-sidebar.md
@@ -4,18 +4,17 @@ date: 2026-06-28
decision-makers: Marlon Kranz
---
-# In-Editor Conversion: Webview Sidebar, Two-Layer Architecture, and CJS Bundling
+# In-Editor Conversion: Webview Sidebar and Two Layers
## Context and Problem Statement
The only way to compile `.bpmnscript` to `.bpmn` or decompile `.bpmn` to `.bpmnscript` was the `bpmns` CLI.
The CLI entry points (`buildAction`/`parseAction`) bake in `process.exit()` and `console.log`, making them unusable inside the VS Code extension host.
-
Three related questions arose.
Which surface should the in-editor conversion use, a native VS Code tree view or a webview panel in a dedicated activity-bar container?
How should the conversion logic be structured so it can be tested without the VS Code host, which is unavailable under plain vitest?
And how should the `operaton-moddle.json` lookup be resolved for the bundled extension?
-`@bpmn-script/transform` calls `fileURLToPath(import.meta.url)` at module-init time to locate that file, and esbuild's CJS output sets `import.meta.url` to `undefined`, causing `ERR_INVALID_ARG_TYPE` on extension activation, the same JSON-asset footgun seen in `packages/transform/src/`.
+`@bpmn-script/transform` calls `fileURLToPath(import.meta.url)` at module-init time to locate that file, and esbuild's CJS output sets `import.meta.url` to `undefined`, causing `ERR_INVALID_ARG_TYPE` on extension activation.
## Decision Drivers
@@ -39,14 +38,14 @@ For the bundling fix:
## Decision Outcome
The UI is a webview panel in a dedicated "BPMNscript" activity-bar container (view id `bpmnscript.sidebar`, name "Convert").
-The panel renders a small custom layout for the active file: its name, one convert button, a link to the counterpart file when it exists, and a "pick a BPMN file" action.
+A webview gives direct control over the panel layout and leaves room to grow it, for example with an inline diagram preview, in a way a tree view's fixed row model would not.
It uses only `--vscode-*` CSS custom properties, so it tracks the host theme without any external toolkit dependency.
-A webview gives direct control over that layout and leaves room to grow it, for example with an inline diagram preview, in a way a tree view's fixed row model would not.
The architecture is a two-layer split: a pure core (`conversion-core.ts`, no `vscode` import) and a thin VS Code adapter (`conversion.ts`).
The core contains all interesting logic (severity gating, error classification, unsupported-element handling) and is unit-testable under vitest.
-The adapter owns only VS Code I/O: URI resolution, file reads and writes, `DiagnosticCollection` updates, overwrite confirmation, and opening the result.
-The commands (`bpmnscript.compile`, `bpmnscript.decompile`, and `bpmnscript.openAndDecompile`, which picks a BPMN file and runs the decompile handler on it) are the single execution path.
+The adapter owns only VS Code I/O: URI resolution, file reads and writes, focusing the Problems panel, overwrite confirmation, and opening the result.
+The panel renders a small layout for the active file: its name, one convert button, a link to the counterpart file when it exists, and an action that picks a BPMN file.
+The commands `bpmnscript.compile`, `bpmnscript.decompile` and `bpmnscript.openAndDecompile`, the last of which picks a BPMN file and runs the decompile handler on it, are the single execution path.
The sidebar and the command palette call the same commands with a URI argument, so behavior is identical across entry points.
The bundling fix is an esbuild CJS shim plus an `onEnd` asset copy, keeping the transform package unmodified.
diff --git a/docs/decisions/0006-target-users-without-bpmn-knowledge.md b/docs/decisions/0006-target-users-without-bpmn-knowledge.md
new file mode 100644
index 0000000..6f45ef7
--- /dev/null
+++ b/docs/decisions/0006-target-users-without-bpmn-knowledge.md
@@ -0,0 +1,71 @@
+---
+status: accepted
+date: 2026-06-30
+decision-makers: Marlon Kranz, Oliver Kopp
+---
+
+# Target users without BPMN knowledge, and minimize boilerplate
+
+## Context and Problem Statement
+
+ADR-0011 gave BPMNscript a structured, code-like surface aimed at developers who prefer working in code.
+It left two questions open: how much BPMN literacy the language may assume, and how much required syntax is acceptable.
+Keywords, settings, and event declarations could still presuppose familiarity with BPMN's element types and vocabulary, and the grammar could still demand explicit text where the compiler could supply a default.
+A third question came with a proposal to push the boilerplate rule past the model: write the body of a `JavaDelegate`'s `execute` method directly in the `.bpmnscript` file, generate the complete Java class from it, scaffold the surrounding Operaton application (build configuration, Spring Boot wiring, resources), and generate tests for the result.
+
+How much BPMN knowledge may the language assume, how much required syntax is acceptable, and does the host application's boilerplate belong to the language too?
+
+Decided in the supervision meeting of 2026-06-30.
+
+## Decision Drivers
+
+- BPMN-literate users already have a well-supported option: the graphical modeler.
+ The textual language is motivated by the other population, for whom BPMN vocabulary is a barrier rather than a help.
+- Required syntax that carries no process information raises the entry barrier without paying for itself.
+- ADR-0011's structured constructs already hide gateway mechanics behind `if`/`while`/`parallel`.
+ Without an explicit audience decision, future grammar work has no tiebreaker between "closer to BPMN" and "simpler for newcomers".
+- Bidirectional conversion between DSL and BPMN XML is a core property of the tool, so text the target format has no slot for cannot enter the surface.
+- ADR-0007 keeps the IR a clean graph of process semantics, not a store for host-language code.
+
+## Considered Options
+
+- Assume BPMN literacy: mirror BPMN terminology and structure, keeping the text close to the XML it compiles to
+- Assume general programming literacy but no BPMN knowledge, and minimize required syntax
+- Go further and take the host application's boilerplate too: embed Java method bodies in the DSL and generate the delegate classes, a complete Operaton application, and tests
+
+## Decision Outcome
+
+Chosen option: "Assume general programming literacy but no BPMN knowledge, and minimize required syntax".
+Concretely, two rules bind future grammar decisions:
+
+1. No BPMN prerequisite.
+ Reading or writing a `.bpmnscript` file must not require knowing BPMN.
+ Keywords name what the user means (a step a person performs, a step the system performs, a decision), not the BPMN element behind it.
+2. Defaults over declarations.
+ Whenever the compiler can infer or synthesize a detail, the grammar must not require the user to write it.
+ When two designs express the same process, the one with less required text wins.
+
+Implicit sequence flow (ADR-0011) and synthesized structural ids (ADR-0010) already follow both rules; this decision makes them binding for what comes next.
+
+The second rule stops at the compiler's output boundary, which is BPMN XML.
+`bpmns build` compiles a `.bpmnscript` file to a `.bpmn` file, `bpmns parse` decompiles it back, and delegate classes are hand-written in a separate host application, as in `examples/spring-boot/`.
+Embedding a method body would give up the two properties the design rests on: BPMN XML has no representation for Java source, so an embedded body is lost on compile and cannot be recovered on decompile, and carrying it through the IR pierces ADR-0007's boundary.
+It would also change what deployment means.
+BPMN XML alone hot-deploys through a single REST call (`POST /engine-rest/deployment/create`), while a Java class has to be on the engine's classpath, so every change to an embedded body forces a rebuild and restart of the host application and the deployment unit becomes the application rather than the model.
+The generated scaffold is a long-lived support surface of its own, in dependency versions, upgrade path and build tooling, beyond the thesis time budget.
+
+### Consequences
+
+- Good, because the entry barrier drops: a newcomer can write a running process without first learning BPMN's vocabulary or diagram semantics.
+- Good, because grammar discussions get a tiebreaker instead of re-arguing the audience each time.
+- Good, because no lossy construct enters the language and the IR stays free of host-language code, so the round-trip guarantees hold for everything the surface admits.
+- Good, because a deploy command stays a thin REST client, the mechanics the end-to-end test adapters (`tests/fixtures/adapters/`) already exercise.
+- Bad, because hiding BPMN vocabulary makes the DSL-to-XML correspondence less obvious for BPMN-literate readers; documentation has to carry that mapping, and the README glossary is a start.
+- Bad, because every default the grammar adopts is a rule the round trip must invert, synthesized on compile and elided on decompile, growing the mapping surface that ADR-0010 normalization already covers for ids.
+- Bad, because delegates are written and kept in sync by hand; the `class: "..."` string is not checked against any Java source, so a mismatch surfaces only at engine runtime.
+- Bad, because the one-file authoring experience is not available: a process and the code its steps call live in separate files.
+
+## More Information
+
+The engine tuning keys (`asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, `retryCycle`) and the listener block are engine-side settings outside the no-BPMN-knowledge promise: they name what Operaton does with a step, not a BPMN element, and a reader who never tunes the engine never writes them.
+Generating tests for the compiled `.bpmn` artifact stands on its own: it consumes the compiler's existing output rather than adding Java source to the language, so none of the round-trip or IR concerns apply to it, and it is set aside as possible later work.
diff --git a/docs/decisions/0006-engine-agnostic-intermediate-representation.md b/docs/decisions/0007-intermediate-representation-ast-bpmn.md
similarity index 84%
rename from docs/decisions/0006-engine-agnostic-intermediate-representation.md
rename to docs/decisions/0007-intermediate-representation-ast-bpmn.md
index f939ece..637ed83 100644
--- a/docs/decisions/0006-engine-agnostic-intermediate-representation.md
+++ b/docs/decisions/0007-intermediate-representation-ast-bpmn.md
@@ -4,7 +4,7 @@ date: 2026-04-13
decision-makers: Marlon Kranz
---
-# Use an Intermediate Representation between the AST and BPMN XML
+# An intermediate representation between the AST and BPMN XML
## Context and Problem Statement
@@ -17,7 +17,7 @@ Should both directions route through a shared intermediate representation (IR),
## Decision Drivers
-- The two transforms are the hardest code in the project: gateway synthesis on the way down, dominator-based restructuring on the way up (ADR-0009, ADR-0010).
+- The two transforms are the hardest code in the project: gateway synthesis on the way down, dominator-based restructuring on the way up.
Both need a graph model to run against, and neither the block-structured AST nor moddle's serialization-bound object model is one.
- A shared model lets the two directions meet in the middle, so the round-trip is one model with four transforms instead of two independent converters.
Round-trip idempotence then becomes a property of a single representation.
@@ -37,9 +37,8 @@ Gateway synthesis (`astToIr`) and structural recovery (`irToDsl`) both operate o
Because both directions share it, the round-trip is verified against one representation rather than two.
Portability across execution engines is a non-goal.
-The project targets Operaton (ADR-0007), so the IR is not abstracted for other engines.
-It models the semantics Operaton executes under field names that carry no vendor prefix: executable processes, service tasks bound to a Java class, a JUEL expression, a delegate expression, or an external topic, script tasks, and process variables.
-Attributes that vary only at serialization, such as `targetNamespace`, are attached at the IR-to-XML boundary and are not stored in the IR.
+The project targets Operaton (ADR-0008), so the IR is not abstracted for other engines.
+It models the semantics Operaton executes under field names that carry no vendor prefix, with attributes that vary only at serialization, such as `targetNamespace`, attached at the IR-to-XML boundary rather than stored in the IR.
### Consequences
diff --git a/docs/decisions/0007-operaton-moddle-extension-fork.md b/docs/decisions/0008-fork-camunda-moddle-extension.md
similarity index 61%
rename from docs/decisions/0007-operaton-moddle-extension-fork.md
rename to docs/decisions/0008-fork-camunda-moddle-extension.md
index be9b826..c14c52c 100644
--- a/docs/decisions/0007-operaton-moddle-extension-fork.md
+++ b/docs/decisions/0008-fork-camunda-moddle-extension.md
@@ -4,7 +4,7 @@ date: 2026-05-21
decision-makers: Marlon Kranz
---
-# Fork the Camunda Moddle Extension as a Local Operaton Extension
+# Fork the Camunda moddle extension as a local Operaton one
## Context and Problem Statement
@@ -20,40 +20,23 @@ How should BPMNscript register Operaton's extension attributes with `bpmn-moddle
- Emitted XML must carry the `operaton:` prefix and namespace.
`camunda:` is accepted for legacy support, but Operaton officially recommends using `operaton:`.
- Registering two extensions that define the same attribute names on the same BPMN type causes a name collision in `bpmn-moddle`'s extension registry.
- That might break compatibility with future extensions supporting Camunda.
- Only a subset of Operaton's extension attributes is needed as of now; carrying the full Camunda descriptor adds unnecessary complexity.
- Minimizing external dependencies reduces maintenance burden.
## Considered Options
-- Load `camunda-bpmn-moddle` and post-process the serialized XML to rewrite the namespace
+- Load `camunda-bpmn-moddle` and post-process the serialized XML to rewrite the namespace, which reuses an established, maintained package but is fragile: string-replacing prefixes after serialization risks corrupting `xmlns` declarations or attribute values that contain the prefix string, and the `camunda:` and `operaton:` extensions cannot be loaded side by side because `bpmn-moddle` collides on identically named attributes
- Ship a trimmed fork of the Camunda descriptor with prefix and URI replaced
## Decision Outcome
Chosen option: "Ship a trimmed fork", because it avoids namespace collisions, eliminates fragile string-replacement post-processing, and keeps the descriptor small and auditable.
+The fork is based on `camunda-bpmn-moddle/resources/camunda.json` from the `camunda/camunda-bpmn-moddle` repository on GitHub, with the prefix and namespace URI replaced and only the attributes BPMNscript uses kept.
### Consequences
-- Good, because emitted XML correctly carries `xmlns:operaton` and `operaton:`-prefixed attributes without post-processing
-- Good, because the descriptor defines only the attributes actually used, which keeps it easy to review and extend
-- Good, because no external dependency is needed for the Operaton namespace
-- Bad, because if Operaton publishes an official moddle extension in the future, the local fork must be replaced
-
-## Pros and Cons of the Options
-
-### Load `camunda-bpmn-moddle` and rewrite the XML
-
-- Good, because it reuses an established, maintained package
-- Bad, because string-replacing namespace prefixes after serialization is fragile: it can corrupt `xmlns` declarations or attribute values that happen to contain the prefix string
-- Bad, because the `camunda:` and `operaton:` extensions cannot be loaded side-by-side due to `bpmn-moddle`'s property name collision on identically named attributes
-
-### Ship a trimmed local fork
-
-- Good, because the descriptor is self-contained and carries no runtime dependency
-- Good, because only the needed attributes are defined, keeping the extension surface minimal
-- Bad, because it must be maintained manually when new Operaton attributes are needed
-
-## More Information
-
-The fork is based on `camunda-bpmn-moddle/resources/camunda.json` from the bpmn-io GitHub organization.
+- Good, because emitted XML correctly carries `xmlns:operaton` and `operaton:`-prefixed attributes without post-processing.
+- Good, because the descriptor defines only the attributes actually used, which keeps it easy to review and extend.
+- Good, because no external dependency is needed for the Operaton namespace.
+- Bad, because if Operaton publishes an official moddle extension in the future, the local fork must be replaced.
+- Bad, because the fork must be maintained manually when new Operaton attributes are needed.
diff --git a/docs/decisions/0008-structured-grammar.md b/docs/decisions/0008-structured-grammar.md
deleted file mode 100644
index 2a7b17d..0000000
--- a/docs/decisions/0008-structured-grammar.md
+++ /dev/null
@@ -1,54 +0,0 @@
----
-status: accepted
-date: 2026-06-12
-decision-makers: Marlon Kranz
----
-
-# Use a Structured, Code-Like Grammar
-
-## Context and Problem Statement
-
-A BPMN DSL can expose its surface syntax in two broad shapes.
-One is a flat declaration-plus-edges form: every flow node (`start`, `user`, `gateway`, `service`, `end`) is declared at the top level, and explicit `->` edges (with `when:`, `as:`, `default:`) connect them.
-This maps directly to the BPMN graph model, but it reads like a diagram description rather than code, and offers little advantage over editing BPMN XML for developers who think in control-flow terms.
-The other is a structured, code-like language with implicit sequence flow and block-scoped control statements.
-
-Which surface should BPMNscript use: a flat graph declaration, or a structured, code-like language with implicit sequence flow and block-scoped control statements?
-
-## Decision Drivers
-
-- The thesis goal is a textual DSL that serves developers who prefer working in code
-- IDE support, meaning inline errors, type-aware validation, and jump-to-definition, is far richer for a real expression AST than for opaque condition strings
-- Authoring a flat graph requires the same mental model as a BPMN diagram; a structured language is closer to how developers already write sequential logic
-- The round-trip direction (BPMN XML to DSL) needs a `goto`-capable fallback for unstructured graphs, and the structured surface accommodates this naturally
-- A JUEL-subset expression sub-language parsed to a real AST is needed to enable type-check diagnostics on condition expressions
-
-## Considered Options
-
-- Structured code-like syntax (keyword + braces, implicit flow, `if`/`while`/`parallel`/`goto`)
-- A flat node/edge syntax (explicit declarations + `->` edges, mapping 1:1 to the BPMN graph)
-- Hybrid: named blocks for common patterns, with a fallback to explicit edges
-
-## Decision Outcome
-
-Chosen option: "Structured code-like syntax", because it matches how developers already write sequential logic.
-Conditions become a real expression AST, which is what makes type-check diagnostics possible; `goto` slots into the same surface as the decompilation fallback for unstructured BPMN graphs.
-
-### Consequences
-
-- Good, because the DSL reads and writes like program code (`if`/`while`/`parallel`)
-- Good, because conditions are a first-class expression AST, enabling type-check validation and jump-to-definition for variable references
-- Good, because `parallel { { } { } }` maps directly to AND fork/join pairs, making parallel-gateway support natural to author
-- Good, because `goto` as a residual form keeps decompilation total: every valid BPMN graph has a valid DSL representation
-- Bad, because the desugaring (`astToIr`) must synthesize gateway pairs from block structure, adding complexity relative to a flat pass-through that mirrors the graph directly
-
-### Confirmation
-
-The construct round-trip idempotence test (`tests/round-trip-constructs.test.ts`) verifies that `if`/`else`, `while` and `parallel` survive `astToIr -> irToXml -> xmlToIr -> irToDsl` without losing structure.
-The conditioned `parallel` branch and the multi-branch `await` survive the same four hops in the frozen pair `tests/golden/branch-and-race.bpmnscript` and the BPMN generated from it.
-The goto-degradation path is exercised by `tests/golden/unstructured-goto.bpmn` in the same suite.
-Neither the conditioned branch nor the multi-branch `await` spent a reserved word, so the grammar header's claim that there is no `gateway` keyword and that every gateway is derived from a control-flow construct holds word for word.
-
-## More Information
-
-The JUEL-subset boundary, meaning what parses natively and what falls back to `"${...}"`, is fixed by the grammar's expression sub-rules in `packages/language/src/bpmn-script.langium` and mirrored by the hand-rolled parser in `packages/transform/src/juel.ts`.
diff --git a/docs/decisions/0009-auto-layout-and-expansion-hint.md b/docs/decisions/0009-auto-layout-and-expansion-hint.md
new file mode 100644
index 0000000..46e050b
--- /dev/null
+++ b/docs/decisions/0009-auto-layout-and-expansion-hint.md
@@ -0,0 +1,90 @@
+---
+status: accepted
+date: 2026-04-13
+decision-makers: Marlon Kranz
+---
+
+# Diagram interchange: auto-layout and the sub-process expansion hint
+
+## Context and Problem Statement
+
+BPMN 2.0 XML carries Diagram Interchange (DI) data that fixes the graphical layout of process elements: positions, dimensions and edge waypoints.
+A textual DSL has no coordinate system of its own, so it has to settle what happens to DI on export from the DSL and on import of an existing diagram.
+A nested container is part of the same question, since an embedded sub-process holds a plane of its own.
+
+## Decision Drivers
+
+- A textual DSL has no inherent coordinate system for graphical layout.
+- Preserving original DI data through a text-based round-trip is impractical.
+- Generated BPMN XML must be renderable by standard BPMN tools.
+- No layout computation of our own, since a bespoke box-and-offset algorithm would duplicate what a layout library already does.
+- DI is presentation only, so nothing about it may touch the semantic element tree or its ids.
+- A process without any sub-process must keep producing byte-identical output, which is the path the export pipeline is tested and deployed against.
+
+## Considered Options
+
+- Auto-layout the whole diagram on export, discard DI on import, and pre-seed a minimal `isExpanded` shape hint per sub-process so the library expands nested containers
+- Preserve DI data in a side channel, as annotations in the source or as a separate file, impractical for a text-based round-trip
+- Include layout hints in the DSL syntax, which would put graphical coordinates into a language meant to describe process semantics only
+- Accept the collapsed sub-process rendering and post-process the output to strip the misplaced child shapes, which still hides an embedded sub-process's children from a viewer and is more code than the hint for a worse result
+- Compute sub-process bounds and child placement manually, bypassing the layout library for nested containers, which reintroduces the custom layout code this decision exists to avoid for one case and is an open-ended burden to keep visually consistent with the library
+
+## Decision Outcome
+
+Chosen option: auto-layout on export with a pre-seeded expansion hint, and DI discarded on import, because a textual DSL has no meaningful way to represent or preserve graphical coordinates, and `bpmn-auto-layout` (the bpmn.io layout library) produces adequate diagrams from the process content alone.
+
+`irToXml` computes no coordinates.
+It serializes DI-less BPMN XML and hands it to `bpmn-auto-layout`, which lays out every element and injects the `bpmndi:` diagram-interchange data.
+
+Fed DI-less XML containing a `bpmn:subProcess`, the library does not throw, and it does not produce a degraded-but-sane result either: it renders the sub-process as a collapsed box and scatters shapes for its nested children into the root plane, at coordinates that duplicate unrelated top-level elements.
+The output parses and validates, so nothing signals the problem, while a viewer opening the generated diagram shows the sub-process's children floating on top of sibling top-level elements instead of nested inside their parent.
+The library reads an existing shape's `isExpanded` flag before computing bounds, and simply had nothing to read when no shape pre-existed.
+Supplying that one boolean per sub-process is the smallest input that makes it take the branch it already has for expanded containers.
+
+Before calling `moddle.toXML`, `irToXml` walks the built moddle tree for every `bpmn:SubProcess` element at any nesting depth.
+Only when at least one exists, it attaches a `bpmndi:BPMNDiagram` holding a `bpmndi:BPMNPlane` rooted at the process, which in turn holds one `bpmndi:BPMNShape` per sub-process, each carrying `isExpanded="true"` and a `bpmnElement` reference to its sub-process.
+No `dc:Bounds` are supplied, since the library recomputes and discards any bounds on a pre-existing shape and derives correct ones from the expanded layout.
+What it does need, and what the shape carries for no other reason, is an `id`: it locates a pre-existing shape by looking it up in its own id-keyed element index while parsing the hinted XML, so a shape without one is invisible to it even though nothing in the final output ever references that id back.
+`bpmn-auto-layout` then discards every pre-existing diagram (`this.diagram.diagrams = []`) after reading the `isExpanded` flags off it and regenerates the diagram it actually serializes, so the hint is a one-shot instruction rather than persisted diagram data.
+
+### Consequences
+
+- Good, because the DSL syntax stays focused on process semantics and carries no coordinates.
+- Good, because the layout library produces consistent, readable diagrams and computes every bound and waypoint in the final document, with no coordinate math and no added dependency of ours.
+- Good, because a process without a sub-process attaches no hint at all, so its output is byte-for-byte unchanged.
+- Bad, because manually arranged layouts in imported BPMN files are lost after a round-trip.
+- Bad, because the hint's correctness is pinned to one specific behavior of the installed `bpmn-auto-layout` version, namely reading `isExpanded` off an id-keyed pre-existing shape.
+ A library upgrade that changes how it detects expansion could regress nested layout without any XML-validity signal.
+- Neutral, because users who need precise layout control can adjust the generated BPMN in a graphical editor after export.
+
+### Confirmation
+
+A regression tripwire in `ir-to-xml.test.ts` builds an IR whose sub-process has two or more children, runs it through the real `irToXml` with no mocks and the actual installed `bpmn-auto-layout`, and asserts every nested child's `bpmndi:BPMNShape` bounds fall strictly inside its parent sub-process's shape bounds.
+The same suite pins two-level nesting (inner sub-process inside outer, inner children inside inner), exactly one `bpmndi:BPMNDiagram` in the final output rather than a duplicate of the hint, and that a sub-process-free process's output stays byte-identical to the frozen golden.
+
+## More Information
+
+The hint is built by `buildSubProcessExpansionHint` and `collectSubProcessElements` in `packages/transform/src/ir-to-xml.ts`, called from `irToXml` right before serialization.
+The IR itself carries no layout information (ADR-0007), since the hint is derived purely from the moddle tree built for export and never stored.
+
+When `bpmn-auto-layout`'s grid solver throws on a graph it cannot place, `irToXml` raises `LayoutError`.
+It carries the document serialized just before the layout call, with no `bpmndi:` diagram.
+The CLI and the extension catch it, write that diagram-less document, and warn the user instead of losing the output.
+The engine deploys it unchanged: `BpmnParse.parseDiagramInterchangeElements` reads a diagram only when the document's `BPMNDiagram` list is non-empty, and a document with none skips diagram parsing and deploys on its process content alone.
+
+The layout as it stands has three known defects, each measured on the current tree.
+The measurement walks every flow node for a shape, every sequence flow for an edge, and every edge segment for an activity shape it crosses that is neither its source nor its target.
+First, a `goto` edge into a sub-process is routed across the sub-process's own children instead of around them.
+The example `order-handling`'s `Flow_MarkAutoApproved_Payment` crosses both the `Payment` sub-process's start event and its first task.
+The golden `boundary-events`' `Flow_MarkAddressVerified_PackGoods` crosses the `PackGoods` sub-process's start event and its first task the same way.
+
+Second, the spread of several boundary events on one host scales with the host's width rather than with the events' own.
+The library places the `i`-th of `n` attachers at `host.x + (i + 1) * host.width / (n + 1) - 18`, all on the host's lower edge.
+Two events on a 100 px task therefore sit 33 px apart at their centers while each is 36 px wide.
+In the golden `boundary-events`, the two events on `CheckAddress` (x 625 to 725) span x 640.3 to 676.3 and x 673.7 to 709.7, overlapping by 2.7 px.
+Two events overlap on any host narrower than 108 px, three times an event's width, and every further event narrows the spacing again; the two on the 850 px wide `PackGoods` sub-process land 283 px apart and clear each other.
+
+Third, a guard clause such as `if (chargeDeclined) { end BookingAbandoned }` keeps the join that only its default flow reaches (ADR-0031).
+The layouter puts the end event in the cell between the split and that join on the same row, so the default edge runs straight through it.
+The example `booking-attempt`'s `Flow_Gateway_booking-attempt_1_2_split_default` crosses `BookingAbandoned` this way, and the goldens `event-handlers` (`PaymentFailed`) and `transactions` (`BookingAbandoned`) carry the same shape.
+A border-termination pass for the first defect and a boundary-spread pass for the second are both considered options, not committed work; the third goes away with the join elision ADR-0031 defers.
diff --git a/docs/decisions/0009-dominator-based-restructuring.md b/docs/decisions/0009-dominator-based-restructuring.md
deleted file mode 100644
index 6b43c7b..0000000
--- a/docs/decisions/0009-dominator-based-restructuring.md
+++ /dev/null
@@ -1,84 +0,0 @@
----
-status: accepted
-date: 2026-06-12
-decision-makers: Marlon Kranz
----
-
-# Use Dominator/Post-Dominator Analysis for IR-to-DSL Restructuring
-
-## Context and Problem Statement
-
-The decompile direction (`irToDsl`) must turn a flat BPMN graph (the IR) back into structured BPMNscript source.
-The graph may have come from a graphical modeler and may be structured, partially structured, or entirely unstructured (irreducible).
-
-How should `irToDsl` identify which subgraphs can be expressed as `if`/`while`/`parallel` blocks, and what should happen for subgraphs that cannot?
-
-## Decision Drivers
-
-- The reconstruction must be total: every valid IR must produce a valid DSL string and never throw, because the CLI `parse` command must always produce output
-- Structured constructs (`if`/`while`/`parallel`) should be recovered where possible so the decompiled output is readable and round-trips without information loss
-- The algorithm must handle AND fork/join pairs (parallel gateways), not just XOR
-- Unstructured graphs (irreducible control flow, cross-branch gotos) must not crash the decompiler; they degrade to `goto` statements
-- The logic must be isolated and separately testable, not entangled with IR types or the grammar
-
-## Considered Options
-
-- Dominator/post-dominator analysis with a fixed pattern catalog and `goto` fallback
-- RPST (Refined Program Structure Tree) decomposition
-- Ad-hoc recursive pattern matching without formal CFG analysis
-
-## Decision Outcome
-
-Chosen option: "Dominator/post-dominator analysis with a fixed pattern catalog and `goto` fallback".
-Dominator analysis gives a mechanically checkable criterion for each structured construct, and most edges the catalog cannot fold degrade to `goto`.
-A statement with more than one route out prints as an `if` chain, because a statement is one position with one way on and a jump written beside the fall-through would end the block, cutting off the chain that fall-through carries.
-Every route gets a form: a condition heads its branch, and a route carrying none heads a branch as `true`, takes the chain's `else`, or runs straight into the join as the chain's fall-through.
-Heading that last route instead would put a `true` over an empty branch and leave the rest of the chain unreachable.
-Where a split degrades instead, its routes leave as jumps, and each jump takes a branch of such a chain, since a jump ends its block and a second one written beside the first could never run.
-Some edges have no `goto` form at all.
-An edge arriving at a gateway that still chooses between branches cannot be named, because a `goto` names a statement and a gateway has none, so the jump is only expressible through the gateway's successor and only while the routing has a single outcome.
-An edge whose target the printer elides cannot be named either, the elided element leaving no statement behind for a jump to spell.
-Such an edge is dropped, a marker comment is printed where it would have gone naming the element it led into, and `irToDsl` reports the drop on a warnings channel of its own.
-The marker comment stays in the printed source, where it is the reader's pointer to the place needing repair.
-
-The pattern catalog:
-
-- XOR split with a post-dominating join -> `if`/`else if`/`else`
-- Statement with more than one route out -> the same `if` chain, a conditioned route heading a branch and an unconditioned route running straight into the join taken as the chain's fall-through
-- Unconditioned back-edge from a body exit to the XOR head dominating it -> `while`, the loop condition read from the head's edge into the body
-- Conditioned back-edge from an XOR head to the body entry dominating it -> `do...while`, the loop condition read from the back-edge itself
-- AND fork with a matching AND join -> `parallel { { } { } }`
-- OR fork with a matching OR join -> the same `parallel` block with each conditioned branch headed by its condition, and the fallback flow heading a branch as `else`.
- A fallback that runs straight into the join is left out where enough branches remain to fill the block, and prints as an empty `else` where dropping it would leave too few.
-- Event-based gateway whose every outgoing flow reaches an intermediate catch event -> `await { ... }` with one branch per catch, continuing at the exclusive merge the branches share when they have one
-- Every other edge -> `goto `, or a dropped-edge marker where the edge has no name to jump to
-
-A gateway a pattern folds is never printed, which is what keeps the round trip idempotent.
-
-### Consequences
-
-- Good, because the algorithm terminates and produces parseable DSL for every IR (total over the supported scope)
-- Good, because AND fork/join pairs are recovered as `parallel` blocks without special-casing the decompiler
-- Good, because the CFG analysis (`cfg-analysis.ts`) is a pure, stateless utility with its own test suite, so it can be audited independently of the emitter
-- Good, because the restructurer reports what it drops instead of leaving the caller to find it, including where the drop changes what a recompiled document runs.
- A fallback re-derived on an imported OR fork that named none is one such report: Operaton's `InclusiveGatewayActivityBehavior` throws a stuck execution when every branch of such a fork carries a condition and none of them holds, while the printed block falls through, so a document recompiled from that script runs on where the model would have stopped.
- A split whose branches the catalog cannot fold is reported too, its edges written as jumps, and a jump ends the path it sits on where the split opened several.
-- Neutral, because RPST would recover more structured patterns (for example nested switch-like gotos) but is left for later, once the scope justifies the added machinery
-- Bad, because topology-based back-edge disambiguation (while versus do-while) requires checking the `conditionExpression` field, not just graph shape
-- Bad, because the decompiler always produces parseable source but not always source that validates, and not every edge survives
-- Bad, because a statement whose own routes split prints as an `if` chain, which takes one route where the model takes every route it can at once, so what a recompiled document runs changes; the print hop reports it
-
-### Confirmation
-
-`irToDsl` is verified total by the unit test suite (`packages/transform/test/`): every test input produces source and its warnings, and never throws.
-The goto-degradation path is confirmed by `tests/golden/unstructured-goto.bpmn` in `tests/round-trip-constructs.test.ts`.
-
-## More Information
-
-The CFG analysis utility lives at `packages/transform/src/cfg-analysis.ts` and exposes `analyzeCfg(process): CfgAnalysis` with `immediateDominator`, `immediatePostDominator`, `dominates`, `postDominates`, `backEdges`, `outgoing`, and `incoming` queries.
-`VIRTUAL_ENTRY` and `VIRTUAL_EXIT` constants give the dominator algorithm a unique single entry and exit.
-
-Amended by ADR-0036, which lets a bare authored terminal print inline in a guard clause when the split's own route is its only incoming flow, rather than staying a `goto`.
-
-RPST decomposition is left for later.
-It would recover more structured patterns, but the dominator-based catalog with a `goto` fallback already covers the current scope, so the added machinery is not yet justified.
diff --git a/docs/decisions/0010-deterministic-structural-ids.md b/docs/decisions/0010-deterministic-structural-ids.md
deleted file mode 100644
index 3b45ad3..0000000
--- a/docs/decisions/0010-deterministic-structural-ids.md
+++ /dev/null
@@ -1,94 +0,0 @@
----
-status: accepted
-date: 2026-06-12
-decision-makers: Marlon Kranz
----
-
-# Use Deterministic Structural Ids for Synthesized BPMN Elements
-
-## Context and Problem Statement
-
-The `astToIr` desugarer gives an id to every BPMN element the DSL source leaves unnamed: the exclusive gateway pair enclosing an `if`/`else`, the loop-head gateway for `while`/`do...while`, the fork/join pair for `parallel`, the event-based gateway and exclusive merge for a multi-branch `await`, the catch event each `await` waits at, the boundary event a hosted handler attaches with, the event sub-process a host-less handler becomes, an `emit` or `throw` written without a name, and implicit start/end events.
-The `irToDsl` restructurer also synthesizes flow ids when emitting the DSL.
-
-How should these synthesized ids be generated?
-The options are random/UUID, a sequential counter, or a deterministic function of the element's structural position in the source.
-
-## Decision Drivers
-
-- Re-compiling the same source must produce the same BPMN XML; non-deterministic ids would make the output non-reproducible and break byte-comparison golden tests
-- The round-trip normalizer (`tests/helpers/normalize-ir.ts`) must be able to recognize and re-key synthesized ids, which is only practical if the id scheme is stable and documented
-- Sequential counters depend on traversal order, making ids sensitive to unrelated source changes
-- Ids derived from structural position are self-documenting and survive refactoring of unrelated parts of the process
-
-## Considered Options
-
-- UUIDs (random, non-deterministic)
-- Sequential counter (deterministic but traversal-order-sensitive)
-- Structural coordinate: id derived from the enclosing element's position in the process body
-
-## Decision Outcome
-
-Chosen option: "Structural coordinate".
-Deriving each id from the element's position in the source makes recompilation reproducible, keeps golden tests stable, and lets a reader trace a synthesized id back to the statement that produced it.
-
-The frozen id templates (from `packages/transform/src/synthesize-ids.ts`):
-
-| Template | Produced by | Example |
-| --------------------------- | ------------------------------ | ----------------------------------------------------- |
-| `Gateway__split` | `makeGatewaySplitId` | `Gateway_invoice-approval_2_split` |
-| `Gateway__join` | `makeGatewayJoinId` | `Gateway_invoice-approval_2_join` |
-| `Gateway__fork` | `makeGatewayForkId` | `Gateway_invoice-approval_4_fork` |
-| `Gateway__race` | `makeGatewayRaceId` | `Gateway_invoice-approval_5_race` |
-| `Gateway__loop` | `makeGatewayLoopId` | `Gateway_invoice-approval_3_loop` |
-| `Flow__default` | `makeDefaultFlowId` | `Flow_Gateway_invoice-approval_2_split_default` |
-| `Flow__` | `makeSequenceFlowId` | `Flow_ReviewInvoice_Gateway_invoice-approval_2_split` |
-| `StartEvent_` | `makeStartEventId` | `StartEvent_invoice-approval` |
-| `EndEvent_` | `makeEndEventId` | `EndEvent_invoice-approval` |
-| `Throw_` | `makeThrowEventId` | `Throw_invoice-approval_2` |
-| `EventSubProcess_` | `makeEventSubProcessId` | `EventSubProcess_invoice-approval_1` |
-| `Catch_` | `makeIntermediateCatchEventId` | `Catch_order-handling_5_b0` |
-| `Boundary__` | `makeBoundaryEventId` | `Boundary_ApprovePayout_timer` |
-
-The structural coordinate `` for a compound statement at body index `i` inside a process with id `P` is `P_i`.
-For nested compounds the parent coordinate is prepended (`P_i_j`).
-Branch segments distinguish sibling blocks within a compound: `_t` for the if-then block, `_e` (0-based) for else-if branches, `_e` for the else block, and `_b` for the branches of a `parallel` and of a multi-branch `await`.
-Loop and sub-process bodies carry no segment, since a single block has no sibling to disambiguate against.
-A sub-process body's enclosing coordinate is the sub-process's own ``, so gateways inside it stay positional.
-Implicit start/end events synthesized inside a container are seeded from that container's id, the process id at the top level and the sub-process name inside a sub-process, resolved against one process-wide taken set so every id is document-unique.
-
-Collision resolution: if a base id is already in the taken set, append `_2`, `_3`, and so on until a free slot is found.
-
-Gateway ids skip the `taken`/`resolveCollision` guard, because the position-path scheme never generates the same id twice.
-What collides with them is an author-chosen statement name matching a synthesized-id pattern.
-The `checkReservedNames` validator in `packages/language/src/bpmn-script-validator.ts` rejects such names at validation time: `Gateway_*_(split|join|fork|loop|race)`, `StartEvent_*`, `EndEvent_*`, `Throw_*`, `EventSubProcess_*`, `Boundary_*`, `Catch_*`, and the two-segment `Flow_*_*` form (`/^Flow_.+_.+$/`).
-Single-segment names such as `Flow_Control` stay legal, because synthesized flow ids occupy only `SequenceFlow.id` and never node names, so they cannot collide.
-
-A flow id collides without any reserved name being written, because the flow into a statement is `Flow__` and a split that names a fallback holds `Flow__default` back for it: an `if` chain's fallback, an inclusive fork's fallback, and either loop's exit.
-An AND fork and a race reserve nothing, having no fallback to reserve for.
-A statement named `default` in an earlier branch of such a split spells that string, and the document would ship two sequence flows under one id, which is not valid BPMN.
-`reserveDefaultFlowId` (`packages/transform/src/ast-to-ir.ts`) claims the reserved string before any branch is lowered, so the author's flow takes the next free slot instead.
-
-### Consequences
-
-- Good, because re-compiling the same source always produces the same BPMN ids
-- Good, because golden files remain stable across unrelated source changes
-- Good, because the id scheme is self-documenting: reading a gateway id reveals its position in the source
-- Good, because `synthesize-ids.ts` is a pure, dependency-free module with its own test suite
-- Neutral, because the structural coordinate is longer than a UUID, making gateway ids verbose in deep nesting, mitigated by the fact that deeply nested processes are rare in the current scope
-- Bad, because renaming a process id or reordering top-level statements changes all synthesized ids in that process, which breaks deployed BPMN definitions.
- That is a concern for production use and acceptable for a DSL-authoring workflow where recompile is expected to replace the definition.
-- Bad, because a start or end whose own id matches one of the reserved patterns is left out of the printed source wherever the printer writes no statement for it, and its label comes back as an import warning instead.
- An element the printer does write a statement for keeps such an id verbatim, and the print reports it, because the compiler turns that name down when the script is read back.
- This is the status quo rather than a change in behavior, and resolving it at the source would mean narrowing the reserved patterns or renaming such an element on decompile.
-
-### Confirmation
-
-The `synthesize-ids.test.ts` suite verifies every template and collision-resolution rule.
-The round-trip normalizer (`tests/helpers/normalize-ir.ts`) uses the id patterns to re-key synthesized ids before comparing IR snapshots.
-
-## More Information
-
-The id templates are frozen, because they are consumed by `astToIr`, `irToDsl`, and the round-trip normalizer.
-Any change requires updating all three consumers and regenerating the `invoice-approval-generated.bpmn` golden file.
-The frozen contract is documented in the header of `packages/transform/src/synthesize-ids.ts`.
diff --git a/docs/decisions/0010-deterministic-synthesized-ids.md b/docs/decisions/0010-deterministic-synthesized-ids.md
new file mode 100644
index 0000000..b88d88d
--- /dev/null
+++ b/docs/decisions/0010-deterministic-synthesized-ids.md
@@ -0,0 +1,138 @@
+---
+status: accepted
+date: 2026-06-12
+decision-makers: Marlon Kranz
+---
+
+# Deterministic ids for synthesized elements, reserved and respelled
+
+## Context and Problem Statement
+
+The `astToIr` desugarer gives an id to every BPMN element the DSL source leaves unnamed: the exclusive gateway pair enclosing an `if`/`else`, the loop-head gateway for `while`/`do...while`, the fork/join pair for `parallel`, the event-based gateway and exclusive merge for a multi-branch `await`, the catch event each `await` waits at, the boundary event a hosted handler attaches with, the event sub-process a host-less handler becomes, an `emit` or `throw` written without a name, and implicit start/end events.
+It also gives every sequence flow its id, since the script never writes a flow: `irToDsl` prints none of the flow ids an imported document carries, and the compiler mints them again when the printed source is compiled.
+How should these ids be generated, and what does an author's own name have to stay clear of so that the compiler never mints a second element under an id the document already carries?
+
+The spelling of an author's id is the other half of the same question.
+A BPMN id is an `xs:ID`, while the script's name slot is the `ID` terminal: letters, digits and `_`, a `-` between them, and no keyword.
+An id such as `Task.1`, `user`, `Review-` or `WFP-6-` (the MIWG reference models) is a legal id the script cannot spell.
+The Modeler names its default start `StartEvent_1`, and 25 of 44 real exported diagrams sampled from public example repositories carry that id, so whatever the validator reserves for a start decides whether a typical Modeler diagram imports intact.
+Renaming such an element is not a fix the user can apply without cost: the engine keys history (`HistoricActivityInstance.getActivityId`), migration plans (`MigrationPlanBuilder.mapActivities`) and a modification (`InstantiationBuilder.startBeforeActivity`) on the activity id.
+
+## Decision Drivers
+
+- Re-compiling the same source must produce the same BPMN XML; non-deterministic ids would make the output non-reproducible and break byte-comparison golden tests.
+- The round-trip normalizer (`tests/helpers/normalize-ir.ts`) must be able to recognize and re-key synthesized ids, which is only practical if the id scheme is stable and documented.
+- Sequential counters depend on traversal order, making ids sensitive to unrelated source changes.
+- Ids derived from structural position are self-documenting and survive refactoring of unrelated parts of the process.
+- The import contract ([ADR-0012](0012-honest-bpmn-import.md#decision-outcome)): a document that deploys imports, unless it marks more than one process executable, holds an event without an id that the engine deploys but cannot run, or uses a construct this surface cannot spell, and every change to what runs is reported.
+- An activity id is the key the engine's history and migration match on, so a changed id is a changed process, and the report has to say so.
+- One fact, one home: the shape of a name is the `ID` terminal, and the transform spells it once for both directions.
+
+## Considered Options
+
+- Structural coordinate: an id derived from the element's position in the process body, with the exact minted ids reserved against author names and an unspellable id respelled on print, matching exactly what the compiler mints so every legal document prints as source that recompiles unchanged
+- UUIDs, random and non-deterministic
+- A sequential counter, deterministic but traversal-order-sensitive
+- Reserving a `StartEvent_` and `EndEvent_` prefix rather than the exact minted ids, and renaming a Modeler default start on import: one rule for both directions without a per-container lookup, but renaming the one element every Modeler diagram has changes most imports' activity id, and without the rename that start is either dropped from the script with its label and initiator or printed and refused on read-back
+- Refusing every id the script cannot spell: nothing then prints under a name the model does not carry, but the MIWG reference models and any diagram with a dotted or hyphen-terminated id would not import at all, although the engine deploys them
+
+## Decision Outcome
+
+Chosen option: the structural coordinate with exactly reserved names.
+Deriving each id from the element's position in the source makes recompilation reproducible, keeps golden tests stable, and lets a reader trace a synthesized id back to the statement that produced it.
+Reserving the exact forms the compiler mints, and respelling an id the script cannot spell, keeps every document importable while reporting the one change it makes.
+
+The frozen id templates (from `packages/transform/src/synthesize-ids.ts`):
+
+| Template | Produced by | Example |
+| --------------------------- | ------------------------------ | ----------------------------------------------------- |
+| `Gateway__split` | `makeGatewaySplitId` | `Gateway_invoice-approval_2_split` |
+| `Gateway__join` | `makeGatewayJoinId` | `Gateway_invoice-approval_2_join` |
+| `Gateway__fork` | `makeGatewayForkId` | `Gateway_invoice-approval_4_fork` |
+| `Gateway__race` | `makeGatewayRaceId` | `Gateway_invoice-approval_5_race` |
+| `Gateway__loop` | `makeGatewayLoopId` | `Gateway_invoice-approval_3_loop` |
+| `Flow__default` | `makeDefaultFlowId` | `Flow_Gateway_invoice-approval_2_split_default` |
+| `Flow__` | `makeSequenceFlowId` | `Flow_ReviewInvoice_Gateway_invoice-approval_2_split` |
+| `StartEvent_` | `makeStartEventId` | `StartEvent_invoice-approval` |
+| `EndEvent_` | `makeEndEventId` | `EndEvent_invoice-approval` |
+| `Throw_` | `makeThrowEventId` | `Throw_invoice-approval_2` |
+| `EventSubProcess_` | `makeEventSubProcessId` | `EventSubProcess_invoice-approval_1` |
+| `Catch_` | `makeIntermediateCatchEventId` | `Catch_order-handling_5_b0` |
+| `Boundary__` | `makeBoundaryEventId` | `Boundary_ApprovePayout_timer` |
+
+The structural coordinate `` for a compound statement at body index `i` inside a process with id `P` is `P_i`.
+For nested compounds the parent coordinate is prepended (`P_i_j`).
+Branch segments distinguish sibling blocks within a compound: `_t` for the if-then block, `_e` (0-based) for else-if branches, `_e` for the else block, and `_b` for the branches of a `parallel` and of a multi-branch `await`.
+Loop and sub-process bodies carry no segment, since a single block has no sibling to disambiguate against.
+A sub-process body's enclosing coordinate is the sub-process's own ``, so gateways inside it stay positional.
+The implicit start and end of a container are seeded from that container's id, the process id at the top level and the sub-process name inside a sub-process, and a boundary escape's end is `EndEvent_`; all of them resolve against one process-wide taken set so every id is document-unique.
+If a base id is already in the taken set, the resolution appends `_2`, `_3`, and so on until a free slot is found.
+
+Gateway ids skip the `taken`/`resolveCollision` guard, because the position-path scheme never generates the same id twice.
+What collides with them is an author-chosen statement name matching a synthesized-id pattern.
+The `checkReservedNames` validator in `packages/language/src/bpmn-script-validator.ts` rejects such a name at validation time.
+It reserves by prefix `Gateway_*_(split|join|fork|loop|race)`, `Throw_*`, `EventSubProcess_*`, `Boundary_*`, `Catch_*`, and the two-segment `Flow_*_*` form (`/^Flow_.+_.+$/`).
+Single-segment names such as `Flow_Control` stay legal, because synthesized flow ids occupy only `SequenceFlow.id` and never node names, so they cannot collide.
+For a start and an end it reserves, for each statement, the exact `StartEvent_` and `EndEvent_` of the container `c` whose body holds it: the process, or the nearest `subprocess` or `attempt` block.
+The message says the compiler generates that id for the container's implicit start or end.
+`StartEvent_1` is an ordinary name.
+A handler body's own implicit start and end, and a boundary escape's end, are minted off an id no author writes, so the validator reserves those three forms by prefix instead: `StartEvent_EventSubProcess_`, `EndEvent_EventSubProcess_` and `EndEvent_Boundary_`.
+
+A flow id collides without any reserved name being written, because the flow into a statement is `Flow__` and a split that names a fallback holds `Flow__default` back for it: an `if` chain's fallback, an inclusive fork's fallback, and either loop's exit.
+An AND fork and a race reserve nothing, having no fallback to reserve for.
+A statement named `default` in an earlier branch of such a split spells that string, and the document would ship two sequence flows under one id, which is not valid BPMN.
+`reserveDefaultFlowId` (`packages/transform/src/ast-to-ir.ts`) claims the reserved string before any branch is lowered, so the author's flow takes the next free slot instead.
+
+The printer recognizes a minted start or end by the same exact forms, plus `EndEvent_` for a boundary in the same container and the `Throw_` prefix an unnamed throw lowers to.
+Only those are left out, since the compiler re-derives them; a start or end under any other id prints with its label, its documentation and its initiator.
+
+The printer builds one map per print, from the process id and every element id that prints, to the name each is written under.
+An id the `ID` terminal accepts maps to itself.
+Any other maps to the id with every non-word character replaced by `_`, a leading `_` where the result would open on a digit or read as a keyword, resolved against every id in the document and every name minted before it.
+Every site that writes an id reads the map: the process head, every statement head, a `goto`, a boundary's host, and a named throw or catch.
+A gateway of any kind, a boundary event, an event sub-process, the catch event of a race branch and a sequence flow have no id slot in the script, so the map leaves them out.
+Their authored ids do not survive a rebuild: the compiler synthesizes each one from the templates above, and no warning reports it.
+An element with a name slot keeps its id: a task, a sub-process, a start, an end, a single `await`, and a named `throw` or `emit`.
+Each rename draws one `renamedId` warning naming the printed name and saying that the rebuilt document carries it as the activity id.
+
+An element the document gives no id gets one on import, before anything keys it.
+A sequence flow gets `Flow__`, and any other element `_`, from the local name of its BPMN type and the id of the process or sub-process holding it, such as `UserTask_invoice`; both resolve against every id in the document the way the compiler's ids do.
+An end event also skips `EndEvent_` for every id in the document and so usually imports as `EndEvent__2`, since the printer would take such an id for the implicit end the compiler gives a container or a boundary escape and leave the event out.
+Operaton deploys such a flow as an unnamed transition and such a task, end or catch as an activity nothing can flow into, since a `sourceRef` or `targetRef` needs an id, so the step prints and the validator reports that it can never run.
+A start event, a timer event, an event-based gateway, and a message or signal boundary event without an id refuse the import instead, since Operaton fails the deployment on each.
+An error, escalation, cancel or conditional boundary event without an id refuses too: Operaton deploys it and fails the run when an error is thrown inside its step, an escalation reaches it, its transaction is cancelled, or its step runs.
+A minted id would make the rebuilt document deploy and run where the source does not.
+A compensation boundary event deploys and runs without an id, so it takes a minted one like a task, and is then refused like every compensation boundary event ([ADR-0012](0012-honest-bpmn-import.md)).
+
+A form field id or an `operaton:inputParameter`/`operaton:outputParameter` name outside the terminal refuses the import.
+The engine sets the variable under that exact name (`FormFieldHandler.handleSubmit`, `InputParameter.execute`, `OutputParameter.execute`), so a minted name would change which variable is set rather than how it is written.
+
+### Consequences
+
+- Good, because re-compiling the same source always produces the same BPMN ids, and golden files stay stable across unrelated source changes.
+- Good, because the id scheme is self-documenting: reading a gateway id reveals its position in the source.
+- Good, because `synthesize-ids.ts` is a pure, dependency-free module with its own test suite.
+- Good, because a Modeler default diagram imports, prints and rebuilds with its start's id, label and initiator intact, and a MIWG reference model prints as source that parses and validates, with one report per id it could not keep.
+- Neutral, because the structural coordinate is longer than a UUID, making gateway ids verbose in deep nesting, mitigated by the fact that deeply nested processes are rare in the current scope.
+- Bad, because renaming a process id or reordering top-level statements changes all synthesized ids in that process, which breaks deployed BPMN definitions.
+ That is a concern for production use and acceptable for a DSL-authoring workflow where recompile is expected to replace the definition.
+- Bad, because a respelled id is a changed activity id in the rebuilt document, so a process rebuilt from such a script is not the process the history and migration APIs know; the warning says so, and the remedy is a rename in the model.
+- Bad, because an imported gateway, boundary event, event sub-process or race-branch catch comes back under a synthesized id without a warning, and the engine records these in history under their activity id as it does a task.
+ A migration plan written against the original document has to map them anew.
+- Bad, because a statement written under another container's minted id, such as `user EndEvent_S` beside `subprocess S`, is legal and makes the compiler mint `EndEvent_S_2` for `S`, which the printer then writes as an ordinary `end EndEvent_S_2`; the round trip stays stable from the second print on, and nothing runs differently.
+
+### Confirmation
+
+`packages/transform/test/synthesize-ids.test.ts` verifies every template, the collision-resolution rule, and the exact predicates for a minted start and end.
+The reserved-name table in `packages/language/test/validating.test.ts` pins the exact rule per container and the freed `StartEvent_1`.
+The respelling table in `packages/transform/test/ir-to-dsl.test.ts` prints every id site under a minted name, asserts the whole warning list, and re-parses and validates the source; the Modeler-default row asserts the printed start, end and initiator with no warning.
+The refusal matrix in `packages/transform/test/xml-to-ir.test.ts` pins the two import refusals.
+The two id-less tables in the same file pin the minted ids and the id-less events that refuse.
+The round-trip normalizer (`tests/helpers/normalize-ir.ts`) uses the id patterns to re-key synthesized ids before comparing IR snapshots, and the golden pair suites pass unchanged.
+
+## More Information
+
+The id templates are frozen, because they are consumed by `astToIr`, `irToDsl`, and the round-trip normalizer.
+Any change requires updating all three consumers and regenerating the `invoice-approval-generated.bpmn` golden file.
+The frozen contract is documented in the header of `packages/transform/src/synthesize-ids.ts`, which also holds the exact predicates and the minting scheme.
+The name map is built by `printedNames` in `packages/transform/src/ir-to-dsl.ts`; the per-container check is `checkReservedNames` in `packages/language/src/bpmn-script-validator.ts`; the two import refusals go through `refuseUnspellableVariable` in `packages/transform/src/xml-to-ir.ts`.
diff --git a/docs/decisions/0011-structured-grammar-one-bracket-shape.md b/docs/decisions/0011-structured-grammar-one-bracket-shape.md
new file mode 100644
index 0000000..4e992c0
--- /dev/null
+++ b/docs/decisions/0011-structured-grammar-one-bracket-shape.md
@@ -0,0 +1,110 @@
+---
+status: accepted
+date: 2026-06-12
+decision-makers: Marlon Kranz
+---
+
+# A structured, code-like grammar with one bracket shape
+
+## Context and Problem Statement
+
+A BPMN DSL can expose its surface syntax in two broad shapes.
+One is a flat declaration-plus-edges form: every flow node (`start`, `user`, `gateway`, `service`, `end`) is declared at the top level, and explicit `->` edges (with `when:`, `as:`, `default:`) connect them.
+This maps directly to the BPMN graph model, but it reads like a diagram description rather than code, and offers little advantage over editing BPMN XML for developers who think in control-flow terms.
+The other is a structured, code-like language with implicit sequence flow and block-scoped control statements.
+
+Inside a structured surface the same question returns one level down.
+An element carries an engine setting, a type, a label, a trigger payload and, for a container, a body.
+If the spelling of a value follows from where the value sits rather than from the shape of the element, an author has to memorize a rule per position, and the printer has to choose one per position instead of emitting one form.
+A delimiter that carries two unrelated jobs has the same effect on a reader: a brace that holds engine settings on a task and the flow inside a subprocess cannot be told apart without reading its contents.
+
+Which surface should BPMNscript use, and which shape should every element in it share?
+
+## Decision Drivers
+
+- The thesis goal is a textual DSL that serves developers who prefer working in code.
+- IDE support, meaning inline errors, type-aware validation, and jump-to-definition, is far richer for a real expression AST than for opaque condition strings.
+- Authoring a flat graph requires the same mental model as a BPMN diagram; a structured language is closer to how developers already write sequential logic.
+- The round-trip direction (BPMN XML to DSL) needs a `goto`-capable fallback for unstructured graphs, and the structured surface accommodates this naturally.
+- A JUEL-subset expression sub-language parsed to a real AST is needed to enable type-check diagnostics on condition expressions.
+- One rule an author can derive the next line from, rather than several rules indexed by position.
+- A delimiter that says what kind of content it holds, so settings and flow are distinguishable before they are read.
+- No new reserved words, since the grammar owns the vocabulary in one place.
+- A shape the printer can emit mechanically, because every construct round-trips through `ir-to-dsl`.
+- Diff behaviour, since a process under version control is edited attribute by attribute.
+
+## Considered Options
+
+- Structured code-like syntax with brackets and blocks, where keyword and braces carry implicit flow and control statements (`if`/`while`/`parallel`/`goto`), `( )` carries what an element is, and `{ }` carries what runs inside it
+- A flat node/edge syntax, explicit declarations and `->` edges mapping 1:1 to the BPMN graph, which needs the same mental model as a diagram and leaves conditions as opaque strings with no expression AST behind them
+- Hybrid: named blocks for common patterns, with a fallback to explicit edges
+- Colon configuration, where `:` introduces every detail, type, value and label alike, needing no per-position rule but wordier, since every label becomes its own `label:` line, and still leaving the brace carrying two jobs
+- Keyword prose, where small words such as `is`, `means`, `assigned` and `catching` replace the punctuation, reading close to English but trading punctuation the author can see for vocabulary to recall, and adding reserved words to a grammar kept structured on purpose
+- Off-side, where indentation carries the structure and every detail is a `key: value` child, using the fewest separators but making whitespace meaningful, so a misindented paste changes the model and the printer's indentation on the way back is a risk in every generated file
+- Decorators, where each attribute is its own `@name value` line above a bare `keyword id`, keeping diffs clean but read bottom-up, with the configuration before the element it belongs to
+- Binding form, where every element is written as an assignment, `Id = kind(name = value)`, closest to the data and a builder API but reading like configuration rather than flow, where `if` and `on` stand out as foreign
+
+## Decision Outcome
+
+Chosen option: structured code-like syntax with brackets and blocks, because it matches how developers already write sequential logic, and because the two delimiters are what make a shape carry the distinction a position-indexed spelling fails to make.
+Conditions become a real expression AST, which is what makes type-check diagnostics possible; `goto` slots into the same surface as the decompilation fallback for unstructured BPMN graphs.
+
+The shape is `verb Id(attributes) { children }`.
+`( )` holds the scalar settings that describe the element.
+`{ }` holds everything with internal structure, which means the flow inside a container and the member lists on a step.
+An element with no members has no brace, so `user Review(label: "Review the order", assignee: "demo")` is a complete statement, and an element with no settings has no parens.
+It adds no reserved words, and the `:` inside the parens is the separator the form block and the `var` declaration already use.
+
+### What the shape alone does not decide
+
+A handler keeps the colon after its host, as in `on Pack: error(OUT_OF_STOCK, code: c)`.
+Dropping it makes a hostless handler carrying a code and a hosted handler carrying none both read as `on` followed by two identifiers.
+No lookahead separates those, because the trigger words are soft identifiers and the host is a cross-reference the parser cannot resolve while it is parsing.
+The same colon settles the same question for `throw` and `emit`.
+
+A trigger keeps its own slot, so an event-bearing element reads `verb Id trigger(payload, settings) { children }`.
+The payload is positional, since `TriggerPayloadRule` gives every trigger at most one code, and a key would spell that code's role twice.
+
+Form blocks, listeners, io parameters and call mappings live in the braces rather than the parens, because those four are lists with their own members, the same half of the shape as flow.
+This is why `{ }` is defined above as everything with internal structure rather than as what runs: a form does not run, and forcing it into a parenthesised value would nest a listener's own settings two levels deep.
+
+A setting with no value is written as a bare word inside the parens, as in `timer("PT2H", alongside)`.
+The parens therefore hold a payload, then keyed settings, then bare flags, told apart by whether a `:` follows.
+
+### Consequences
+
+- Good, because the DSL reads and writes like program code (`if`/`while`/`parallel`), and an author who has seen one element can write any other.
+- Good, because conditions are a first-class expression AST, enabling type-check validation and jump-to-definition for variable references.
+- Good, because `parallel { { } { } }` maps directly to AND fork/join pairs, making parallel-gateway support natural to author.
+- Good, because `goto` as a residual form keeps decompilation total: every imported graph prints as source, and an edge with no name to jump to is dropped with a marker line and a warning (ADR-0014).
+- Good, because the printer emits one shape rather than choosing a spelling per position, which removes a class of difference between an authored file and a generated one.
+- Good, because a `{` opens children and nothing else, so the delimiter alone says whether a construct nests.
+- Bad, because settings lists are longer and wrap.
+- Bad, because `user Review(...)` reads like a function call rather than a step.
+- Bad, because the desugaring (`astToIr`) must synthesize gateway pairs from block structure, adding complexity relative to a flat pass-through that mirrors the graph directly.
+- Bad, because a bare timer payload means a duration, so `timer("P1D")` and `timer(every: "P1D")` differ by a word while their strings look identical.
+- Bad, because an event binding written `code: c` is not distinguishable by shape from a setting, so the rule that a binding declares a variable while a setting reads one lives in the validator rather than in the grammar.
+- Bad, because a lone identifier in parens is a payload rather than a flag, which `condition(ready)` needs an ordering rule to resolve rather than a shape.
+- Bad, because a flag is only bare to the reader: `alongside`, `sequentially` and `local` are keywords and never lex as identifiers, so the grammar names the legal flags rather than accepting any word in that position.
+- Bad, because the four TextMate grammars cannot import the vocabulary, so each needs a test that fails when it drifts.
+
+### Confirmation
+
+The construct round-trip idempotence test (`tests/round-trip-constructs.test.ts`) verifies that `if`/`else`, `while` and `parallel` survive `astToIr -> irToXml -> xmlToIr -> irToDsl` without losing structure.
+The conditioned `parallel` branch and the multi-branch `await` survive the same four hops in the frozen pair `tests/golden/branch-and-race.bpmnscript` and the BPMN generated from it.
+The goto-degradation path is exercised by `tests/golden/unstructured-goto.bpmn` in the same suite.
+Neither the conditioned branch nor the multi-branch `await` spent a reserved word, so the grammar header's claim that there is no `gateway` keyword and that every gateway is derived from a control-flow construct holds word for word.
+
+Every construct prints through `ir-to-dsl` and re-parses through the compiler, so a shape the printer emits but the parser rejects fails the suite rather than reaching a file.
+The golden fixtures under `tests/golden` and the examples under `examples/spring-boot/processes` compile in the suite, so the surface is confirmed by the same gate that confirms the language.
+
+A handler and a listener sit in the same brace, and what separates them is the handler's own body block, which is mandatory where a listener has none.
+The word after `on` plays no part in it, so the parse stays unambiguous even if a trigger word and a listener event ever collide.
+Such a collision would still leave `on ` meaning two things to a reader and to the validator, so the two vocabularies are held disjoint by a test rather than by the grammar.
+
+The TextMate keyword alternation must stay equal to the grammar's reserved words, since three of the four grammars are generated or copied and are gitignored, which makes a stale artifact invisible in review.
+
+## More Information
+
+The JUEL-subset boundary, meaning what parses natively and what falls back to `"${...}"`, is fixed by the grammar's expression sub-rules in `packages/language/src/bpmn-script.langium` and mirrored by the hand-rolled parser in `packages/transform/src/juel.ts`.
+The completion provider offers at a caret only what the validator accepts there, and `packages/language/test/completion.test.ts` holds that with a table that inserts every offered item and validates the result.
diff --git a/docs/decisions/0012-embed-java-delegate-code-and-generate-operaton-application.md b/docs/decisions/0012-embed-java-delegate-code-and-generate-operaton-application.md
deleted file mode 100644
index e5ba0c5..0000000
--- a/docs/decisions/0012-embed-java-delegate-code-and-generate-operaton-application.md
+++ /dev/null
@@ -1,55 +0,0 @@
----
-status: rejected
-date: 2026-07-04
-decision-makers: Marlon Kranz
-consulted: thesis supervisor
----
-
-# Embed Java Delegate Code and Generate the Operaton Application
-
-## Context and Problem Statement
-
-The thesis supervisor proposed a deploy feature: write the body of a `JavaDelegate`'s `execute` method directly in the `.bpmnscript` file, generate the complete Java class from it, scaffold the surrounding Operaton application (build configuration, Spring Boot wiring, resources), and generate tests for the result.
-
-Today the toolchain stops at BPMN XML.
-`bpmns build` compiles a `.bpmnscript` file to a `.bpmn` file, `bpmns parse` decompiles it back, and `service X(class: "...")` carries only a class-name string (emitted as `operaton:class`).
-Delegate classes are hand-written in a separate host application, as in `examples/spring-boot/`.
-
-Should the compiler's output boundary move from BPMN XML to a complete, runnable Operaton application?
-
-## Decision Drivers
-
-- Bidirectional conversion between DSL and BPMN XML is a core property of the tool.
- BPMN XML has no representation for Java source, so embedded method bodies are lost on compile and cannot be recovered on decompile; the target format has no slot for the information.
-- ADR-0006 keeps the IR a clean graph of process semantics, not a store for host-language code.
- `JavaDelegate` bodies are Java source, and carrying them through the IR pierces that boundary.
-- "Deploy" changes meaning.
- BPMN XML alone hot-deploys through a single REST call (`POST /engine-rest/deployment/create`), but Java classes must be on the engine's classpath: every change to an embedded body forces a rebuild and restart of the host application.
- The deployment unit becomes the application, not the model.
-- A generated application scaffold is a long-lived support surface (dependency versions, upgrade path, build tooling) that exceeds the thesis time budget.
-
-## Considered Options
-
-- Embed Java method bodies in the DSL and generate delegate classes, a full Operaton application, and tests
-- Keep BPMN XML as the compilation boundary; delegates remain hand-written in a host application
-
-## Decision Outcome
-
-The proposal is rejected; BPMN XML remains the compilation boundary.
-Embedding Java gives up the two properties the design rests on: round-trippable conversion, and an IR that models process structure rather than host-language code.
-It also turns deployment from a one-artifact REST call into an application build.
-The scaffold's maintenance cost alone puts it outside the thesis scope.
-
-### Consequences
-
-- Good, because the existing round-trip guarantees are unchanged; no lossy construct enters the language.
-- Good, because the DSL and IR stay free of host-language code per ADR-0006.
-- Good, because a future deploy command stays a thin REST client, the mechanics the E2E test adapters (`tests/fixtures/adapters/`) already exercise.
-- Bad, because delegates are written and kept in sync by hand; the `class: "..."` string is not checked against any Java source, so a mismatch surfaces only at engine runtime.
-- Bad, because the one-file authoring experience the proposal aimed for is not available.
-
-## More Information
-
-One part of the proposal stands on its own: generating tests for the compiled `.bpmn` artifact.
-Test generation consumes the compiler's existing output rather than adding Java source to the language, so none of the round-trip or IR concerns above apply.
-It is set aside as possible later work, independent of this decision.
diff --git a/docs/decisions/0012-honest-bpmn-import.md b/docs/decisions/0012-honest-bpmn-import.md
new file mode 100644
index 0000000..fe374bc
--- /dev/null
+++ b/docs/decisions/0012-honest-bpmn-import.md
@@ -0,0 +1,105 @@
+---
+status: accepted
+date: 2026-07-04
+decision-makers: Marlon Kranz
+---
+
+# Honest BPMN import: refusals, warnings, and the parse tiers
+
+## Context and Problem Statement
+
+`xmlToIr`'s docstring stated that silent semantic loss is impossible, while the transform dropped several kinds of content without any diagnostic: event definitions on start and end events, loop characteristics on tasks, whole collaborations, Operaton and camunda extension content, and lanes.
+Some of those drops change what the imported process executes; others do not.
+The import contract has to make good on its own claim: what should happen when `xmlToIr` meets content the IR cannot carry, and should every such case be treated the same way?
+
+A surface that answers from its own reading of the IR disagrees with `BpmnParse` in both directions, refusing files the engine deploys while carrying files it refuses, and it says nothing about what the engine does with what it drops.
+Camunda Modeler's default export is the case that costs most: it writes a `bpmn:collaboration` with one participant for any diagram with a pool or a lane, an `isExecutable="false"` process for every empty pool, and `xmlns:camunda` on every file, which is also the shape of the Operaton invoice example and four MIWG reference files.
+Document shapes the engine rejects before `BpmnParse` sees an element pass through instead, since moddle-xml and saxen read them where a validating parse does not.
+`bpmn:documentation` has no place at all, which keeps the letter of the contract while working against everything the contract exists for.
+
+## Decision Drivers
+
+- The no-silent-semantic-loss claim must hold for content whose absence changes execution semantics: a dropped timer or a dropped loop is not a cosmetic loss.
+- Not every unrepresentable construct is equally severe, and refusing content that causes no semantic loss, an extension attribute or a lane, would make the importer unusable on any file a real modeler exports, the Modeler's default output and the Operaton invoice example included.
+- The engine's parser is the one reader whose verdict decides whether a document runs, so it is the one reference for where a tier sits, and a warning that names what the engine does with a dropped setting is the only form under which a reader can tell a cosmetic drop from one that changes a run.
+- Whatever channel reports non-fatal drops must be impossible to ignore by accident, since a warning nobody reads is a silent drop.
+- Consumers, the CLI and the VS Code extension, need one classification check for "is this an unsupported-construct refusal?" that does not enumerate every subclass by hand.
+- One fact about the engine lives in one place, in the message or the comment beside the check, naming the method that reads it.
+
+## Considered Options
+
+- Two tiers drawn where `BpmnParse` draws them: refuse what the engine fails the deployment on, warn on what it deploys and this surface cannot spell.
+- Refuse everything the IR cannot express, with no warning tier: nothing the engine runs is ever dropped and a listener on a gateway could not slip through, but it refuses every file a real modeler exports over content that costs the reader a warning rather than a run.
+- Drop what the IR cannot carry in silence and correct the docstring to describe that.
+- Draw the tiers from the surface's own reading of the IR and correct the wording of the drops alone.
+- Refuse every setting the engine reads that this surface cannot spell.
+- Refuse every collaboration and every multi-process document, on the reading that a pool means several linked processes: nothing is dropped, but the refusal blocks most real input on a shape the engine ignores, and its message called a lone pool "multiple linked processes".
+- Write the pool back on export rather than warn that the round trip loses it: the frame would survive, but it needs a `bpmn:collaboration`, a participant shape whose bounds enclose every laid-out element, and a plane over the collaboration, none of which `bpmn-auto-layout` produces, for a frame the engine never reads.
+- Read the camunda namespace through a second moddle package, or by rewriting the prefix in the document text: a second package would key every consumption table twice, and a prefix rewrite could hit a prefix spelled inside attribute text, where the URI cannot appear.
+- Carry documentation as a list of `{ text, textFormat }` members per node, or rework the printer to give a setting a multi-line entry: nothing in the corpus writes two children on one element, so neither buys anything the single escaped string does not.
+- Carry documentation on every element the transform touches rather than on every element that already carries a name.
+- For BPMN's own resource roles, refuse the spelling, or carry both spellings in the IR and the script: the round trip would be byte-stable on the roles, but the language then has two spellings for one assignment and the IR two fields the engine merges into one.
+
+## Decision Outcome
+
+Chosen option: two tiers, drawn where `BpmnParse` draws them, because it is the only option consistent with the no-silent-semantic-loss claim that accepts what the engine deploys, within the scope bounds below (one executable process per file, no event without an id that the engine deploys but cannot run, and no construct this surface cannot spell), and says what the engine does with what it drops.
+
+The rule has three clauses.
+A shape `BpmnParse` fails the deployment on refuses, and the message names the method and quotes its sentence, since there is nothing that runs to write back.
+A shape the engine deploys and this surface can spell is carried, with a warning where the spelling changes.
+A shape the engine deploys and this surface cannot spell either drops with a warning that names the method that reads it and says the document written back runs without it, or is refused with its reason.
+`packages/transform/README.md` lists the refused ones, among them a `bpmn:complexGateway`, a compensation boundary event, two input parameters sharing a name, a script body no fence can enclose, and a thrown message's result variable beside an `expression` binding, where the engine stores a value the throw has no slot for.
+
+A refusal throws a subclass of `UnsupportedConstructError` before any IR is produced, so there is never a partial IR, and a consumer classifies the whole family with a single `instanceof` check while each subclass still carries construct-specific metadata for a tailored message.
+A warning is returned in a `warnings: ImportWarning[]` array alongside the IR, so `xmlToIr` returns `{ ir, warnings }` rather than a bare `BpmnProcess`, which makes the channel unignorable at the type level: every call site must destructure or explicitly discard `warnings`, where an optional collector parameter (`xmlToIr(xml, sink?)`) or a second `xmlToIrWithWarnings` function leaves it easy to skip.
+What the engine deploys and then fails on at run time is carried as written, with a warning that the printed script draws an error there.
+The exception is an event without an id that the engine deploys and cannot run, which is an error, escalation, cancel or conditional boundary event.
+The IR names every node, so such an event cannot be carried as written, and an id minted for it would make the handler run where the source fails, so the import refuses it ([ADR-0010](0010-deterministic-synthesized-ids.md)).
+The import contract is therefore that a document the engine deploys imports, unless it marks more than one process executable, holds an event without an id that the engine deploys but cannot run, or uses a construct this surface cannot spell; each of these is refused with its reason, and nothing the engine runs is dropped without a warning.
+
+No element leaves the transform unreported: whatever it cannot carry is named by the tag the document spells and by its own id, and attributed to the element it sat on, a construct being reported whole so that a dropped `bpmn:ioSpecification` names itself rather than each data input inside it.
+What is dropped without a warning is the diagram interchange data ADR-0009 settles, a BPMN attribute the transform neither reads nor reports, such as a process's `processType` or `isClosed`, and an attribute in a foreign namespace written directly on a mapped BPMN element, which is where an editor parks its own bookkeeping; the same foreign attribute on an extension child the IR reads is reported.
+`packages/transform/README.md` names every refusal class and every warning category with the conditions that reach it, and `docs/bpmn-coverage.md` places each BPMN element on one side of the boundary or the other.
+
+Three document-level shapes follow from the same rule rather than from any one element.
+`selectProcess` imports the one process marked `isExecutable="true"` and pushes one warning per other process, since `parseProcessDefinitions` does not deploy those either.
+A file with two or more processes marked executable is refused, although `parseProcessDefinitions` deploys each of them as a definition of its own: the IR and a script hold one process, so one executable process per file is a scope bound of this tool, not an engine rule.
+A collaboration is diagram data to the engine, since `parseCollaboration` records a participant's `processRef` only so that `parseBPMNShape` can attach the pool's bounds and no method in `BpmnParse` reads a `bpmn:messageFlow`, so a pool warns rather than refuses.
+The camunda namespace is read as the operaton one by replacing the URI in the document text before `moddle.fromXML`, with one warning per document: `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to the camunda URI only when the operaton lookup finds nothing, and moddle-xml binds a prefix to a package by URI rather than by spelling, so after the swap every `camunda:` element arrives typed `operaton:*` and every `camunda:` attribute reads through the one `operaton:` reader.
+
+BPMN's own resource roles on a user task import onto the Operaton assignment attributes, with one warning per role naming the rewrite, since `BpmnParse.parseTaskDefinition` reads both into the same assignee and candidate lists that `operaton:assignee`, `operaton:candidateUsers`, and `operaton:candidateGroups` fill.
+The exported document carries the Operaton attributes alone, and the engine builds the same identity links from them.
+
+A single plaintext `bpmn:documentation` child imports onto one optional `documentation?: string`, carried wherever an IR node already carries a `name`, with its newline escaped so the printer's `Lines = string[]` model is untouched and Langium's default `convertString` is the exact inverse.
+The body is carried verbatim, so a modeler's pretty-printed whitespace inside the element comes back as it was written rather than trimmed.
+Tying documentation to the name means the positions that report it instead are already the positions a label is reported at: an element with no slot for a name has no slot for documentation either, so there is one rule rather than two.
+
+The contract covers two hops.
+`irToDsl` reports in the same shape, returning `{ source, warnings }` for the reason `xmlToIr` returns `{ ir, warnings }`: a channel a caller can skip is a silent drop.
+Some of what its warnings name merely drops, such as a gateway label no statement form can hold; some changes what a recompiled document runs, such as a fallback re-derived on an imported fork whose branches all carry conditions and that named none; and some is printed as the model spells it and draws an error when the source is read back, such as an `else` beside a branch that runs whatever the conditions do.
+Every one of them is warned rather than refused, because a refusal has no meaning on this hop: the restructurer is total and always produces source.
+
+### Consequences
+
+- Good, because a document the engine deploys imports, provided it marks at most one process executable, holds no event without an id that the engine cannot run, and uses no construct this surface cannot spell, among them the Modeler's default collaboration file, the Operaton invoice example, and the MIWG files with one executable process, and one the engine refuses is refused with the engine's own sentence rather than imported into a script that cannot deploy.
+- Good, because every caller, the CLI, the VS Code extension, and the round-trip suite, surfaces both the refusal and the warning channel instead of only one or neither, and the shared `UnsupportedConstructError` base keeps classification to one `instanceof` check as new refusal categories are added.
+- Good, because every warning on a setting the engine reads says which method reads it, so the reader knows whether the drop changes a run.
+- Bad, because the engine's sentences are quoted in the importer and drift with the engine's wording.
+- Bad, because a shape the engine deploys and then fails at run time, such as an empty listener `class` on a task, is carried into a script that draws an error at the step, which the reader has to repair by hand.
+- Bad, because some warned items do bear on what runs, against the boundary this decision draws, among them a dropped `operaton:field`, which leaves the bound class without a value it was injected with, and `isExecutable="false"`, which imports as an executable process.
+ Refusing either would reject files that otherwise import cleanly, and carrying `isExecutable` through would mean an IR field, a serializer path, and a DSL surface for a flag this tool has no use for.
+- Bad, because the round trip rewrites a document in places: the pool is gone, so a pooled diagram opens as a plain process, and a `bpmn:potentialOwner` comes back as `operaton:candidateGroups`.
+- Bad, because an id the schema admits and the engine deploys, such as one with an umlaut, is refused until renamed, since moddle-xml cannot read it.
+- Bad, because a handful of undeclared `operaton:` extension elements cannot be tied by `bpmn-moddle` to a specific owning element, so their warnings are attributed to the process id rather than the precise element.
+
+### Confirmation
+
+The refusal and warning tables in `packages/transform/test/xml-to-ir.test.ts` pin every documented row with the whole warning list or the refusal class and detail, including the document level, the `camunda: prefix alias` describe, the resource-role merge, and the quantity warning.
+`tests/review-loop.round-trip.test.ts` and the invoice fixture in `tests/` pin the Modeler-shaped documents end to end, and `tests/e2e/forms-and-external-tasks.test.ts` deploys the re-export of a task assigned in BPMN's own words to a real engine and reads the identity links back.
+The frozen `documentation` golden pair and a parameterized sweep per hop name every position that carries documentation and every position that reports it instead, so a position wired to neither branch fails the sweep rather than passing in silence.
+
+## More Information
+
+The refuse and warn boundary and the `ImportWarning` shape (`elementId`, `category`, `message`) live in `packages/transform/src/errors.ts` and `packages/transform/src/xml-to-ir.ts`, where the checks are `refuseDocumentShapes`, `refuseDroppedIds`, `refuseImports`, `selectProcess`, `warnCollaborationDrops`, `checkExclusiveGateways`, `checkStartEventCount`, `checkHandlerFlows`, `attachDefaultFlows`, `refuseIoMapping`, `onlyExtensionElement`, `resolveListenerBinding`, `readThrownMessageBinding`, and the `ENGINE_READS_ELSEWHERE` table behind `warnUnimportedSetting`; the namespace swap sits at the top of `xmlToIr`.
+`PrintWarningCategory` and the warnings built beside it in `packages/transform/src/ir-to-dsl.ts` settle which reports the print hop makes and what each one costs.
+The consumer-facing summary is the import contract section of `packages/transform/README.md`.
diff --git a/docs/decisions/0013-target-users-without-bpmn-knowledge.md b/docs/decisions/0013-target-users-without-bpmn-knowledge.md
deleted file mode 100644
index efe4573..0000000
--- a/docs/decisions/0013-target-users-without-bpmn-knowledge.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-status: accepted
-date: 2026-06-30
-decision-makers: Marlon Kranz, Oliver Kopp
----
-
-# Target Users Without BPMN Knowledge and Minimize Boilerplate
-
-## Context and Problem Statement
-
-ADR-0008 gave BPMNscript a structured, code-like surface aimed at developers who prefer working in code.
-It left two questions open: how much BPMN literacy the language may assume, and how much required syntax is acceptable.
-Keywords, settings, and event declarations could still presuppose familiarity with BPMN's element types and vocabulary, and the grammar could still demand explicit text where the compiler could supply a default.
-
-How much BPMN knowledge may the language assume, and how much required syntax (boilerplate) is acceptable?
-
-Decided in the supervision meeting of 2026-06-30.
-
-## Decision Drivers
-
-- BPMN-literate users already have a well-supported option: the graphical modeler.
- The textual language is motivated by the other population, for whom BPMN vocabulary is a barrier rather than a help.
-- Required syntax that carries no process information raises the entry barrier without paying for itself.
-- ADR-0008's structured constructs already hide gateway mechanics behind `if`/`while`/`parallel`.
- Without an explicit audience decision, future grammar work has no tiebreaker between "closer to BPMN" and "simpler for newcomers".
-
-## Considered Options
-
-- Assume BPMN literacy: mirror BPMN terminology and structure, keeping the text close to the XML it compiles to
-- Assume general programming literacy but no BPMN knowledge, and minimize required syntax
-
-## Decision Outcome
-
-Chosen option: "Assume general programming literacy but no BPMN knowledge, and minimize required syntax".
-Concretely, two rules bind future grammar decisions:
-
-1. No BPMN prerequisite.
- Reading or writing a `.bpmnscript` file must not require knowing BPMN.
- Keywords name what the user means (a step a person performs, a step the system performs, a decision), not the BPMN element behind it.
-2. Defaults over declarations.
- Whenever the compiler can infer or synthesize a detail, the grammar must not require the user to write it.
- When two designs express the same process, the one with less required text wins.
-
-Implicit sequence flow (ADR-0008) and synthesized structural ids (ADR-0010) already follow both rules; this decision makes them binding for what comes next.
-
-### Consequences
-
-- Good, because the entry barrier drops: a newcomer can write a running process without first learning BPMN's vocabulary or diagram semantics.
-- Good, because grammar discussions get a tiebreaker instead of re-arguing the audience each time.
-- Bad, because hiding BPMN vocabulary makes the DSL-to-XML correspondence less obvious for BPMN-literate readers; documentation has to carry that mapping, and the README glossary is a start.
-- Bad, because every default the grammar adopts is a rule the round trip must invert, synthesized on compile and elided on decompile, growing the mapping surface that ADR-0010 normalization already covers for ids.
-
-## More Information
-
-Related decisions: ADR-0008 (structured, code-like grammar) settled the surface shape.
-ADR-0010 (deterministic structural ids) is the existing model for how a compiler-supplied default stays round-trippable.
diff --git a/docs/decisions/0013-validator-mirrors-parse-time-rules.md b/docs/decisions/0013-validator-mirrors-parse-time-rules.md
new file mode 100644
index 0000000..cb645d7
--- /dev/null
+++ b/docs/decisions/0013-validator-mirrors-parse-time-rules.md
@@ -0,0 +1,70 @@
+---
+status: accepted
+date: 2026-09-14
+decision-makers: Marlon Kranz
+---
+
+# The validator mirrors the engine's parse-time rules
+
+## Context and Problem Statement
+
+`BpmnParse` refuses a set of documents at deployment that this surface compiled without a word.
+`addEventSubscriptionDeclaration` keeps one message or signal subscription per name on one scope, and one conditional start per condition text on the process.
+A handler written inside a block subscribes on that block, since the event sub-process it lowers to takes the block as its event scope (`parseEventDefinitionForSubprocess`), and a handler attached to the block subscribes on the block too (`parseBoundaryEvents`), so the two collide on one name.
+Every branch of an `await` block subscribes on the gateway (`parseIntermediateCatchEvent`), so two branches on one name collide as well.
+`addEscalationEventDefinition` refuses an escalation catch without a code beside one with a code when both are boundary events on one host or both event sub-processes in one scope.
+`selectInitial` refuses a second plain or timer start on a process.
+The validator keyed a handler by its host name and grouped handlers by syntactic container, so none of these collisions drew a diagnostic, and the deployment failed with an engine message naming a generated id.
+
+Three more facts sit beside those, silent rather than refused.
+`parseScopeStartEvent` reads neither `operaton:initiator` nor a start form off a start that is not the process's own, so both compiled onto a nested start and did nothing.
+`DefaultFailedJobParseListener.parseActivity` stores a retry cycle only for an activity with an async flag, and `parseAsynchronousContinuation` folds `exclusive` into the async flags it sets.
+`createActivityOnScope` stores a job priority that only a job declaration reads.
+Each of the three keys therefore configures nothing without the job its async flag creates.
+`parseServiceTaskLike` hands `resultVariable` to an `expression` binding alone, so the setting beside `topic` or `type` writes no variable.
+
+Which of the engine's parse-time rules should the validator repeat, and how should a setting the engine never reads be reported?
+
+## Decision Drivers
+
+- A document the validator passes should deploy, and a refusal should reach the author on the line that caused it rather than as an engine message naming a generated id.
+- A setting that configures nothing is a mistake the author cannot see on the page, since the document deploys and runs.
+- Every mirrored rule cites the engine method it repeats, so a reader can check it against the engine's source when the engine moves.
+
+## Considered Options
+
+- Mirror each parse-time refusal in the validator and warn on each setting the engine never reads.
+- Leave the refusals to the engine and document them: nothing in this tool then restates a rule that can move, but the author meets the rule as a failed deployment rather than on the line that caused it.
+- Mirror the refusals and refuse the unread settings too: an unread setting would be impossible to miss, but the document deploys and runs, so the refusal would reject a script the engine accepts.
+
+## Decision Outcome
+
+Chosen option: mirror each parse-time refusal in the validator and warn on each setting the engine never reads.
+A refusal the engine makes is a fact the page can state on the right line, and a setting that does nothing leaves the process running, which is a warning's severity.
+
+The validator groups handlers by the scope the engine subscribes them on: the host of a boundary event, or the container an event sub-process sits in.
+Within one scope a message or signal name is refused twice among boundary and event sub-process catches, so a handler inside `Sub` and one attached to `Sub` collide; a process start of the same name is keyed apart (`hasMultipleEventDefinitionsWithSameName` compares the start flag too) and sits beside either.
+A boundary on a repeated host subscribes on the multi-instance body, one scope above the host's own (`getMultiInstanceScope`), so a name inside such a host stays free.
+An escalation, error, or cancel catch is compared against catches of its own kind alone, and an escalation catch-all beside a coded catch of its kind is refused.
+The `on error` case stays refused as this surface's own rule, and its message says so: `addErrorEventDefinition` sorts the definitions and the runtime takes the first match.
+Two branches of one `await` block on one message or signal name are refused.
+On a process, a second plain or timer start, a second message or signal start of one name, and a second condition start of one rendered condition text are refused.
+A start inside a subprocess, an attempt block, or a handler body refuses `initiator` and a `form` block.
+
+`retryCycle`, `exclusive`, or `jobPriority` written without `asyncBefore` or `asyncAfter` of the same spelling family draws a warning saying it configures no job; `join*` pairs with `join*` and `run*` with `run*`.
+On a repeated step the step's own `retryCycle` and `exclusive` pair with `asyncBefore` and `asyncAfter`, the whole-loop job, while `runRetryCycle`, `runExclusive`, and `jobPriority` pair with `runAsyncBefore` and `runAsyncAfter`, the per-run jobs.
+`jobPriority` sits with the per-run keys because `parseActivity` swaps the scope to the multi-instance body before `createActivityOnScope` stores the priority, so it lands on the inner activity and prices each run's job.
+The body itself is created without one, and its job takes the process's priority.
+A timer carrier is exempt for all three, since the timer job reads them off the event element.
+`resultVariable` beside `topic` or `type` draws the same kind of warning, since nothing writes the variable.
+
+### Consequences
+
+- Good, because a script that compiles now deploys where these rules are concerned, and the message names the scope and the engine method.
+- Good, because a setting that configures nothing is reported at the key that carries it.
+- Bad, because the validator restates engine rules that can move under a new engine version; each rule cites the method it mirrors so the drift can be checked.
+- Neutral, because a script with a nested start form, an unpaired job setting, or a `resultVariable` beside `topic` draws a diagnostic where it drew none.
+
+### Confirmation
+
+`packages/language/test/validating.test.ts` holds a row per mirrored rule: a handler inside a block beside a boundary on it per trigger, the repeated-host exemption, the escalation pairs both ways and the legal mix, two race branches on one name, each duplicate start kind, `initiator` and a form on a start in each of the three containers, each job setting key with and without its flag on each carrier, and `resultVariable` beside each binding.
diff --git a/docs/decisions/0014-honest-bpmn-import-contract.md b/docs/decisions/0014-honest-bpmn-import-contract.md
deleted file mode 100644
index 2992f0d..0000000
--- a/docs/decisions/0014-honest-bpmn-import-contract.md
+++ /dev/null
@@ -1,145 +0,0 @@
----
-status: accepted
-date: 2026-07-04
-decision-makers: Marlon Kranz
----
-
-# Honest BPMN Import: Refuse Unsupported Constructs, Warn on Non-Semantic Drops
-
-## Context and Problem Statement
-
-`xmlToIr`'s docstring stated that silent semantic loss is impossible, but the transform actually dropped several kinds of content without any diagnostic: event definitions on start/end events (timer, message, signal, error, terminate), loop characteristics on tasks (multi-instance and standard loop), whole collaborations (pools and message flows), Operaton/camunda extension attributes beyond `assignee`/`formKey`/`class`, and lanes.
-Some of these drops change what the imported process executes; others do not.
-The import contract needs to make good on its own claim: what should happen when `xmlToIr` encounters content the IR cannot carry, and should every such case be treated the same way?
-
-## Decision Drivers
-
-- The "no silent semantic loss" claim must hold for content whose absence changes execution semantics.
- A dropped timer or a dropped loop is not a cosmetic loss.
-- Not every unrepresentable construct is equally severe.
- Refusing content that causes no semantic loss (an extension attribute, a lane) would make the importer unusable on any file a real modeler exports, since modelers routinely add such content.
-- Whatever channel reports non-fatal drops must be impossible to ignore by accident; a warning nobody reads is equivalent to a silent drop.
-- Consumers (the CLI, the VS Code extension) need one classification check ("is this an unsupported-construct refusal?") that does not have to enumerate every error subclass by hand as new ones are added.
-
-## Considered Options
-
-- Two-tier contract: refuse (throw) constructs that change execution semantics, warn (return) on constructs that do not
-- Refuse everything the IR cannot express, with no warning tier
-- Keep the current behavior (drop silently) and only correct the docstring to describe it
-
-## Decision Outcome
-
-Chosen option: "Two-tier contract: refuse constructs that change execution semantics, warn on constructs that do not", because it is the only option consistent with the "no silent semantic loss" claim while remaining usable on real Modeler-exported files, which routinely carry cosmetic extension content that a total-refusal contract would reject outright.
-
-Refused constructs throw a subclass of `UnsupportedConstructError` before any IR is produced, so there is never a partial IR, and the refusals include:
-
-- an event definition of a kind the event's position does not accept -> `UnsupportedEventDefinitionError`
-- a multi-instance repetition the engine refuses to deploy, one this tool cannot write back unchanged, or one on an event handler, which its trigger enters rather than repeats -> `UnsupportedLoopCharacteristicsError`
-- a collaboration (pools/message flows) -> `UnsupportedCollaborationError`
-- an event of a supported kind carrying a shape the surface cannot express, such as an error throw with no code -> `UnsupportedEventFeatureError`
-- a call activity with no `calledElement`, with a version binding the surface cannot pin, with a `calledElementTenantId`, which decides which tenant's copy of the called process runs, or with an `operaton:in`/`out` mapping in a shape the surface cannot spell -> `UnsupportedCallActivityError`
-- an `operaton:formField` of a type the form surface does not map -> `UnsupportedFormFieldTypeError`
-- an `operaton:constraint` the engine refuses to deploy or the surface cannot spell: an unregistered name, a `validator` or a bound with no `config`, or a name repeated on one field -> `UnsupportedFormFieldConstraintError` (ADR-0037)
-- Operaton extension content the IR's discriminated unions cannot represent, such as a parameter carrying body text and a nested value at once -> `UnsupportedExtensionFormError`
-- an unsupported flow-element kind (pre-existing) -> `UnsupportedElementError`
-- an unsupported service-task execution form (pre-existing), an `operaton:type` outside `external`, `mail`, and `shell`, or a `mail`/`shell` type on a thrown message -> `UnsupportedServiceTaskFormError` (ADR-0042)
- A mail or shell task missing a field its behaviour's own parse requires or naming a field its behaviour class does not declare draws the same error.
- So does a shell task carrying a field as an expression, or spelling a flag outside `true`/`false` in any letter case.
- A service, send, or business rule task without a decision reference that carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression` draws it too, since `BpmnParse.parseServiceTaskLike` fails the deployment (ADR-0043).
-- a sequence flow's condition expression or a conditional event definition's condition carrying a `language`, which Operaton hands to a script engine rather than evaluating as the UEL expression this tool writes -> `UnsupportedConditionExpressionError`
-- an `operaton:errorEventDefinition` on an external task carrying `errorRef` and no `expression`, which fails the deployment, or an `errorRef` naming no error root with a code -> `UnsupportedErrorMappingError` (ADR-0038)
-- a user task carrying a `bpmn:humanPerformer` beside `operaton:assignee`, or more than one `bpmn:humanPerformer`, both of which Operaton refuses to deploy -> `UnsupportedAssignmentError` (ADR-0039)
-- an event-based gateway carrying `operaton:asyncAfter`, which `BpmnParse.parseEventBasedGateway` refuses to deploy -> `UnsupportedEventFeatureError` (ADR-0040)
-
-Every refusal shares the abstract base `UnsupportedConstructError`, so a consumer classifies the whole family with a single `instanceof` check while each subclass still carries construct-specific metadata for a tailored message.
-
-Warned constructs are returned in a `warnings: ImportWarning[]` array alongside the IR.
-`xmlToIr` now returns `{ ir, warnings }` rather than a bare `BpmnProcess`:
-
-- an Operaton or camunda extension attribute or element whose content the IR does not read.
- The boundary is drawn per owner kind rather than by a list of names, so `operaton:assignee` is data on a user task and a reported drop on a service task.
-- an external task's `operaton:taskPriority`, `operaton:properties`, or `operaton:errorEventDefinition` on a service, send, or business rule task bound by class, expression, delegate expression, decision, or a built-in mail or shell type, since `parseExternalServiceTask` alone reads them and no other binding reaches it (ADR-0038, ADR-0042)
-- a shell flag the engine deploys and then reads as `false`, one spelled `TRUE` rather than `true`, carried as written with a warning that the printed script draws an error there (ADR-0042)
-- on a form field, what the engine never reads: a `datePattern` off a `date` field, `operaton:value` children off an `enum` field, and a `config` on `required` or `readonly`.
- What the engine deploys and then fails on is carried as written, with a warning that the printed script draws an error there.
- That is a bound on a type its validator refuses, a bound whose `config` is not an integer, and a literal enum default naming no value.
- A repeated enum value id is kept once, as the engine keeps it, and the warning names the rewrite (ADR-0037).
-- lanes
-- a `name` on an event handler, a boundary event, a throw, an emit, or an await, since none of those has a label slot in this tool's surface; and a `name` on a start or an end whose id carries a synthesized-id prefix, which no script can spell back, so the statement that would have carried the label is left out whole
-- a `bpmn:Message` or `bpmn:Signal` root that nothing in the imported process references, a receive task's `messageRef` included; and an error or escalation root carrying no code, which nothing can key it by.
- An error or escalation root carrying a code imports as the declaration ADR-0030 gives it, whether or not anything raises it.
-- `bpmn:documentation` on the definitions root, an event definition, an external task's `operaton:errorEventDefinition`, an Error, Escalation, Message, or Signal root, a multi-instance loop characteristics element, a sequence flow, or one of the three intermediate events with no label slot, since none of those has a node in the IR to hold it.
- `bpmn:documentation` carrying more than one child on one element, or a child whose `textFormat` is not plaintext, warns the same way; a single plaintext child on any other element imports as the `documentation` ADR-0031 gives it.
-- BPMN content on an element the transform touches that no reader reads: an artifact on a process or a sub-process (a `bpmn:textAnnotation`, its `bpmn:association`, a `bpmn:group`), a `bpmn:ioSpecification`, a `bpmn:property`, a data association, a `bpmn:auditing` or `bpmn:monitoring` block, and a resource role the engine reads nothing of: a `bpmn:performer`, a role with no formal expression, and any role on an activity other than a user task.
- On a user task a `bpmn:humanPerformer` and a `bpmn:potentialOwner` import onto the Operaton assignment attributes instead, with a warning per role naming the rewrite (ADR-0039).
-- a root element other than the process and the error, escalation, message, and signal roots the events resolve against, such as a `bpmn:category`, a `bpmn:dataStore`, a `bpmn:itemDefinition`, or a `bpmn:interface`
-- an attribute written without a namespace that BPMN does not declare
-- a BPMN-declared attribute the engine itself reads nothing of, an `instantiate="true"` or an `eventGatewayType` other than `Exclusive` on a wait with several branches, or a `startQuantity` or `completionQuantity` other than `1` on an activity (ADR-0039), so the imported process runs exactly as the source document does.
- Each is reported only where the document set it away from the value it reads back as when nothing is written, which is why an exported gateway carrying `eventGatewayType="Exclusive"` warns about nothing.
-- a `default` on a step, which the engine does read: it names the route Operaton takes when no other route out of the step is taken, and this tool carries a fallback on a split alone, so the imported process loses that route and does not run as the source document does.
-- a `bpmn:standardLoopCharacteristics` or a `bpmn:loopCondition` on any host, which Operaton parses and never dispatches on, so the step imports and runs once exactly as the source document runs it
-- a `bpmn:dataObject`, a `bpmn:dataObjectReference`, or a `bpmn:dataStoreReference`, which Operaton replaces with its own variable mechanism and never reads
-- `isExecutable="false"` on the process, which imports as an executable process regardless
-- a `bpmn:manualTask`, which imports as a plain step, since `ManualTaskActivityBehavior` is a pass-through identical to a plain task.
- The warning names the rewrite rather than the drop, because the engine stores the tag it parsed as the activity type and history reports `task` where the document said `manualTask`.
-
-No element leaves the transform unreported.
-Whatever it cannot carry is named by the tag the document spells and by its own id, and attributed to the element it sat on, a construct being reported whole so that a dropped `bpmn:ioSpecification` names itself rather than each data input inside it.
-The lane structure goes the same way, reported lane by lane, with anything hung on a lane or a lane set leaving with it.
-What is dropped without a warning is the diagram interchange data ADR-0003 settles, and an attribute the transform does not read.
-One shape of that is an attribute in a foreign namespace written directly on a mapped BPMN element, which is where an editor parks its own bookkeeping.
-The same attribute on an extension child the IR reads is reported, and so is an attribute written in no namespace at all that BPMN does not declare, since no editor writes there.
-The other is a BPMN attribute the transform neither reads nor reports, such as a sequence flow's `name` or a process's `processType` or `isClosed`.
-
-Returning `{ ir, warnings }` makes the warnings channel unignorable at the type level: every call site must destructure or explicitly discard `warnings`, so a caller cannot silently drop the diagnostics this decision exists to guarantee.
-The alternatives, an optional collector parameter (`xmlToIr(xml, sink?)`) or a second function (`xmlToIrWithWarnings`), leave the channel easy to skip.
-
-The contract covers two hops.
-`irToDsl` reports in the same shape, returning `{ source, warnings }` for the reason `xmlToIr` returns `{ ir, warnings }`: a channel a caller can skip is a silent drop.
-Its warnings carry the same fields and report what the print hop could not carry into the script.
-Some of what they name merely drops, such as a gateway label no statement form can hold.
-Some changes what a recompiled document runs, such as a fallback re-derived on an imported fork whose branches all carry conditions and that named none.
-Some is printed as the model spells it and draws an error when the source is read back: an `else` beside a branch that runs whatever the conditions do leaves the fallback nothing to take, and the script it prints does not recompile at all.
-Every one of them is warned rather than refused, because a refusal has no meaning on this hop: the restructurer is total by ADR-0009 and always produces source.
-The reports themselves live in `packages/transform/src/ir-to-dsl.ts`, where `PrintWarningCategory` and the warnings built beside it settle which the print hop makes and what each one costs.
-ADR-0025 is the decision that extended this contract to the print hop.
-
-### Consequences
-
-- Good, because every caller (the CLI, the VS Code extension, the round-trip test suite) now surfaces both the refusal and the warning channel instead of only one or neither.
-- Good, because the shared `UnsupportedConstructError` base keeps consumer classification to one `instanceof` check as new refusal categories are added.
-- Bad, because some warned items on the import hop do bear on what runs, against the boundary this decision draws, among them a dropped `operaton:field`, which leaves the bound class without a value it was injected with, a dropped `default` on a step, which takes away the route the engine falls back to, and `isExecutable="false"`, which imports as an executable process.
- Refusing any of them would reject files that otherwise import cleanly, and carrying `isExecutable` through would mean an IR field, a serializer path, and a DSL surface for a flag this tool has no use for, so they are reported instead.
-- Bad, because every call site of `xmlToIr` had to migrate from `const ir = await xmlToIr(xml)` to `const { ir } = await xmlToIr(xml)`, a one-time, mechanical, but repo-wide edit.
-- Bad, because a handful of undeclared `operaton:` extension elements cannot be tied by `bpmn-moddle` to a specific owning element; their warnings are attributed to the process id rather than the precise element.
-
-## More Information
-
-The exact refuse/warn boundary and the `ImportWarning` shape (`elementId`, `category`, `message`) are documented in `packages/transform/src/errors.ts` and `packages/transform/src/xml-to-ir.ts`; a consumer-facing summary is in `packages/transform/README.md`.
-
-Amended by ADR-0030, which narrows `unreferencedRoot` to the roots the surface still cannot hold.
-
-Amended by ADR-0031, which narrows the `bpmn:documentation` bullet to the positions the surface still cannot hold.
-
-Amended by ADR-0033, which lifts the refusal on a call activity's variable mapping and narrows the bullet to the shapes the surface still cannot resolve or write back.
-
-Amended by ADR-0036, under which the label bullet's "left out whole" holds for a start, and for an end only at its block's tail; anywhere else the end prints under its reserved id, label and all, and the print reports it.
-
-Amended by ADR-0037, which adds the form field constraint refusal and the form field drops, carries, and rewrite to the lists above.
-
-Amended by ADR-0038, which adds the error mapping refusal and the warning on an external task's extras under a binding the engine never reads them for.
-
-Amended by ADR-0039, which adds the assignment refusal, narrows the resource-role drop to the roles the engine never reads, adds the quantity attributes to the warned list, and removes `startQuantity` from the unreported examples.
-
-Amended by ADR-0040, which adds the event-based gateway refusal above and carries a gateway's five job settings instead of warning about them.
-A listener or an input/output parameter found on a gateway still warns, since no gateway kind reads either.
-
-Amended by ADR-0041, which carries an async, exclusive, or retry setting found on a repetition's `multiInstanceLoopCharacteristics` element instead of refusing it, narrowing the `UnsupportedLoopCharacteristicsError` bullet above.
-
-Amended by ADR-0042, which adds the `UnsupportedServiceTaskFormError` and extras-warning detail above for a mail or shell task.
-
-Amended by ADR-0043, which adds a result variable beside a class or delegate expression binding to the `UnsupportedServiceTaskFormError` bullet above.
-
-Related decisions: ADR-0006 (the shared IR, where `warnings` deliberately lives outside the IR, which stays serializable).
-ADR-0007 (the Operaton moddle extension fork, whose declared and undeclared elements determine warning-attribution precision).
-ADR-0003 (auto-layout for diagram interchange, the one kind of element this contract drops without a warning).
diff --git a/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md b/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md
new file mode 100644
index 0000000..4e403f4
--- /dev/null
+++ b/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md
@@ -0,0 +1,149 @@
+---
+status: accepted
+date: 2026-06-12
+decision-makers: Marlon Kranz
+---
+
+# Restructure the IR into a DSL with dominator analysis
+
+## Context and Problem Statement
+
+The decompile direction (`irToDsl`) must turn a flat BPMN graph (the IR) back into structured BPMNscript source.
+The graph may have come from a graphical modeler and may be structured, partially structured, or entirely unstructured (irreducible).
+
+How should `irToDsl` decide which subgraphs can be phrased as `if`, `while`, `parallel` or `await` blocks, what form the routes it cannot fold take, and what happens to a region it cannot phrase at all?
+
+## Decision Drivers
+
+- The reconstruction must be total: every valid IR must produce a valid DSL string and never throw, because the CLI `parse` command must always produce output.
+- Structured constructs should be recovered wherever the graph allows it, so the decompiled output is readable and round-trips without information loss.
+- The analysis must handle AND fork and join pairs (parallel gateways), not just XOR.
+- Unstructured graphs (irreducible control flow, cross-branch jumps) must not crash the decompiler; they degrade to `goto` statements.
+- A jump is the fallback for what the catalog cannot fold, never the first answer for a shape it can fold.
+- A report describes what runs differently and nothing else, so a print that routes the way the model routes draws none, and what the marker line and the warnings channel say has to be enough to start a hand repair, so a lost split names itself in the source.
+- The page says what the graph does.
+ Statements run top to bottom and the flow between them is never written out, so a statement missing from the page is a flow the reader cannot see.
+- The honest import contract (ADR-0012): what the round trip cannot carry is reported, never dropped without a word.
+- The logic must be isolated and separately testable, not entangled with IR types or the grammar.
+
+## Considered Options
+
+- Dominator and post-dominator analysis with a fixed pattern catalog and a `goto` fallback: each construct gets a criterion that reads the graph rather than a heuristic, and the fallback is a form the language already has, so totality costs no new syntax.
+- RPST (Refined Process Structure Tree) decomposition: a region tree would recover more structured patterns and would answer the continuation question for every construct at once, but the catalog with a `goto` fallback already covers the current scope.
+- Ad-hoc recursive pattern matching without formal control-flow analysis: there is no criterion to check a match against, so an unstructured graph has no defined outcome and the decompiler is free to loop or to throw.
+- An `if` chain for a step with more than one route out, and jumps carrying no conditions where a split degrades: the print of a common modeler shape, a task with two plain flows, then runs one route where the model runs both, and a degraded split's conditions are discarded with no marker for the reader to find.
+- Refusing a step with more than one outgoing flow on import: the printer never sees the shape, but the engine deploys and runs it, and the import contract refuses only what the engine refuses or what changes a stored value.
+- Keeping a split whose every route is conditioned as jumps wherever it sits: the print reports the drop and no rule is added, but a condition on every route with no default named is the common modeler shape, and the process the thesis demonstrates on came back running differently.
+- Deferring the chain that holds an elided synthesized end to the last position in its block: it conflicts with the elided-start-first rule whenever one chain holds both, only one chain can be last where there are several elided ends, and printing the end in place covers every shape it would.
+
+## Decision Outcome
+
+Chosen option: dominator and post-dominator analysis with a fixed pattern catalog and a `goto` fallback, because dominator analysis gives a mechanically checkable criterion for each structured construct, and the edges the catalog cannot fold degrade to `goto` instead of to a failure.
+
+The analysis is `analyzeCfg(process): CfgAnalysis`, a pure function over the IR with `immediateDominator`, `immediatePostDominator`, `dominates`, `postDominates`, `backEdges`, `outgoing` and `incoming` queries.
+`VIRTUAL_ENTRY` and `VIRTUAL_EXIT` give the dominator algorithm a unique single entry and exit.
+
+The pattern catalog:
+
+- XOR split with a post-dominating join -> `if`/`else if`/`else`
+- Statement with more than one route out -> the fork block the engine runs it as, inclusive or parallel
+- Unconditioned back-edge from a body exit to the XOR head dominating it -> `while`, the loop condition read from the head's edge into the body, when that edge is the head's first route other than its default
+ Operaton tests the routes in document order, so an exit listed before the body route is tested first, which `while` cannot say, and the head then prints as an `if` chain with the back edge as a dropped-edge marker.
+ The body's end is where a split's other routes fall through, so a back edge that leaves an exclusive split ahead of another route other than the default would print with that route tested first, and it prints as a dropped-edge marker instead.
+- Conditioned back-edge from an XOR head to the body entry dominating it -> `do...while`, the loop condition read from the back-edge itself, when that back-edge is the head's first route other than its default
+ A route listed before it keeps its place as the head of an `if` chain, with the back-edge as a `goto` into the body.
+- AND fork with a matching AND join -> `parallel { { } { } }`
+- OR fork with a matching OR join -> the same `parallel` block with each conditioned branch headed by its condition, and the fallback flow heading a branch as `else`.
+ A fallback that runs straight into the join is left out where enough branches remain to fill the block, and prints as an empty `else` where dropping it would leave too few.
+- Event-based gateway whose every outgoing flow reaches an intermediate catch event -> `await { ... }` with one branch per catch, continuing at the exclusive merge the branches share when they have one
+- Every other edge -> `goto `, or a dropped-edge marker where the edge has no name to jump to
+
+A gateway a pattern folds is never printed, which is what keeps the round trip idempotent.
+
+`emitRoutes` asks where a split's routes come back together in six steps.
+The split's own merge comes first: `pairMerges` walks up the dominator tree from each merge, a split passed on the way being closed by a merge passed before it, and the first split left open owns the merge.
+`ownJoin` takes that merge while it is unprinted, at least one route reaches it before the stop node, and no route runs through a gateway the split does not dominate on the way.
+The clean join is the post-dominator the split dominates, refused when the enclosing construct's stop node does not post-dominate it, since inside a `do` body the body dominates every node behind the loop, so a join behind the loop would otherwise pass the dominance checks whenever a leaving route and the loop exit share an end.
+`convergence` reads where the split's live routes come back together off the model's routes by forward reachability bounded by the stop node, and so answers a split whose branch can end and whose post-dominator therefore lies at the exit.
+The guard clause answers when exactly one route is unconditioned, that route being the continuation; it admits a bare authored terminal as a branch entry when the split's route is the terminal's only incoming flow, so the terminal prints inside the `if` rather than as a `goto` with a trailing statement, and it admits a gateway the split dominates, which a jump could not name anyway.
+Operaton takes the first route in document order that carries no condition or one that holds, so the chain keeps that order: a route without a condition listed before a conditioned one, the guard clause's continuation included, heads its branch as `true` in its place, and the routes after it stay in the chain although neither the engine nor the rebuilt split ever takes them.
+The enclosing continuation is the stop node the enclosing construct handed down, and answers only while that node is defined, no route is unconditioned, and at least one route's target is dominated by the split and post-dominated by the stop node; the chain then walks that route inline, prints the routes that leave the region as jumps, and stops at the stop node where the enclosing construct carries on.
+The same rule serves a split inside a `while` body, a `do` body and a branch, including a split inside a guarded branch.
+Outside any enclosing construct, a split whose every route is conditioned and that has no default continues at a route whose target has no other incoming flow, which leaves that target unprinted and outside any loop the split is not in: its last route in document order when that one qualifies, else its first that does.
+The recompiled split gains a fallback the model never named, which the print reports, and that fallback runs into the statement the print put after the `if`, so a second print reads the same.
+The last route prints as the chain's fall-through rather than as an empty branch, so the print builds without the validator's empty-branch warning.
+Its condition gives way to the fallback, and since Operaton tries the routes in document order and so tries that route last anyway, only the no-match case changes, which the print reports.
+Any other route keeps its condition over an empty branch, since dropping it would make the route the engine tries first the one the rebuilt split tries last.
+With no such route the jumps stay, closing with no `else`.
+A loop gateway's own exit condition prints as the fall-through after the `} while (...)` line and is reported as dropped.
+Every route of a folded exclusive split gets a form: a condition heads its branch, and a route carrying none heads a branch as `true`, takes the chain's `else`, or runs straight into the join as the chain's fall-through, since heading that last route instead would put a `true` over an empty branch and leave the rest of the chain unreachable.
+
+`followLinear` reads a step's routes off the model, and two or more hand the step to the fork printer as its split, since `BpmnActivityBehavior.performOutgoingBehavior` leaves a step by every flow whose condition holds or that carries none, and by the flow the step names as `default` only when none was taken.
+The block is inclusive when any route carries a condition or the step names a `default`, the rule `InclusiveGatewayActivityBehavior.execute` applies to a gateway of that kind, and parallel otherwise, with the `default` as the block's `else`.
+The one shape a choice routes the same way, exactly one weighed route beside the `default`, keeps the `if`/`else` the choice prints, which reads better and lowers to the same routing.
+The fork printer is keyed on a split's id and kind rather than on a gateway, so a step's block takes the merge's `join` settings and no head settings of its own.
+
+A fork or race the catalog cannot fold prints one marker line naming the split and its kind, `split G degraded to jumps; was inclusive`, and then an `if` chain of jumps with each route's condition on its own jump and the split's fallback as the `else`, each jump taking a branch of that chain since a jump ends its block and a second one beside it could never run.
+The report says the conditions are on the jumps and that the split's kind, whether it opened one branch, every branch whose condition held, or the first to resolve, is written nowhere.
+An exclusive split with no clean join prints its jumps with no marker: an `if` chain of jumps is a choice, and an exclusive split takes the first route whose condition holds (`ExclusiveGatewayActivityBehavior.doLeave`), so nothing of it is lost.
+
+A gateway with no route out prints nothing and is reported by kind, the report naming what the model does there: a parallel gateway ends the run as the script's block end does (`ParallelGatewayActivityBehavior.execute` leaves by no route and `PvmExecutionImpl.leaveActivityViaTransitions` ends the execution), an inclusive gateway stops with an error (`InclusiveGatewayActivityBehavior.execute` throws `stuckExecutionException`), an event-based gateway waits forever with nothing to wait for, and an exclusive gateway keeps the model from deploying (`BpmnParse.validateExclusiveGateway`), which the import refuses ahead of the printer, so that last report covers hand-built IR alone.
+The loop patterns ignore a back edge whose source is its target, so such a gateway prints as a choice whose route back into itself has no name and takes the dropped-edge marker, while its exit route keeps its condition.
+An event sub-process a flow edge leads into is malformed IR and the printer throws, as it does on a duplicate id; the import refuses the flow ahead of it.
+
+Some edges have no `goto` form at all.
+An edge arriving at a gateway that still chooses between branches cannot be named, because a `goto` names a statement and a gateway has none, so the jump is only expressible through the gateway's successor and only while the routing has a single outcome.
+An edge whose target the printer elides cannot be named either, the elided element leaving no statement behind for a jump to spell.
+Such an edge is dropped, a marker comment is printed where it would have gone naming the element it led into, and `irToDsl` reports the drop on a warnings channel of its own.
+The marker comment stays in the printed source, where it is the reader's pointer to the place needing repair.
+The branch walk records the `await` or `parallel` branch each statement is printed in, as the path of branch ids from the outermost block down, and every jump records the path it was printed under.
+Once the walk is done, a jump whose target sits in a branch the jump's path does not begin with is reported, since the validator refuses a `goto` into a branch from outside it: a branch's steps run only when the whole block is reached.
+The jump is written all the same; the model is where the route comes from.
+
+A plain end event whose id carries a synthesized prefix and holds nothing printable is left out only where it is its block's tail, at its block's own depth with no flow statement of the same block printed after it.
+The compiler mints such an end in `lowerContainerBody` and `lowerBoundaryHandler`, both at the tail of a block, and never inside a branch or loop body, whose exit it wires to the join or the loop head instead, so only at a tail does reading the source back re-derive the same terminal.
+`isElidedOnPrint` stays the model-level predicate and keeps answering "the printer may drop this end", which `warnRefusedStatements`, `forwardToRealTarget` and the importer's `warnElidedNamedDrop` read.
+Whether the printer does drop the end is a fact about print order, known only once every chain of the block is on the page, so that half of the decision sits in the emitter: `emitNode` records the end with the position it would have printed at, and `Emitter.emit` then leaves out an end nothing followed and splices any other back at its position, with one report per printed end, in page order.
+The printed source draws exactly one validator error, on the printed `end` line, and renaming the id in the model makes the source clean and round-trips the document structurally unchanged.
+The import warning on a labelled plain end describes both outcomes, since the importer cannot know which position the printer will give an end: where the script can do without the end it is left out and its label with it, and anywhere else it prints under a name the script refuses.
+Either way the fix it names is a rename in the diagram.
+
+### Consequences
+
+- Good, because the algorithm terminates and produces parseable DSL for every IR, total over the supported scope, and the fallback costs the language no new syntax.
+- Good, because AND fork and join pairs are recovered as `parallel` blocks without special-casing the decompiler.
+- Good, because a step with two plain routes or with a weighed route beside a plain one round-trips to the routing the engine runs, with no report.
+- Good, because the conditions of a degraded split survive on its jumps, and the marker line puts the reader at the split rather than leaving the jumps to explain themselves.
+- Good, because the fork printer, the invented-fallback report and the fallback-condition report each have one home, and a gateway and a step share it.
+- Good, because the CFG analysis is a pure, stateless utility with its own test suite, auditable independently of the emitter, and the same dominator queries answer the own merge, the clean join and the enclosing continuation, while `convergence` reads forward reachability and the guard clause only which routes carry a condition.
+- Good, because a split whose every route is conditioned inside a loop body prints its staying routes inline, so the Operaton invoice example prints its review call inside the loop and the rebuilt process keeps the loop.
+ A cascade of such splits inside one body prints as nested chains, each continuing at the same stop node, and the rule adds no state to the walk.
+- Good, because the restructurer reports what it drops instead of leaving the caller to find it, including where the drop changes what a recompiled document runs.
+ A fallback re-derived on an imported OR fork that named none is one such report: `InclusiveGatewayActivityBehavior` throws a stuck execution when every branch of such a fork carries a condition and none of them holds, while the printed block falls through, so a document recompiled from that script runs on where the model would have stopped.
+- Good, because a synthesized plain end that is not its block's tail is refused loudly instead of rewiring its chain into whatever the printer appended next: the reader finds a reserved id on the page, a print warning, and a validator error naming the element to rename.
+- Neutral, because a route running straight into an enclosing construct's stop node prints as an empty branch, which the validator warns about; the condition on it survives, where a jump would have lost the branch.
+- Neutral, because a step whose routes reconverge at a merge of another kind, or at a step, degrades to jumps under the marker: the block form synchronizes at a merge of its own kind, which the model does not have there.
+- Neutral, because a jump into an elided end is still dropped and marked.
+ `forwardToRealTarget` reads the model-level answer, and at the top level the jump is printed before the end's position is known; for a plain end in the same container the re-derived tail end is the same terminal, so the process runs the same and the marker is the only cost.
+- Neutral, because RPST would recover more structured patterns, for example nested switch-like gotos, but is left for later, once the scope justifies the added machinery.
+- Bad, because topology-based back-edge disambiguation, `while` against `do...while`, requires checking the `conditionExpression` field, not just graph shape.
+- Bad, because a target the stop node post-dominates but the split does not dominate is walked inline by the chain all the same, the way the guard clause walks it, so a target reached from elsewhere leaves its other edges as jumps.
+- Bad, because "tail" for a synthesized end is decidable only after the block's passes, so the emitter carries a deferred list and a resolve step that a reader has to find.
+- Bad, because the decompiler always produces parseable source but not always source that validates, and not every edge survives.
+- Bad, because a modeller's document with a default-named plain end that is not last prints a reserved id and is refused until renamed, and the import-side label warning can only name the two outcomes rather than the one that will happen.
+- Bad, because a step whose routes merge at a merge of another kind prints jumps under a marker where an `if` chain would read better, that chain being a choice the model is not.
+- Bad, because a branch that ends inside a loop body, a `do { end } while`, a loop gateway the compiler leaves without an incoming flow, and a post-test loop whose body ends inside a branch still print as jumps or markers, the last of them a jump the validator refuses; these are reported and pinned as they are.
+
+### Confirmation
+
+`irToDsl` is verified total by the unit test suite (`packages/transform/test/`): every test input produces source and its warnings, and never throws.
+The goto-degradation path is confirmed by `tests/golden/unstructured-goto.bpmn` in `tests/round-trip-constructs.test.ts`.
+The tables in `packages/transform/test/ir-to-dsl.test.ts` pin the printed source, the whole warning list, the re-parse and the real-node reachability for each shape above: a step whose own routes split, the degraded marker with its weighed jumps, the empty-split report per gateway kind, the self-looping gateway, the jump across a branch border, the guard clause, a split inside a loop body whose every route is conditioned, and a synthesized plain end that is not its block's tail.
+`tests/goto-fallback.round-trip.test.ts` runs the composite shapes a fuzz run drew through compile, export, import and print, pinning each print's warning categories and whether it validates, and pins the modeler's shape of a conditioned split in a loop through the XML hop.
+The `review-loop` round-trip suite pins the frozen artifact byte for byte, its import without a warning, and the chain inside the `do` body, and every round-trip suite under `tests/` validates the source printed from its golden with zero error diagnostics, which is the proof that no golden's printed form moved.
+
+## More Information
+
+The CFG analysis lives at `packages/transform/src/cfg-analysis.ts`.
+In `packages/transform/src/ir-to-dsl.ts`, `emitRoutes` chooses the continuation, `cleanJoin` holds the containment check, `convergence` and `enclosingContinuation` sit beside it, `branchStaysInRegion` decides a guard clause's branch entry, `followLinear` reads a step's routes, `emitFork` prints a fork block, `emitJumps` the degraded chain, `emptySplitWarning` the report for a gateway with no route out, and `emitBranch` with the check at the end of `Emitter.emit` does the branch bookkeeping for a jump across a branch border.
+Each print warning category and what it costs is listed in `packages/transform/README.md`.
diff --git a/docs/decisions/0015-subprocess-di-expansion-hint.md b/docs/decisions/0015-subprocess-di-expansion-hint.md
deleted file mode 100644
index 2a1cc0e..0000000
--- a/docs/decisions/0015-subprocess-di-expansion-hint.md
+++ /dev/null
@@ -1,85 +0,0 @@
----
-status: accepted
-date: 2026-07-17
-decision-makers: Marlon Kranz
----
-
-# Author a Diagram-Interchange Expansion Hint for Sub-processes
-
-## Context and Problem Statement
-
-ADR-0003 established that `irToXml` never computes its own coordinates: it serializes DI-less BPMN XML and hands it to `bpmn-auto-layout`, which lays out every element and injects the `bpmndi:` diagram-interchange data.
-That decision assumed a flat process with one plane and no nested containers.
-
-Embedded sub-processes break the assumption.
-Fed DI-less XML containing a `bpmn:subProcess`, the installed `bpmn-auto-layout` does not throw, but it also does not produce a degraded-but-sane result: it renders the sub-process as a collapsed box and scatters shapes for its nested children into the root plane, at coordinates that duplicate unrelated top-level elements.
-The output parses and validates, so nothing signals the problem.
-A viewer opening the generated diagram would show the sub-process's children floating on top of sibling top-level elements instead of nested inside their parent.
-
-How should `irToXml` produce a correctly laid-out diagram for a process that contains a sub-process, without taking on any layout computation of its own?
-
-## Decision Drivers
-
-- ADR-0003's no-custom-layout-code stance should hold for sub-processes too.
- A bespoke box-and-offset algorithm here would duplicate what `bpmn-auto-layout` already does correctly once it is told to expand.
-- The fix must not touch the semantic element tree or its ids, because DI is presentation only.
-- A process without any sub-process must keep producing byte-identical output; this is the golden path the export pipeline is tested and deployed against.
-
-## Considered Options
-
-- Pre-seed a minimal `isExpanded` shape hint per sub-process before layout
-- Accept the collapsed rendering and post-process the output to strip the misplaced child shapes
-- Compute sub-process bounds and child placement manually, bypassing `bpmn-auto-layout` for nested containers
-
-## Decision Outcome
-
-Chosen option: "Pre-seed a minimal `isExpanded` shape hint per sub-process before layout", because it stays inside `bpmn-auto-layout`'s own contract.
-The library already reads an existing shape's `isExpanded` flag before computing bounds; it simply had nothing to read when none was pre-existing.
-Supplying that one boolean per sub-process is the smallest input that makes the library take the branch it already has for expanded containers.
-
-Before calling `moddle.toXML`, `irToXml` walks the built moddle tree for every `bpmn:SubProcess` element at any nesting depth.
-Only when at least one exists, it attaches a `bpmndi:BPMNDiagram` holding a `bpmndi:BPMNPlane` rooted at the process, which in turn holds one `bpmndi:BPMNShape` per sub-process, each carrying `isExpanded="true"` and a `bpmnElement` reference to its sub-process.
-No `dc:Bounds` are supplied: the library recomputes and discards any bounds on a pre-existing shape, computing correct ones from the expanded layout instead.
-What the library does need, and what the shape carries for no other reason, is an `id`.
-It locates a pre-existing shape by looking it up in its own id-keyed element index while parsing the DI-less-but-hinted XML, so a shape without one is invisible to it even though nothing in the final output ever references that id back.
-
-`bpmn-auto-layout` fully replaces this hint during layout: it discards every pre-existing diagram (`this.diagram.diagrams = []`) after reading the `isExpanded` flags off it, then regenerates the diagram it actually serializes.
-The hint therefore never survives into the output as such; it is a one-shot instruction, not persisted diagram data.
-
-### Consequences
-
-- Good, because the change stays inside `irToXml`'s existing DI-generation call and adds no dependency, no coordinate math, and no custom layout logic.
- The library still produces every bound and waypoint in the final document.
-- Good, because a process without a sub-process attaches no hint at all, so its output is byte-for-byte unchanged.
-- Bad, because the hint's correctness is pinned to one specific behavior of the installed `bpmn-auto-layout` version, namely reading `isExpanded` off an id-keyed pre-existing shape.
- A library upgrade that changes how it detects expansion could silently regress nested layout without any XML-validity signal.
-
-### Confirmation
-
-A regression tripwire in `ir-to-xml.test.ts` builds an IR whose sub-process has two or more children, runs it through the real `irToXml` with no mocks and the actual installed `bpmn-auto-layout`, and asserts every nested child's `bpmndi:BPMNShape` bounds fall strictly inside its parent sub-process's shape bounds.
-The same test suite also pins two-level nesting (inner sub-process inside outer, inner children inside inner), exactly one `bpmndi:BPMNDiagram` in the final output (the library's regenerated diagram, not a duplicate of the hint), and that a sub-process-free process's output stays byte-identical to the frozen golden.
-
-## Pros and Cons of the Options
-
-### Pre-seed a minimal `isExpanded` shape hint per sub-process
-
-- Good, because it is a handful of lines that reuse a code path the layout library already has.
-- Good, because it degrades to a no-op for sub-process-free processes.
-- Neutral, because it depends on empirically observed library internals rather than a documented public contract for this exact scenario.
-
-### Accept the collapsed rendering and post-process the output to strip misplaced shapes
-
-- Bad, because a collapsed sub-process still loses the point of an embedded sub-process in a viewer: the reader cannot see its children without manually expanding and re-laying-out the box themselves.
-- Bad, because stripping the scattered child shapes without breaking id references between the semantic tree and the DI is more code than the chosen option, for a worse result.
-
-### Compute sub-process bounds and child placement manually
-
-- Bad, because it reintroduces exactly the custom layout code ADR-0003 exists to avoid, for one specific case, while the rest of the diagram still depends on the library.
-- Bad, because keeping a hand-rolled nested-box algorithm visually consistent with `bpmn-auto-layout`'s own spacing and sizing conventions is an ongoing maintenance burden with no natural termination.
-
-## More Information
-
-Implemented in `packages/transform/src/ir-to-xml.ts` (`buildSubProcessExpansionHint`/`collectSubProcessElements`), called from `irToXml` right before serialization.
-
-Related decisions: ADR-0003 (auto-layout owns all DI generation; this decision extends rather than revisits that choice).
-ADR-0006 (the IR itself carries no layout information, since the hint is derived purely from the moddle tree built for export and never stored).
diff --git a/docs/decisions/0015-the-print-declares-every-variable-it-reads.md b/docs/decisions/0015-the-print-declares-every-variable-it-reads.md
new file mode 100644
index 0000000..323b61e
--- /dev/null
+++ b/docs/decisions/0015-the-print-declares-every-variable-it-reads.md
@@ -0,0 +1,65 @@
+---
+status: accepted
+date: 2026-09-14
+decision-makers: Marlon Kranz
+---
+
+# The print declares every variable it reads
+
+## Context and Problem Statement
+
+A `var x: number` line in the header declares a variable so the validator can check its uses, and nothing else.
+BPMN has no slot for it, the compiler writes none, and the process the engine runs is the same with or without it.
+A document nobody wrote in this language therefore carries no declaration at all, while a variable the printed script reads in a condition, a count, an `until`, an error mapping's `when` or an `in` mapping needs one or the script warns the moment it is built.
+
+Where should the declarations a rebuilt script needs come from?
+
+## Decision Drivers
+
+- The script the tool prints from a document should build without a warning about the tool's own output, since a warning there names nothing the author can fix.
+- One fact, one home: which positions read a variable and which constructs type one are the validator's rule and its symbol table.
+ The print should mirror them rather than keep a list of its own.
+- A form field and a catch binding already carry a type through the XML, so a second carrier for the same fact would be a second thing to reconcile.
+
+## Considered Options
+
+- Declare on print every variable the body reads bare, typed `any`: it works for a document nobody wrote in this language, which carries no declaration to read back, and adds nothing to the XML and needs no reader, at the cost of the type, a name being declarable only as `any`.
+- Carry `var` lines through the XML in an extension element: the type would survive the round trip, but an extension element the engine ignores would be a second home for a fact the form and catch bindings already carry, the importer would have to reconcile the two when they disagree, and a third-party document never carries it, so the derivation on print is needed all the same.
+- Keep declaring collections alone: nothing changes, but nearly every decompiled program then warns on rebuild, about the tool's own output.
+
+## Decision Outcome
+
+Chosen option: declare on print every variable the body reads bare, typed `any`, because the print already knows every position it writes a variable in, and a third-party document never carries a declaration, so the derivation is needed whatever the XML holds.
+
+The header gets one `var : any` line per name the body reads in a position the script writes bare and nothing types, in first-appearance order over one fixed walk of the model.
+The positions are the ones the validator reads a variable at: a flow's condition, a loop's count and completion condition, a conditional trigger, an error mapping's condition, an `in` mapping's source, and a bare collection.
+A body prints bare only when it fits the expression subset, so only those bodies contribute names; a raw `"${...}"` body is opaque to the validator and to the print alike.
+The typed set is the validator's symbol table, `DefaultVariableSymbolProvider.collect`.
+It holds every form field, every catch binding, every input and output parameter name, every loop element variable, and the four counters `MultiInstanceActivityBehavior` sets once anything repeats.
+`externalTask` inside a mapping and the source of an `out` mapping are left out for the reasons the validator leaves them out: the one is resolved on the external task's execution, the other in the called process.
+The type is `any` because a read says nothing more.
+Every declaration of a name has to agree on its type, so a line for a name a form already types would be an error rather than a duplicate.
+
+The walk reads the model, not the print.
+A condition the print had to drop still declares its variable, since the model reads it, the drop is reported beside it, and the declaration is what the author repairing the drop by hand needs.
+
+### Consequences
+
+- Good, because a decompiled script builds clean, the Operaton invoice example included.
+- Good, because the document the engine sees is unchanged: nothing new is written to the XML.
+- Bad, because a `var x: boolean` comes back as `var x: any`, so the type an author wrote is lost on the way through the XML.
+ A fixture that needs the type back declares the variable on a form, whose type the XML carries.
+- Bad, because the positions and the typed set are spelled in the language package and mirrored in the transform package.
+ A position or a symbol source added on one side has to be added on the other.
+ The mirror is stated in one comment on each side, and the round-trip suites turn red on the first program that drifts.
+- Neutral, because a variable a dropped condition read is declared and never read in the printed body.
+
+### Confirmation
+
+The row under "the header declares every variable the body reads bare" in `packages/transform/test/ir-to-dsl.test.ts` prints one process reading a variable at every position and typing one through every source, pinning the whole header and an empty diagnostic list on the re-parse.
+The two-pass table in `tests/round-trip-constructs.test.ts` requires an empty diagnostic list on every printed script, and its last row is a condition variable coming back declared and re-printing byte for byte.
+
+## More Information
+
+The walk is `variableDecls` in `packages/transform/src/ir-to-dsl.ts`.
+The validator's positions are in `checkExpression` in `packages/language/src/bpmn-script-validator.ts` and its symbol table in `packages/language/src/variable-symbol-provider.ts`.
diff --git a/docs/decisions/0016-derived-event-root-elements.md b/docs/decisions/0016-derived-event-root-elements.md
deleted file mode 100644
index 9447ed1..0000000
--- a/docs/decisions/0016-derived-event-root-elements.md
+++ /dev/null
@@ -1,173 +0,0 @@
----
-status: accepted
-date: 2026-07-17
-decision-makers: Marlon Kranz
----
-
-# Derive Event Root Elements From Usage: Syntactic Throw Terminality, Soft Trigger Words
-
-## Context and Problem Statement
-
-BPMN keeps error and escalation definitions in two places.
-A `bpmn:Error` or `bpmn:Escalation` root element sits under `bpmn:Definitions`, declared once per code and carrying, for errors, the message text (`operaton:errorMessage`).
-Every start, end, or intermediate-throw event that catches or throws that code carries its own `errorEventDefinition`/`escalationEventDefinition`, referencing the root by id.
-Nothing about the act of using a code in an event definition is enough, by itself, to know it needs a root element to exist; that bookkeeping sits one level up, in the document's root-element list, disconnected from where the code is actually written.
-
-The DSL's audience does not maintain BPMN documents by hand.
-Asking a user to declare a root element before referencing its code from a handler, a throw, and a catch-all elsewhere in the same process is exactly the kind of required syntax that carries no process information of its own.
-At the same time, the event surface needs two properties that BPMN's own vocabulary does not settle for a textual language: what makes a thrown event end its path versus merely notify and continue, and whether the words naming event kinds and catch-parameter fields need to be reserved to work at all.
-
-This decision covers three related points that recur across the whole event layer: how root elements come into existence, how `throw` and `emit` decide whether the flow continues, and how the words spelling the event vocabulary are lexed and validated.
-
-## Decision Drivers
-
-- The target audience writes processes, not BPMN documents; syntax that exists only to declare what a compiler could already infer from usage does not pay for itself.
-- BPMN lets an event definition reference no root at all, a catch-all.
- The surface must not force a code where the author wrote none, and must not synthesize a root element for a code that never resolves to one.
-- The language round-trips: BPMN imported into DSL source and recompiled must reproduce the same document, so every derivation rule has to invert, and a printed statement must mean the same thing regardless of where it happens to sit in its enclosing block.
-- Error and escalation are the first two event kinds; message, signal, timer, and conditional follow later.
- The design should not spend a keyword, or accumulate a root-element bookkeeping obligation, that later kinds would each need to repeat.
-
-## Considered Options
-
-For root elements:
-
-- Derive them from usage, deduped by code, with the thrown-message text as one declared exception
-- Model them explicitly as a `Definitions`-level IR root
-- Require an explicit declaration for every code
-
-For `throw`/`emit` terminality:
-
-- Decide it by keyword, as a property of the word itself
-- Decide it by statement position, so that last in a block compiles to an end event and anything else to an intermediate throw
-
-For `error`, `escalation`, `code`, and `message`:
-
-- Lex them as soft, validated identifiers
-- Reserve them as keywords
-
-## Decision Outcome
-
-Chosen: derive root elements from usage instead of modeling them explicitly, with the thrown-message text as the one piece of root data the IR stores because usage alone cannot supply it; decide `throw`/`emit` terminality from the keyword rather than from statement position; and lex trigger kinds and binding fields as validated identifiers rather than keywords.
-All three keep BPMN's own bookkeeping (registries, event definitions, reserved vocabulary) out of the author's hands wherever the compiler can carry it instead, and spend required syntax only where the alternative breaks round-trip fidelity or collides with the audience's own variable names.
-
-Root elements are derived, not modeled.
-`irToXml` walks the whole IR once, collects every distinct error and escalation code in use on a catching start event, a throwing end event, or an intermediate throw, anywhere in the document, and synthesizes one `bpmn:Error` or `bpmn:Escalation` root per distinct code.
-Every event definition carrying that code is wired to the same root through `errorRef`/`escalationRef`.
-A definition without a code (catch-all) gets no ref and contributes no root, because BPMN itself treats a ref-less catch as "any error", so there is nothing for it to point at.
-Root ids are sanitized from the code and de-collided against the rest of the document; the root's `name` is the name the code is declared under (ADR-0030), which is the code itself wherever an identifier can spell it.
-
-The one exception is the message text an error carries when thrown.
-`operaton:errorMessage` lives on the root, not on the throw statement, because two throw sites sharing a code share one root, and the message cannot live at the throw site without a mechanism to reconcile disagreeing copies.
-The surface exposes this as a process-header declaration, `error CODE(message: "...")`, read into `BpmnProcess.errorDecls` and stamped onto the synthesized root at export; import reads the message back off the root.
-It is the one piece of root-element data the IR stores explicitly, because it is the one property usage alone cannot determine: nothing about using a code says what its message reads.
-
-An imported document whose two roots share a code but disagree on the message is refused, not merged.
-Collapsing them would change what a throw carries at runtime, which is the same reasoning ADR-0014 already applies to any drop that would alter execution semantics.
-
-ADR-0030 amends this on one point: the declaration form generalizes from the message text to the code itself, so every error and escalation code is declared and every use site names the declaration.
-The inference argument this decision rests on holds on its own terms and is not withdrawn.
-What outweighs it is an argument this decision does not weigh: a declaration is what gives a code a cross-reference, and a cross-reference is what gives it jump-to-definition, find-references and rename, which for a textual DSL is the contribution rather than a convenience.
-Derivation itself is untouched, since `irToXml` still walks usage to build the roots and a declaration only adds one for a code nothing raises.
-
-Throw and emit decide terminality by keyword, not position.
-`throw error(C)` always compiles to an end event; `emit escalation(C)` always compiles to an intermediate throw that falls through to whatever follows it.
-The distinction lives in the keyword, not in where the statement sits inside its block.
-
-A single verb whose compiled form depends on being last in its block looks appealing, one fewer keyword, but it does not survive the round trip.
-Take an IR shape the importer must be able to reproduce: a branch that fires an escalation and rejoins the main flow, `split -> A -> escalation-intermediate-throw -> join`.
-Printed inside its branch block, that statement is the last statement of the block, `{ A (C) }`, simply because the branch has nothing after it, not because the event ends anything.
-A position-decided desugarer reads "last in block" and turns the statement back into an escalation end event on the way in, which drops the join edge the original graph had.
-The same printed text would then mean two different graphs depending on information the text itself does not carry, which is exactly what round-trip fidelity rules out.
-
-Deciding terminality from the keyword removes the ambiguity.
-`throw` always ends its path, matching the intuition every reader already brings from exception handling in a general-purpose language; `emit` always continues.
-Both escalation forms, end event and intermediate throw, stay independently printable, which a document containing either one must be able to reproduce regardless of where it sits.
-
-`emit` is a general continuing verb, not an escalation-specific one.
-Error has no continuing BPMN form, since there is no intermediate error throw, so `emit error` is a validator error.
-Escalation does have one, and later event kinds that fire and continue reuse the same verb instead of growing their own.
-Giving every kind its own throw-and-continue verb turns the surface into a vocabulary quiz; one shared verb keeps it small as kinds are added.
-
-Trigger kinds and binding fields are validated identifiers, not keywords.
-`on`, `throw`, `emit`, and `alongside` are the only words this event layer reserves.
-`error`, `escalation`, `code`, and `message`, the words that actually name the event kind and the catch-parameter fields, lex as plain identifiers and are checked in position by the validator instead.
-
-The reason is the audience, not parsing convenience.
-`message` and `code` are two of the most ordinary variable names in Java-style code, and reserving them would make `var message: string` a parse error anywhere in a file, not only near an event handler.
-A Langium keyword is lexer-global: there is no way to reserve a word only inside `on`/`throw`/`emit` and leave it free everywhere else.
-Four permanent collisions on exactly the words this audience reaches for is a worse trade than the validation work a soft word costs.
-
-What a keyword gives away for free (highlighting, completion, "did you mean") is rebuilt explicitly instead, each in the mechanism meant for it rather than a lexer trick.
-A validator checks `trigger`, `field`, and the declaration's `kind` against their small legal sets and names the options directly in the diagnostic; each set has at most two members, so "unknown event kind 'erorr'; write 'error' or 'escalation'" already is the suggestion.
-A semantic-token provider marks exactly the AST properties that carry meaning, so `on error` highlights `error` while `var error` stays plain, a precision a keyword-based or regex-based highlighter cannot reach.
-Completion items are offered at the same positions.
-This also pre-pays the next event kinds: `message`/`signal` become validated trigger values later at zero additional reserved-word cost.
-
-### Consequences
-
-- Good, because using a code never requires declaring it first: a handler, a throw, and a catch-all can all reference the same code without any one of them being the "first" to establish it.
- ADR-0030 gives this consequence up in exchange for the cross-reference, so a code is declared once and named at every site that raises or catches it.
-- Good, because every user of one code shares exactly one root element, so a code's message text has exactly one place to live, never several that could disagree.
-- Good, because two disagreeing imports of the same code are refused rather than silently reconciled, keeping ADR-0014's "no silent semantic loss" claim intact for this construct too.
-- Good, because `throw`/`emit` terminality is a property of the word printed, readable without looking at what follows in the block, and it matches the reading every reader already brings from exception handling elsewhere.
-- Good, because the soft-word design costs zero additional reserved words when `message`/`signal` triggers are added later.
-- Bad, because a catch-all definition contributes no root element to inspect, so a document consisting only of a catch-all handler for a code leaves nothing that names that code at the root-element level.
- That is correct, but there is no single element a tool can point to for "this code, however it's caught".
-- Bad, because the soft-word design moves work from the grammar into four separate mechanisms (validator, semantic tokens, completion, parser-error messages) that all have to agree on the same two small word sets, instead of one keyword declaration doing it once.
-- Bad, because a document whose two roots for one code disagree cannot be imported at all, even when the disagreement reads as cosmetic to a human ("Payment declined" versus "Payment was declined").
- The importer has no way to tell a cosmetic disagreement from a meaningful one, so it refuses both alike.
-
-### Confirmation
-
-`packages/transform/test/ir-to-xml.test.ts` asserts the derivation directly: a document using one code from a handler, a throw, and a catch-all handler produces exactly one root element referenced by every coded use and none by the catch-all, with dedicated cases for id sanitization and collision suffixing.
-`packages/transform/test/ast-to-ir.test.ts` and `ir-to-dsl.test.ts` pin the branch-tail counterexample as a round-trip case: an intermediate escalation throw as the last statement of a branch block prints as `emit` and re-imports as the same intermediate throw, never an end event.
-`packages/language/test/validating.test.ts` pins the soft-word behavior: `var message: string`, `if (code == "x")`, and a task named `error` all parse and validate cleanly, while an unrecognized trigger word produces the options-naming diagnostic and a misspelled binding field is reported as a setting the handler does not take, rather than a silent pass-through.
-
-## Pros and Cons of the Options
-
-### A `Definitions`-level IR root modeling `{ process, rootElements }` explicitly
-
-Every event definition would reference an explicit IR-level root object instead of a bare code string, and `astToIr`, `irToXml`, `irToDsl`, and `xmlToIr` would all thread it through.
-
-- Good, because it mirrors BPMN's own document structure exactly, leaving no derivation step that could get the mapping wrong.
-- Bad, because every consumer signature changes (the CLI, all four transforms, every existing test) to carry data the DSL surface can never author independently in the first place; there is no syntax for "declare a root without using it" beyond the message declaration this decision already provides.
-- Bad, because it reintroduces the registry-management burden this decision exists to avoid: something still has to create, id, and look up root objects, and that something is either the compiler (making the explicit IR-level model pure ceremony) or the author (reintroducing the boilerplate the derived design avoids).
-
-### Explicit declarations for every code
-
-Every error and escalation code would need a header declaration before use, generalizing how the `error CODE(message: "...")` declaration already works for message text.
-
-- Good, because it removes all derivation logic: a code is either declared or using it is a compile error, symmetrical and simple to check.
-- Bad, because it demands decl-versus-usage redundancy for no expressive gain.
- Dedup-by-code already makes usage-derived synthesis lossless for every property except the message text, which is precisely why that one property gets a declaration instead of the rule being generalized to all of them.
-
-ADR-0030 takes this option after all, on a gain measured in editor behaviour rather than in expressiveness.
-The redundancy is real and is what buys the cross-reference, so the argument above is answered rather than refuted.
-
-### Position-decided throw terminality
-
-A single verb whose compiled form depends on whether a successor statement follows it in the same block: last-in-block compiles to an end event, anything else to an intermediate throw.
-
-- Good, because it removes one keyword (`emit`) from the surface.
-- Bad, because it is unsound under round-trip, per the branch-tail counterexample above: the same printed statement, in the same textual position, would have to mean two different graphs depending on information the printed text does not carry.
-
-### Reserving trigger and field words as keywords
-
-`error`, `escalation`, `code`, `message` become real keywords, parsed and highlighted like `on`, `throw`, and `emit`.
-
-- Good, because highlighting and completion come from the generated grammar and the parser's keyword table directly, with no additional provider code.
-- Bad, because a Langium keyword is lexer-global: reserving `message` for the event surface reserves it everywhere, breaking `var message: string` anywhere in a file, on an audience for whom `message`, `code`, and `error` are ordinary variable names.
-
-## More Information
-
-Extended by ADR-0017 (message/signal/timer/conditional payloads) and ADR-0018 (compensation), the later event kinds that reuse this decision's `throw`/`emit` terminality rule and soft-word design without adding a reserved word.
-Extended also by ADR-0019 (boundary events), whose colon separator exists precisely so the trigger words stay soft.
-Amended by ADR-0030, which generalizes the message-text declaration to the code itself so that a code carries a cross-reference.
-Amended by ADR-0035, which adds the one `emit` that ends its chain, `emit link`, and argues terminality is still read off the printed words.
-
-Related decisions: ADR-0006 (the IR as the shared model, where vendor- or serialization-only data such as `targetNamespace` attaches at the IR-to-XML boundary rather than living in the IR; `errorDecls` and `escalationDecls` are the one exception, and they are an exception because they cannot be derived from usage, not because the boundary rule was relaxed).
-ADR-0010 (deterministic structural ids, whose sanitize-then-suffix collision rule synthesized root ids follow).
-ADR-0013 (the audience this design serves: no BPMN-document bookkeeping, no required syntax the compiler could supply itself).
-ADR-0014 (the honest import contract, under which disagreeing root definitions for one code are refused rather than merged, for the same reason a semantically significant drop is refused rather than warned).
-ADR-0001 and ADR-0002 (VS Code and Langium as the IDE target and workbench, the reason the soft-word design rebuilds highlighting and completion as explicit language-server providers rather than through the keyword table).
diff --git a/docs/decisions/0016-task-kinds-on-the-authoring-surface.md b/docs/decisions/0016-task-kinds-on-the-authoring-surface.md
new file mode 100644
index 0000000..06acf89
--- /dev/null
+++ b/docs/decisions/0016-task-kinds-on-the-authoring-surface.md
@@ -0,0 +1,71 @@
+---
+status: accepted
+date: 2026-08-30
+decision-makers: Marlon Kranz
+---
+
+# Task kinds on the authoring surface
+
+## Context and Problem Statement
+
+Four BPMN activity tags need a statement in this grammar: `bpmn:task`, `bpmn:sendTask`, `bpmn:receiveTask`, and `bpmn:businessRuleTask`.
+A document carrying a tag with no statement imports as something the printer cannot write back, so the round trip stops there.
+Each of the four needs a word, and every statement keyword is a name an author can never use again.
+Which four words, where do their payloads sit, and how many IR kinds do four tags need?
+
+## Decision Drivers
+
+- ADR-0006 binds a keyword to what the author means rather than to the BPMN element behind it.
+- A statement keyword cannot be soft: `Statement` being keyword-led is what lets `start S` tell its optional trigger from the next statement.
+- A Langium keyword is lexer-global, so a word spent on a statement is a variable and step name lost everywhere in a file.
+- ADR-0012 refuses what changes the run and warns only about drops that do not.
+- ADR-0007 models the IR on what the engine does, so a distinction the engine does not make should not become an IR kind.
+
+## Considered Options
+
+- `step`, `send`, `receive`, and `decide` as the four words: each says what the author is doing, three of the four are the verb itself, and the cost is four ordinary English words lost as identifiers everywhere in a file.
+- `task`, `notify`, `expect`, and `rule`: `task` names the BPMN tag and nothing else, `notify` narrows a send to a notification, and `expect` and `rule` read worse than the verb beside `user` and `service`.
+- The message name and the decision key in positional slots after the id: a label and a message name are both quoted strings, so an unkeyed one has no reading that position can settle.
+- The message name and the decision key as settings: `call` already names its target there, and `binding` and `version` already carry the rule a decision step needs.
+- One IR node for the three service-task-like tags: the discriminator is optional, so a service-task literal needs no annotation to stay valid, and the binding switch is written once.
+- One IR kind per tag: it splits a shape Operaton itself does not split, and copies that binding switch through the writer, the printer, and the reader.
+- A receive task with no message name in scope: the engine runs it, so there is nothing a refusal would save.
+- A receive task with no message name refused on import: an error path, a message, and a test spent rejecting content the engine executes, and downgrading it to a validator warning is no cheaper, since the goldens require a decompiled document to re-validate with no diagnostic at all.
+
+## Decision Outcome
+
+Chosen options: `step`, `send`, `receive`, and `decide`, every payload in the settings, one IR node for the three service-task-like tags, and the nameless receive task in scope.
+
+Sharing a word with the BPMN tag is not what ADR-0006 forbids.
+`user`, `service`, and `script` already do it, and each is legal because the word means something to a reader who has never seen BPMN.
+`send`, `receive`, and `decide` are the verbs an author means, and ADR-0006 names a decision as a thing a keyword should name.
+`task` was rejected for the generic kind because it is the one candidate carrying no meaning beyond the tag name.
+
+All four are hard keywords, so `step` cannot be a variable name, a bare expression identifier, an attribute key, a listener event, a form-field id, or a map key.
+No source file uses any of the four, and the four rules built into a live Langium parser with Chevrotain's self-analysis on drew no ambiguity report.
+`decide` sits close to `if`, which is a decision in the same vocabulary, and the two never compete for a position: `if` takes a condition and opens branches, and `decide` is a leaf activity naming a decision table.
+
+One IR node covers `bpmn:serviceTask`, `bpmn:sendTask`, and `bpmn:businessRuleTask`.
+Operaton's `BpmnParse` runs all three through `parseServiceTaskLike` when the tag carries a `class`, `expression`, `delegate`, or `topic` binding, so they share one node and an optional discriminator picks the tag.
+A business rule task naming an `operaton:decisionRef` goes to `parseDmnBusinessRuleTask` instead, and the `decision` binding that form carries is the one variant `service` and `send` refuse.
+A send task therefore has no message semantics of its own: the engine gives it none, and `send X(class: "...")` is what makes it send anything.
+
+`parseReceiveTask` subscribes a receive task to a message only when `messageRef` is present, so a nameless one is a wait state the engine's signal API continues, in scope in both directions with no refusal and no warning.
+A named one shares its derived `bpmn:Message` root with an `await message` of the same name.
+
+### Consequences
+
+- Good, because a message throw or end carries the implementation that makes the engine really send it, so this surface does not treat every thrown message as a pass-through.
+ `BpmnParse` calls `isServiceTaskLike` on the `bpmn:messageEventDefinition` in both throw positions and never inspects the `endEvent` or `intermediateThrowEvent`, so the carry lives on the definition and the same attributes written on the event are inert.
+- Bad, because `step` carries two senses at once: the language's word for the activity that does nothing, and the ordinary noun for any activity in a process.
+
+### Confirmation
+
+`packages/language/test/` and `packages/transform/test/` pin the four statements token by token, every binding rule, the import of each tag, the emitted element with its derived root, and the printed line.
+The frozen pair `tests/golden/task-kinds.{bpmnscript,bpmn}` holds all four kinds, both receive forms, both decision bindings, and a message end carrying an implementation, compared byte for byte by `tests/task-kinds.round-trip.test.ts` under an import with no warning at all.
+`tests/e2e/task-kinds.test.ts` deploys that process to a real Operaton, walks the token past the step, the send, and the decision, and parks it at the receive task until the message is correlated.
+
+## More Information
+
+Related decisions: ADR-0006 (the rule that a keyword names what the author means, and the reason `task` is not one).
+ADR-0012 (the honest import contract behind every refusal and every warning here).
diff --git a/docs/decisions/0017-event-trigger-payload-surfaces.md b/docs/decisions/0017-event-trigger-payload-surfaces.md
deleted file mode 100644
index 726653f..0000000
--- a/docs/decisions/0017-event-trigger-payload-surfaces.md
+++ /dev/null
@@ -1,125 +0,0 @@
----
-status: accepted
-date: 2026-07-18
-decision-makers: Marlon Kranz
----
-
-# Event Trigger Payloads: One Paren Slot, Validated Timer Particles, Deferred Conditional Narrowing, Name-Keyed Roots
-
-## Context and Problem Statement
-
-The event layer's `on` handler already hosts two trigger kinds, error and escalation, sharing one payload shape: an optional code string and an optional parenthesized binding list.
-Four more trigger kinds join the same `on` surface, message, signal, timer, and conditional, and between them they need three payload shapes the existing rule does not have.
-Message and signal carry a name string, structurally identical to error/escalation's code.
-Timer carries something different: a word choosing which of BPMN's three timer forms applies, followed by a time expression.
-Conditional carries a boolean expression, which wants the same parenthesized slot error/escalation already use for bindings, but for an unrelated purpose: a condition, not a catch parameter.
-
-Fitting three payload shapes into one handler surface, without forking `on` into a separate grammar rule per trigger kind and without reserving a new keyword for each shape, is the central question this decision resolves.
-Two narrower questions ride along with it: how a timer's payload text becomes one of BPMN's three timer definition kinds without silently picking the wrong one, and how much of BPMN's conditional-evaluation narrowing the surface should expose given that the DSL's audience has no reason to know it exists.
-
-## Decision Drivers
-
-- One `on` handler surface has to host all six trigger kinds without forking into six grammar rules or growing a reserved word for every new payload shape.
- The soft-word design already in place for trigger names should not be reopened to make room for new payloads.
-- A condition is a real expression, not a string.
- It needs the same round-trip and variable-participation guarantees the language's `if` conditions already have, not a demotion to opaque text that a validator, a symbol table, or a highlighter cannot see inside.
-- A timer's payload must resolve to exactly one of BPMN's three time-definition kinds.
- Because a wrong resolution changes what the engine schedules, not just how the text reads, guessing the kind from the value's shape is a real correctness risk, not a cosmetic one.
-- Message and signal need something for the engine to key its subscriptions on, and BPMN keeps that identity in root elements the same way it already does for error and escalation codes.
- Whatever the surface settles on must not reintroduce the declare-before-use boilerplate the language otherwise avoids.
-- Camunda's conditional-evaluation narrowing attributes change when a condition is checked.
- If the surface cannot express them, silently dropping them on import would be a behavior change disguised as a cosmetic loss.
-
-## Considered Options
-
-For the parenthesized slot:
-
-- One paren slot holding either a binding list or a condition, disambiguated structurally
-- A binding-marker keyword freeing the parens for bindings only
-- A string-wrapped condition
-- Reserving `condition` as a keyword to dispatch the parens
-
-For timer forms:
-
-- Timer particles (`after`/`at`/`every`) as validated identifiers mapping one-to-one to BPMN's three timer forms
-- A single particle with the timer form inferred from the value's shape
-
-For conditional narrowing:
-
-- Defer the attributes and refuse them on import rather than drop them
-- Surface them as a fourth payload shape
-- Drop them silently on import
-
-For message and signal roots:
-
-- Derive them from usage, keyed by name, with no declaration form
-- An explicit root declaration, mirroring the error message declaration
-
-## Decision Outcome
-
-Four related decisions, chosen together because each keeps the same principle: spend a new payload shape or a new reserved word only where the alternative loses something the surface cannot get back, whether round-trip fidelity, execution-accurate scheduling, or truthful runtime semantics, and nowhere else.
-
-One paren slot, disambiguated structurally.
-The parentheses after a trigger's code stay a single grammar position that holds either a binding list or a condition expression.
-Which one is present is decided structurally, not by which trigger word precedes it: a binding list is a field name followed by a variable name, two identifiers in a row, and the expression sub-language never places two identifiers adjacently, since every accessor starts with `.` or `[` and every operator level requires an operator token in between.
-The parser tells the two apart by looking at the second token after the opening parenthesis, which settles it before any per-trigger legality is even considered.
-Whether a given trigger is allowed to use bindings, a condition, or neither is exactly the kind of position rule the validator already owns for every other soft word.
-
-Rejected: a binding-marker keyword, freeing the bare parentheses for a condition by writing bindings after a marker such as `as (...)`.
-It spends a new reserved word to solve a collision that structural lookahead already dissolves for free, and it breaks the parameter-list reading (`on error(X, code: c, message: m)`, read like `catch (Exception e)`) that motivated the binding shape in the first place.
-Rejected: a string-wrapped condition, written as a quoted literal instead of a real expression.
-It reads worse than the language's own `if (...)`, and it throws away everything a real expression AST buys, including undeclared-variable checking, type checking, symbol participation, and precise highlighting, turning the condition into opaque text the rest of the toolchain cannot see inside.
-Rejected: reserving `condition` as a keyword to dispatch the parens explicitly.
-Reserving it would make it a lexer-global reservation, unusable as an ordinary variable name anywhere in a file, for exactly the reason the trigger and binding words already stay soft identifiers rather than keywords.
-
-Timer particles are validated identifiers, one-to-one, with no inference.
-A timer's payload is a particle word, `after`, `at`, or `every`, followed by a time expression.
-Each particle maps to exactly one of BPMN's timer forms: a duration, a fixed date, or a repeating cycle.
-The particle is a plain identifier, checked in position by the validator like every other soft trigger word, not a keyword.
-
-Rejected: a single particle whose timer form is inferred from the shape of the time value itself, where an ISO date looks like a date and a duration string looks like a duration.
-This shifts a scheduling decision onto pattern-matching the value's text, and a value that happens to be malformed, whether a mistyped ISO string or an expression the engine evaluates at runtime rather than a literal the compiler can inspect, silently becomes a different timer kind than the one the author meant, with no diagnostic pointing at the mismatch.
-Three explicit particles read as the English they mean, "after an hour," "at nine," "every ten minutes," and cannot silently resolve to the wrong form.
-
-Conditional narrowing is deferred, and refused rather than dropped on import.
-BPMN's conditional event definition can additionally narrow when a condition is re-evaluated, to a named variable or a specific kind of variable change, rather than re-checking on every change.
-This narrowing is an optional, engine-side evaluation optimization: a handler behaves correctly without it, re-checking on any variable change instead of a specific one.
-The surface does not expose it, because the audience writing BPMNscript has no reason to know Camunda's evaluation-narrowing mechanism exists, and giving it a place on the `on condition` payload would mean a fourth, narrower payload shape carrying nothing but an optimization hint.
-
-Because the narrowing attributes change when the engine re-checks a condition, they are runtime semantics, not cosmetic metadata.
-Importing a document that carries them therefore refuses rather than silently dropping them: dropping a narrowing hint would leave the imported process re-checking its condition on every variable change instead of the one the original author scoped it to, which is exactly the kind of silent behavior change the honest import contract exists to prevent.
-The refusal names the attribute so a future surface has somewhere to land it, rather than pretending the information never existed.
-
-Message and signal roots are derived from usage, keyed by name, with no declaration form.
-A message or signal root carries exactly one piece of information the engine cares about: its name, which is also the identity the engine keys its subscriptions and correlations on.
-Because the name is both the only data and the natural key, the root is fully derivable from wherever the name is used, whether a handler, a throw, or an emit, with no separate declaration form and no new field on the process's structure.
-Every use of the same name, anywhere in the document, resolves to the same root.
-
-This differs from the error and escalation declarations ADR-0030 settles.
-A code is declared in the process header and named at each of its sites, which gives it a cross-reference and gives its thrown message one place to live, since two throw sites sharing a code might disagree on wording.
-A message or signal name has neither need: the name is the root's only data and is also its key, so nothing about a use is ambiguous or needs reconciling, and no declaration form is introduced for one.
-The payload shapes read that split directly.
-An error or escalation payload is a bare name resolving to a declaration, while a message, a signal and a timer carry quoted text, a condition carries an expression, and compensation and cancel carry nothing.
-
-Rejected: an explicit root declaration mirroring the error message declaration.
-It would generalize a mechanism that exists only because the error message text has no other source, to two triggers for which usage already supplies every property in full, which is pure boilerplate with no expressive gain.
-ADR-0030 reverses that reasoning for codes on a tooling argument that would apply to a message and a signal name too; extending it to them is a decision of its own and is not taken here.
-
-### Consequences
-
-- Good, because none of the four decisions spends a new reserved word: message, signal, timer, and conditional slot into the existing soft-word and paren machinery at zero reservation cost, the same guarantee the error/escalation design already made for the trigger set as a whole.
-- Good, because one verb pair, `throw` and `emit`, continues to span every trigger kind that has a throw form at all.
- Signal joins error and escalation under the same rule instead of growing its own vocabulary, so the reader who has learned the rule once for error and escalation does not have to relearn it for signal.
-- Good, because the condition payload stays a real, checkable expression rather than an opaque string, so a condition handler gets the same variable checking, symbol participation, and highlighting an `if` statement already gets.
-- Bad, because a document carrying the conditional narrowing attributes cannot be imported at all, even though the underlying evaluation difference is invisible to a reader who does not already know Camunda's narrowing mechanism exists.
- The refusal is correct but reads as stricter than the visible difference suggests.
-- Bad, because timer's three particles are one more small vocabulary to learn than a single particle would have been, paid once in exchange for a scheduling decision that cannot silently resolve to the wrong timer kind.
-
-## More Information
-
-Extended by ADR-0018 (compensation), the one trigger kind whose payload is empty, slotting into the same `on`/`throw`/`emit` surface this decision shapes without a new reserved word.
-Amended by ADR-0030, which makes an error or escalation payload a declared name and leaves message and signal names derived from usage.
-
-Related decisions: ADR-0013 (the target audience and the no-boilerplate rule, the reason message and signal get no declaration form, and the reason the conditional narrowing attributes stay off the surface until an author actually needs them).
-ADR-0014 (the honest import contract, the reason conditional narrowing attributes are refused on import rather than dropped, and the model this decision follows for treating a semantics-bearing drop as a refusal).
-ADR-0016 (the derived error/escalation root design and the `throw`/`emit` verb pair, which this decision extends to message and signal, and against which it contrasts the error message declaration with message/signal's lack of one).
diff --git a/docs/decisions/0017-service-task-bindings.md b/docs/decisions/0017-service-task-bindings.md
new file mode 100644
index 0000000..4e706cf
--- /dev/null
+++ b/docs/decisions/0017-service-task-bindings.md
@@ -0,0 +1,92 @@
+---
+status: accepted
+date: 2026-08-16
+decision-makers: Marlon Kranz
+---
+
+# Service task bindings, including external, mail and shell
+
+## Context and Problem Statement
+
+A `service`, `send`, or `decide` step names the work it runs with a binding key in its parens.
+Operaton dispatches every form of that work from one place.
+`BpmnParse.parseServiceTaskLike` reads `operaton:type` before any code attribute: `external` builds `parseExternalServiceTask`, `mail` builds `parseEmailServiceTask`, `shell` builds `parseShellServiceTask`, and any other value fails the deployment.
+With no `operaton:type`, the same method reads a class, an expression, or a delegate expression.
+Each form also reads its own extras, and refuses to deploy some of the combinations an author can write beside a binding.
+Which keys does this surface offer, what does each compile to, which extras ride which binding, and which combinations are refused?
+
+## Decision Drivers
+
+- ADR-0007 models every service-task execution form as one tagged union, `ServiceTaskBinding`, rather than as separate node kinds, so a surface that splits what the IR unifies is an asymmetry with no consumer that benefits from it.
+- ADR-0006 commits the language to the smaller keyword surface: a reserved word is a name an author can never choose for a variable or a step, and both `external` and `mail` are words a real process is likely to want.
+- ADR-0012's import contract: refuse what the engine refuses, warn on what changes, drop nothing in silence.
+- ADR-0013's rule that a deployment refusal the engine's parser raises is reported in the editor first, with the fix named.
+- ADR-0021 requires typed IR fields, so an extra under a binding the engine never reads it for is a type error.
+- The one bracket shape of ADR-0011: scalar settings in the parens, structured members in the braces.
+- ADR-0018's rule that a field's legal placement follows the engine's own parser rather than its schema.
+- A mapping names an error code, and ADR-0023 makes every code a declared name the editor resolves.
+
+## Considered Options
+
+- `topic` as a binding key rather than `external` as its own statement keyword: a keyword would flag at the step that the work runs in a separately deployed worker, but it reserves an English word for a distinction that produces no distinct BPMN element and that Operaton itself spells as `operaton:type="external"` on an ordinary `bpmn:serviceTask`.
+- `type` as a binding key rather than `mail` and `shell` as two more keywords: naming each behaviour directly would cost two more reserved words for a distinction the engine reads off one attribute and the IR collapses into one binding kind.
+- `type` rather than dedicated `to:` and `command:` keys: a mail task takes up to eight fields and a shell task twelve, more than a settings key per field can hold without duplicating the member-block machinery `field` already provides.
+- The external extras on the binding rather than on the service task node beside it: on the binding the type forbids them wherever the engine never reads them, while on the node a rule has to say they are empty unless the binding is external.
+- An error mapping as a member line rather than a settings key or a nested block: several mappings repeat the way parameters do, where a settings key holds one value and a `fields { }` nesting is the cost ADR-0018 refused.
+- `when` as a soft word rather than a keyword: lookahead settles the shape, so reserving the word buys nothing, and the price is that the validator checks the word and the editor highlights it through semantic tokens alone.
+- The extras on the three task kinds alone rather than on a thrown message binding a topic too: the engine does read them there, but off the message definition for the priority and the mappings and off the event element for the properties, a split shape of its own for a fourth site.
+- A result variable refused beside a class or delegate binding rather than warned and dropped on import or accepted on both sides: dropping it prints a script that differs from the document, and accepting it keeps producing documents the engine refuses.
+
+## Decision Outcome
+
+A step's settings carry exactly one of five binding keys: `class`, `expression`, `delegate`, `topic`, and `type`.
+The validator's zero-binding and more-than-one-binding messages name all five, so every key is discoverable from the error text alone.
+There is no `external`, `mail`, or `shell` keyword, and each word parses as an ordinary identifier free for a variable or a step name.
+`THROW_BINDING_KEYS` carries the four code and topic keys but not `type`: a thrown message opens no member block here, so the fields a mail or shell task's required-field check demands could never be written on one.
+
+The IR variant for a built-in behaviour is `{ kind: 'builtin'; type: BuiltinTaskType; fields?: FieldInjection[] }`, tagged by the engine's own `type` value rather than by two IR kinds, so every exhaustive switch grows one case instead of two.
+A field rides a `type:` binding for the same reason it rides `class:`: `parseEmailServiceTask` and `parseShellServiceTask` both build their behaviour through `instantiateDelegate`, the identical call a `class` binding's `ClassDelegateActivityBehavior` reaches, so `applyFieldDeclaration` runs the same check whichever behaviour declared the names.
+
+The validator mirrors the checks the engine's own parse runs before it builds a mail or shell behaviour: the required field groups, the field names each behaviour class declares, and the value shapes `validateFieldDeclarationsForShell` casts.
+The tables behind all of them live once in `packages/language/src/vocabulary.ts`, read by both the validator and the importer, so a shape refused on one side and accepted on the other cannot happen by construction.
+
+Three extras ride the `topic` binding and no other, because `parseExternalServiceTask` alone reads them.
+`taskPriority` is a settings key, while a property and an error mapping are member lines of the block the step already opens.
+`error when ` is parsed as `ID ID ID Expr`, told apart from a parameter's `ID ID '='` at the third token, so neither `error` nor `when` is reserved.
+The code slot is a cross-reference in the scope a `throw error(X)` resolves in, so an undeclared code draws the linker's message, jump-to-definition works, and a `bpmn:error` root is derived for the use as for a throw.
+The condition is evaluated on the task's execution by `ExternalTaskEntity.evaluateThrowBpmnError`, where `VariableScopeElResolver` resolves `externalTask` to the task entity beside every process variable.
+In the IR the three sit on the external variant of `ServiceTaskBinding`, so the type cannot carry them under a class, expression, delegate, decision, or built-in binding and every direction's guard stays a type check.
+A task prints its injected fields, then its properties, then its mappings, then its parameters and listeners.
+
+`resultVariable` is refused beside `class` or `delegate` because `parseServiceTaskLike` fails the deployment there, with the message `'resultVariableName' not supported for elements using ''`.
+The validator quotes that refusal under the element name the engine uses and names the fix, and the importer refuses the same document with the same text.
+Like the built-in field checks, these checks ask only once exactly one binding is written; a task with two bindings has that conflict to fix first.
+
+### Consequences
+
+- Good, because there is one keyword surface to teach: an author reads that a step can call a class, an expression, a delegate expression, an external worker, or a behaviour the engine builds itself, as five keys of the same statement rather than as five statements that compile to the same element.
+- Good, because no word is reserved for any of them: `external`, `mail`, `shell`, `when`, and `property` stay ordinary identifiers, and `error` already headed every code site.
+- Good, because boundary-event host enumerations, the reserved-step-name check, and every other place the grammar or validator listed task kinds side by side name `service` once.
+- Good, because a document a real deployment produced, carrying an external, mail, or shell task with its priority, its properties, and its failure mappings, imports and recompiles without a warning.
+- Good, because a mapping's code has a declaration to jump to and a rename that reaches it, and a script that would fail deployment over its result variable draws an error at the setting.
+- Bad, because a reader skimming XML sees `operaton:type="external"` as a distinguishing marker that the source surfaces as a setting in a step's parens, and nothing in `service Ship(topic: "...")` tells a reader who does not know `topic` that this step runs in a separately deployed process.
+- Bad, because the shared block holds a fourth member shape, told from a parameter at the third token rather than by a head word.
+- Bad, because `externalTask` is a name the validator admits in one position and warns on in every other.
+- Bad, because the shell flag's case-sensitivity gap, a written `"True"` deploying and reading as `false`, is a fact this surface documents rather than one it closes.
+- Bad, because the engine's refusal text for a result variable is quoted by the validator and by the importer separately, so a change in the engine's wording is two edits.
+
+### Confirmation
+
+The `external-task` golden pair under `tests/golden` compiles to the shape `parseExternalServiceTask` reads, imports back with no warning, and prints to source that re-desugars to the same IR; its suite asserts the complete binding of all three tasks at every hop and the frozen `extensionElements` text.
+The `mail-and-shell` golden pair carries a mail `service` task and a shell `send` task and round-trips byte for byte, and the `engine-attributes` pair binds its `AssessBodywork` task with an expression beside its `resultVariable`.
+The binding tables in `packages/language/test/validating.test.ts` pin the exactly-one-binding message, the required-field, undeclared-field, and shell-value-shape refusals, each external-extra diagnostic, and `resultVariable` beside each of the five bindings, and the import tables in `packages/transform/test/xml-to-ir.test.ts` pin each refusal and warning.
+`tests/e2e/forms-and-external-tasks.test.ts` fetches the charge from a real engine and fails it with a matching and a non-matching message, and the mail end-to-end case asserts the engine refuses a mail task with no body, citing the same message `validateFieldDeclarationsForEmail` throws.
+
+## More Information
+
+The binding union and its variants are declared in `packages/transform/src/ir/types.ts`, the key and field tables in `packages/language/src/vocabulary.ts`, the validator's binding tables in `packages/language/src/bpmn-script-validator.ts`, and the importer's checks in `readServiceTaskBinding` in `packages/transform/src/xml-to-ir.ts`.
+
+Related decisions: ADR-0007 (the IR between the AST and BPMN XML, whose tagged union these keys are the surface of).
+ADR-0012 (the honest import contract behind every refusal and warning above).
+ADR-0018 (field injection, whose placement rule extends to a third binding, and the `property` direction shared with an external task).
+ADR-0008 (the moddle fork, which gains `taskPriority` on `ServiceTaskLike` and a concrete `ErrorEventDefinition` type beside the trait carrying the catch-side variables).
diff --git a/docs/decisions/0018-compensation-through-event-subprocesses.md b/docs/decisions/0018-compensation-through-event-subprocesses.md
deleted file mode 100644
index fa9e9fe..0000000
--- a/docs/decisions/0018-compensation-through-event-subprocesses.md
+++ /dev/null
@@ -1,115 +0,0 @@
----
-status: accepted
-date: 2026-07-18
-decision-makers: Marlon Kranz
----
-
-# Compensation Only as a Subprocess Undo Block: One Handler Form, No Targeted Throws
-
-## Context and Problem Statement
-
-BPMN gives a compensation handler two different attachment points.
-The first is a boundary event sitting on the activity being compensated, connected by an association to a separate activity marked `isForCompensation`, an activity that never runs in the normal flow and only executes when compensation reaches it.
-The second is a compensation event sub-process: a `bpmn:subProcess` with `triggeredByEvent="true"` whose start event carries a `compensateEventDefinition`, nested directly inside the embedded sub-process it compensates.
-The engine's own reference documentation treats the two as alternatives for the same intent, reversing the completed work of an activity, with the event-sub-process form restricted to embedded sub-processes (it is not propagated to the separate process instance a call activity spawns) and capped at one per sub-process level.
-The engine additionally does not support `waitForCompletion="false"` on a compensation intermediate throw event: the throw always blocks until the triggered compensation finishes, regardless of what the attribute says.
-
-BPMNscript already has a subprocess construct and an established `on`/`throw`/`emit` vocabulary for every other event kind.
-The question this decision settles is which of BPMN's two attachment shapes, or a third designed one, the DSL exposes for compensation, and how throwing compensation fits the verb pair the rest of the event layer already uses.
-
-## Decision Drivers
-
-- The engine itself restricts one of the two BPMN attachment forms (compensation event sub-processes) to embedded sub-processes and one per level; a surface that pretends both forms are equally general everywhere would produce documents the engine does not actually support.
-- The audience writes processes without BPMN vocabulary already established for `on`/`throw`/`emit`; introducing a second attachment mechanism (boundary event, association, a normally-dormant activity) for one event kind breaks the pattern every other trigger kind follows.
-- `waitForCompletion="false"` and `activityRef`-targeted throws only matter in combination with boundary-attached handlers; neither has a use once boundary handlers are out of scope.
-- The honest import contract (ADR-0014) already distinguishes constructs the DSL refuses from ones it silently drops; a construct this decision does not model needs to land on one side of that line, not the other.
-
-## Considered Options
-
-- Compensation handlers exist only as a subprocess's `on compensation` block (a compensation event sub-process nested in the `subprocess` it compensates), thrown with the existing `throw`/`emit` verb pair and no code
-- Boundary compensation events with an associated `isForCompensation` activity, mirroring BPMN's other attachment form
-- A throw targeted at a specific activity by name (`activityRef`), giving one throw statement the ability to compensate a single named unit of work rather than the nearest enclosing scope
-- A dedicated `compensate` verb, kept separate from `emit`, for the continuing form
-
-## Decision Outcome
-
-Chosen option: "Compensation handlers exist only as a subprocess's `on compensation` block, thrown with the existing `throw`/`emit` pair and no code", because it is the only option that keeps compensation inside the vocabulary the rest of the event layer already uses, and because the rejected alternatives either duplicate a granularity the subprocess construct already provides or only pay for themselves once that duplication exists.
-
-The handler.
-A subprocess's undo steps are written directly in its body as `on compensation { ... }`, the steps that reverse whatever that subprocess already finished doing.
-`on compensation` lowers to a `bpmn:subProcess triggeredByEvent="true"` nested inside the `bpmn:subProcess` it undoes, whose start event carries a `compensateEventDefinition`; there is at most one per subprocess, matching the engine's own restriction.
-It runs when compensation reaches the subprocess, and only for a subprocess instance that actually completed, since an instance that never finished has nothing to undo.
-Compensation reaches it by two routes: `throw compensation` or `emit compensation` in an enclosing scope, and a cancel end that gives up an enclosing `attempt` block.
-Operaton's `CancelEndEventActivityBehavior` throws the compensation itself on that second route, before handing the run to that block's cancel handler.
-Because a compensation event sub-process only exists inside an embedded sub-process, the construct is only legal directly inside a `subprocess` body or an `attempt` body; a process cannot compensate itself, so `on compensation` at process level is rejected rather than approximated.
-
-The throw.
-Compensation joins `throw`/`emit` on the same terms every other trigger kind already established (ADR-0016): `throw compensation` ends the current path after undoing the nearest enclosing scope's completed work, `emit compensation` undoes it and continues.
-Neither carries a code, because compensation has no name to correlate against, unlike error or escalation, so there is nothing for a code string to select.
-`emit compensation` always waits for the triggered undo to finish before falling through, matching the engine, which does not honor `waitForCompletion="false"` in the first place; the surface has no attribute for a behavior the engine ignores.
-
-What is refused, not modeled.
-Boundary compensation events, `activityRef`-targeted throws, `waitForCompletion="false"`, and `isForCompensation` activities are all refused on import under the same contract ADR-0014 already established for constructs the DSL cannot express without changing what the process does.
-None of the four is silently dropped or approximated by the nearest `on compensation` equivalent; an import encountering any of them stops with a diagnostic naming the construct.
-When the boundary event and the `isForCompensation` activity are paired through a `bpmn:association`, the diagnostic also names the compensated activity, the boundary event, and the handler, and prints the `subprocess`/`on compensation` rewrite an author would write by hand.
-
-Per-activity undo.
-BPMN's boundary-event form exists chiefly to compensate a single activity rather than a whole embedded sub-process.
-BPMNscript reaches the same granularity without a second mechanism: wrapping the one step that needs to be undoable in its own `subprocess` gives it its own `on compensation` block, scoped to exactly that step.
-A single task that needs an undo path becomes a named unit of work with its own undo block, the same construct already used everywhere else compensation applies, at whatever granularity the author needs.
-
-### Consequences
-
-- Good, because a saga (several steps, each undoable, unwound in reverse when a later step fails) round-trips through the DSL and executes on the engine using only constructs the surface already has: `subprocess`, `on`, `throw`, `emit`.
-- Good, because compensation reuses the same verb pair, the same catch-block reading, and the same container rule every other trigger kind already follows; there is nothing new to learn beyond "undo block" and "no code."
-- Good, because an import carrying the boundary-event pattern, a targeted throw, or `waitForCompletion="false"` refuses outright, rather than reporting nothing and reproducing a different execution than the one the imported document actually specifies.
-- Bad, because an author porting an existing BPMN model that already uses boundary compensation events must restructure it, pulling the compensated activity and its handler into a `subprocess`, rather than have the importer translate the pattern automatically.
-- Bad, because per-activity compensation on many independent steps means one small `subprocess` wrapper per step; the surface trades a second attachment mechanism for a per-step wrapping requirement instead.
-- Neutral, because compensation's ordering (reverse of execution), invocation count, and the variable snapshot an undo block sees are entirely engine behavior.
- ADR-0016's "nothing here to model" reasoning for escalation delivery applies the same way to compensation ordering.
-
-### Confirmation
-
-Round-trip coverage in `packages/transform/test` pins a subprocess with an `on compensation` block lowering to a nested `triggeredByEvent` sub-process with exactly one `compensateEventDefinition`-carrying start event, and `throw compensation`/`emit compensation` lowering to a compensation end event and a compensation intermediate throw respectively, both without an `activityRef`.
-Refusal coverage in the same suite pins a boundary compensation event, an `activityRef`-targeted throw, `waitForCompletion="false"`, and an `isForCompensation` activity each producing a distinct refusal rather than a silently altered import.
-`packages/language/test/validating.test.ts` pins the container rule: an undo block directly inside a `subprocess` body validates and so does one directly inside an `attempt` body, the same block at process level, inside another handler's body, or inside an `if` branch is rejected, and a second undo block in one `subprocess` or one `attempt` is rejected as a duplicate.
-Two suites under `tests/e2e/` deploy to a real Operaton and confirm that an undo block executes on a running engine rather than only lowering to the right shape.
-`service-boundary-and-compensation.test.ts` covers the route this decision defines: the `emit compensation` a process-level error handler raises reaches the undo block of a subprocess that had already completed, releasing the seat it reserved.
-`booking-attempt.test.ts` covers the route ADR-0028 adds: a completed subprocess's undo steps run once the `attempt` block around it is given up.
-
-## Pros and Cons of the Options
-
-### Compensation handlers only as a subprocess `on compensation` block
-
-- Good, because it reuses the subprocess construct and the `on`/`throw`/`emit` vocabulary the rest of the event layer already established, instead of adding a second attachment mechanism for one event kind.
-- Good, because it matches the engine's own restriction, that a compensation event sub-process is only legal inside an embedded sub-process, rather than pretending a broader form exists.
-- Bad, because per-activity compensation costs a wrapping `subprocess` per step instead of a boundary event attached directly to the step.
-
-### Boundary compensation events with an `isForCompensation` associated activity
-
-- Good, because it lets a single activity carry its own compensation handler without wrapping it in a subprocess.
-- Bad, because it is a whole second attachment axis (a boundary event, an association, and an activity that never runs in the normal flow) for a granularity level the wrap-in-`subprocess` pattern already reaches with constructs the surface already has.
-- Bad, because it introduces a kind of activity (`isForCompensation`, dormant except when triggered) with no equivalent anywhere else in the grammar, breaking the rule that every construct maps to something the author writes and runs directly.
-
-### A throw targeted at a specific activity by name (`activityRef`)
-
-- Good, because it lets one throw compensate a single named unit of work rather than everything the nearest enclosing scope completed.
-- Bad, because it only has a target worth naming once boundary-attached handlers exist to be the targets; with those rejected, there is nothing distinct for `activityRef` to select beyond the nearest enclosing scope `throw compensation` already reaches.
-- Bad, because naming an activity across scope boundaries needs a reference-resolution mechanism the grammar does not otherwise require anywhere else in the event layer.
-
-### A dedicated `compensate` verb, separate from `emit`
-
-- Good, because a compensation-specific verb name reads unambiguously as compensation rather than sharing a word used for escalation and signal.
-- Bad, because it grows the event layer's continuing-throw vocabulary to two words (`emit`, `compensate`) for one behavior, fire and continue, that `emit` already spans for every other trigger kind with a continuing form.
-- Bad, because it would need to be a reserved keyword to avoid ambiguity with `emit`, unlike every trigger name and binding field, which stay soft identifiers precisely so the audience's ordinary variable names keep working.
-
-## More Information
-
-Related decisions: ADR-0013 (the target audience and the no-boilerplate rule, the reason a wrapping `subprocess` is preferred over a second attachment mechanism for per-activity granularity).
-ADR-0014 (the honest import contract, the reason boundary compensation events, `activityRef`-targeted throws, `waitForCompletion="false"`, and `isForCompensation` activities are refused rather than approximated).
-ADR-0016 (the `throw`/`emit` terminality rule and the soft-word design, both of which compensation reuses without adding a reserved word or a position-dependent reading).
-ADR-0017 (the trigger-payload design, under which compensation is the one trigger kind whose payload is empty, since it has no code, name, or condition to carry).
-
-Extended by ADR-0019 (boundary events attached to an activity), which adds the attachment axis for six other trigger kinds while leaving this decision intact: compensation is still reachable only as a subprocess undo block, and a compensation boundary event is still refused on import.
-
-Extended by ADR-0028 (work that can be given up), which adds a second route into these same undo blocks: a cancel end gives up the block around them, and the engine throws the compensation itself, with no `throw compensation` written anywhere in the source.
diff --git a/docs/decisions/0018-forms-and-field-injection.md b/docs/decisions/0018-forms-and-field-injection.md
new file mode 100644
index 0000000..7585231
--- /dev/null
+++ b/docs/decisions/0018-forms-and-field-injection.md
@@ -0,0 +1,108 @@
+---
+status: accepted
+date: 2026-09-10
+decision-makers: Marlon Kranz
+---
+
+# Forms and field injection
+
+## Context and Problem Statement
+
+Three Operaton surfaces attach to an element this language already models rather than to a new BPMN construct.
+A service task, a send task, a business rule task, and both listener kinds can carry Operaton fields: name and value pairs injected into the Java bean the class or delegate binding instantiates.
+A user task can carry a form reference by id and version, an alternative to the fixed form key the surface already writes.
+A form field carries four things beyond its id, type, label and default: a validation list, an enum's values, a date pattern, and a property list, read by `DefaultFormHandler.parseValidation`, `DefaultFormHandler.parseProperties`, and `FormTypes.parseFormPropertyType`.
+None of the three needs a new flow node or a new statement keyword; each needs a place inside a member block or a settings list an element already has.
+
+What is open for a field is where that place is, since Operaton's moddle schema admits one almost anywhere `extensionElements` can appear while its parser reads one off only a narrow set of bindings.
+`IoParameter` already spells `input greeting = "hello"` and `output result = "${x}"` inside the brace block a service-task-like statement opens, and a field is the same shape: a name, an `=`, and a value that is either a literal or a raw expression.
+A field also has two XML slots for the same runtime value, `stringValue` as an attribute and `` as a child, and the surface has to pick one spelling rather than grow a second vocabulary for a distinction no author can act on.
+
+What is open for a form reference is which element gets it and what has to go with it: the schema allows one on a user task and on a start event, while the parser refuses to deploy a form reference with no binding or one beside a form key.
+What is open for a form field is where its four parts go, and what the import does with a shape the engine deploys but this surface cannot check.
+
+## Decision Drivers
+
+- Every reserved keyword is a name an author permanently loses.
+- Operaton's own parser decides what is legal, not what its schema merely allows to be written.
+- The one bracket shape of ADR-0011: scalar settings in the parens, structured members in the braces.
+- An illegal combination should be unrepresentable in the IR's type rather than merely checked for, continuing the convention ADR-0021 set for engine attributes generally.
+- A closed set of constraint names the validator can check.
+- The import contract commits this tool to refusing what the engine refuses and warning on what changes, never dropping in silence something that would change what the engine does (ADR-0012).
+
+## Considered Options
+
+- Reuse `IoParameter` as a third direction, `field`: it already parses any identifier in the direction position, so a direction costs no grammar rule and no reserved word and inherits the duplicate-key check, the highlighting, and the completion, at the price of one rule carrying two kinds of member.
+- A dedicated `FieldInjection` rule: the rule name alone would say what a member is, but `field` becomes a word an author permanently loses, and the placement and value rules are the same whichever rule carries them.
+- A nested `fields { }` sub-block: it collects an element's fields under one head, but nests a second member-block shape inside the first, where `input` and `output` already repeat with none.
+- Two DSL spellings, one for `stringValue` and one for ``: the slot the exporter writes would be visible in the source, but the two slots behave identically until the value reads `${...}`, which the single spelling already tells apart, and choosing between them requires knowing Operaton's XML.
+- `formRef` on a user task only: it sits beside `formKey`, so both ways of naming a form are read in one place, at the price of a start event's form reference this surface cannot express.
+- `formRef` on a user task and a start event, matching Operaton's schema: it accepts every placement the schema allows, but a start event carries no `formKey` here either, so it closes one half of that asymmetry and leaves the other standing.
+- Constraints as settings in the field's parens, values and properties as members of its braces: it is the one bracket shape, so a reader derives a field's spelling from any other element, at the price of two member shapes under one block.
+- Constraints as member lines beside the values: a field would have one member list and no parens, but `required` on its own line reads as a part the field is built from rather than a rule it is checked under.
+- A nested `validation { }` block inside the field: it mirrors the XML, and nests a second block shape inside a field, the cost a `fields { }` block was already refused for.
+- `enum` and `property` as soft words rather than keywords: `var enum: string`, a step named `property`, and every existing file stay legal, and an enum value cannot be named by a reserved word, the limit a field id and a parameter name already have.
+- Constraints in the IR as an ordered list rather than flat named fields: the engine's validation order survives the round trip with one map per direction, where flat fields lose the XML order and need a plumbing site per name per direction.
+- A fixed `validator:` key rather than an open key set: an unknown name is a diagnostic here instead of a failed deployment there, and a validator registered under its own engine name is written `validator: ""` instead.
+
+## Decision Outcome
+
+The three rest on the same kind of evidence: what Operaton's own parser reads off an element, not what its schema merely permits to be written there.
+Field injection reuses `IoParameter` as a direction, `formRef` lands on a user task alone and requires a binding, and a form field takes constraints in its parens and members in its braces with every word soft, an ordered constraint list, and a closed key set.
+
+`field greeting = "hello"` sits beside `input` and `output` inside the same member block a service task, a send task, and a business rule task open, and inside a listener's own block too.
+`IoParameter` already parses `direction=ID name=ID '=' value=IoValue` for any identifier in the direction position, so the grammar accepts `field` with no new rule at all, and it is the validator, not the parser, that checks which directions a given owner allows.
+A fourth direction, `property`, joins the three the same way.
+The one grammar edit this decision needs is the brace block a listener gains.
+
+That brace block is forced, not chosen, once a field needs to repeat.
+Spelling it as a setting instead, `service S(field: "greeting=hello")`, hits two limits the settings surface already has: `checkDuplicateKeys` rejects a repeated setting key, and a setting's value is typed as an expression, which admits no map literal that could hold several named fields at once.
+A member block, where `params+=IoParameter*` already repeats freely, is the only surface with room for more than one.
+Operaton hands a field list to a class-bound and a delegate-expression-bound listener of either kind exactly as it does to a service task, so `on (...) { }` takes the same rule the task's own block uses.
+A field is none of the three things a listener's block still refuses to nest: it is not a form, not an input or output parameter, and not a nested listener (ADR-0020).
+
+A field's value has one DSL spelling regardless of which of Operaton's two XML slots it lowers to: a quoted string lowers to the `stringValue` attribute and a `"${...}"` raw expression to an `` child, the same literal-versus-expression split `renderIoValue` already draws for an io parameter's value.
+The moddle already normalizes an `expression` attribute into a child element on its own, so a normalizing round trip is not new here.
+The one shape this rule cannot close is a `stringValue` that reads `${...}`: writing it back would turn a literal Operaton injects verbatim into an expression Operaton evaluates instead, so that shape keeps a drop warning naming the reason rather than being carried.
+
+Where a field is legal follows the engine, not the schema.
+`BpmnParse.parseServiceTaskLike` hands `parseFieldDeclarations` to `ClassDelegateActivityBehavior`, to `ServiceTaskDelegateExpressionActivityBehavior`, and to the mail and shell behaviours it instantiates itself; nothing else takes one.
+`ServiceTaskExpressionActivityBehavior` is built from an expression and a result variable with no field list, and `parseExternalServiceTask` is never handed one either.
+`parseExecutionListener` and `parseTaskListener` repeat the identical split for both listener kinds, neither of which binds a built-in type.
+
+`formRef` lands on a user task only, matching where `formKey` already sits.
+Operaton's schema also allows a `formRef` on a start event, but this language does not carry `formKey` on a start event either, so giving `formRef` a start-event home would fix half of an existing asymmetry and leave the other half standing, which is a larger, separate change.
+A `formRef` requires a binding because `BpmnParse.parseFormDefinition` calls `addError` when `formKey` and `formRef` are both present on one task, and again when `formRef` carries a binding that is absent or outside `latest`, `deployment`, and `version`.
+The IR carries a form reference as a key and a binding with the binding required, which is what makes the invalid state unrepresentable rather than merely refused later, and both hops report it: when Operaton itself refuses to deploy a shape, the importer refuses it too rather than carrying something that would never run.
+
+A form field reads `id: type "label" = default (settings) { members }`, which is the bracket shape of ADR-0011 applied at no grammar cost.
+The grammar's type rule is `VarType | ID`, so `enum` costs no keyword, and the validator holds the word to the five types the engine registers.
+Constraints are written in the order the engine validates them, and the IR keeps that order as a list keyed by a closed name union rather than as flat named fields.
+That key set stays closed even though `customFormTypes` and `customFormFieldValidators` can register more: engine configuration is something this surface cannot see, so a name registered there is indistinguishable from a typo, and `validator` with a class name reaches a custom validator anyway.
+
+### Consequences
+
+- Good, because no word is reserved by any of the three.
+ `field`, `property`, `enum` and the form-field setting keys stay ordinary identifiers, and `formRef` is one more setting key rather than a keyword.
+- Good, because a field and a property reuse the io-parameter machinery already built for `input` and `output`: the duplicate-key check, the highlighting, and the completion all extend to them for free.
+- Good, because the illegal states are unrepresentable rather than merely checked.
+ A field has no binding-key slot to sit in on an expression, a topic, or a decision binding, a form reference with no binding cannot be constructed in the IR at all, and an illegal constraint name is a type error.
+- Good, because a document Operaton itself refuses to deploy is refused on import, and a field the engine would deploy and then fail on every submission or render is a diagnostic naming the fix.
+ A Modeler-drawn form carrying any of the four parts imports, prints, and recompiles to the same IR.
+- Bad, because a listener's brace block now holds two kinds of member under the same grammar rule, an io parameter and a field, so a reader has to check the direction word rather than the rule name to know which one they are looking at.
+ A form field's braces hold two member shapes told apart at the third token for the same reason.
+- Bad, because the field-placement rule is a fact about Operaton's parser, not its schema, so it has to be documented rather than discovered from the moddle alone.
+- Bad, because a constraint the engine deploys on the wrong type is carried on import with a warning, so the printed script does not compile until the author moves it.
+
+### Confirmation
+
+The `forms` golden pair under `tests/golden` compiles to the exact `operaton:formField` shape, imports back with no warning, and prints to source that re-desugars to the same IR, and its suite asserts the complete `FormField[]` of both forms at every hop.
+The validator table in `packages/language/test/validating.test.ts` pins each diagnostic, and the import tables in `packages/transform/test/xml-to-ir.test.ts` pin each refusal, drop, carry, and rewrite.
+
+## More Information
+
+Related decisions: ADR-0008 (the Operaton moddle extension fork, which carries the `Properties`, `Property`, `Validation`, and `Constraint` types).
+ADR-0012 (the honest import contract behind every refusal and warning here).
+ADR-0017 (the binding keys a field rides, and the `property` direction shared with an external task).
+ADR-0020 (the listener surface whose brace block holds a field beside its bindings).
+ADR-0021 (engine attributes as named IR fields, the typing rule a field, a form reference, and the constraint name union all follow).
diff --git a/docs/decisions/0019-a-call-activitys-variable-mapping.md b/docs/decisions/0019-a-call-activitys-variable-mapping.md
new file mode 100644
index 0000000..c906921
--- /dev/null
+++ b/docs/decisions/0019-a-call-activitys-variable-mapping.md
@@ -0,0 +1,80 @@
+---
+status: accepted
+date: 2026-09-11
+decision-makers: Marlon Kranz
+---
+
+# A call activity's variable mapping
+
+## Context and Problem Statement
+
+Operaton lets a call activity name a Java class or a bean expression that computes its in and out variable mapping in code, written as `operaton:variableMappingClass` or `operaton:variableMappingDelegateExpression` on the element.
+Both mechanisms run, and the delegate runs last on each side.
+`BpmnParse.parseCallActivity` reads `operaton:in` and `operaton:out` unconditionally, with no guard on either attribute.
+`CallableElementActivityBehavior.execute` builds the variable map from the in mappings and hands the already populated map to the delegate, which mutates it, and `CallableElementActivityBehavior.passOutputVariables` applies the out mappings first and runs the delegate after.
+A delegate therefore overrides a declared mapping rather than replacing it, so a document carrying one loses nothing on the way in.
+
+What is open is everything else: what the two attributes spell on the authoring surface, whether that spelling sits in the call's parens or its braces, what shape the IR gives the pair, and what happens on a document that sets both at once.
+
+## Decision Drivers
+
+- The honest import contract (ADR-0012) reserves a refusal for loss that changes execution, and the engine runs a declared mapping and a delegate alike.
+- An injected field is legal on a `class` or a `delegate` binding, a rule about binding words that stays keyed on a word alone only while no other binding spells itself with them (ADR-0018).
+- The one bracket shape (ADR-0011) puts a setting that describes the element in the parens and a list with members of its own in the braces.
+- An illegal combination should be unrepresentable in the IR's type rather than merely checked for, the convention ADR-0021 set for engine attributes.
+- Operaton resolves a document setting both attributes with an if/else-if in `BpmnParse.parseCallActivity`: the class wins and the delegate expression is dropped with no diagnostic.
+
+## Considered Options
+
+- `mapper` for the class and `mapperDelegate` for the expression: the field rule stays a rule about the words `class` and `delegate`, true at every site that states it, at the price of two words describing a Java binding without using either word the surface uses for one everywhere else.
+- `class` and `delegate`, the words a service task's own bindings use: they match the Java shape and the attribute names the engine reads, but a call activity's variable mapping receives no field list, so the field rule would have to name an element as well as a word at each of the seven sites that state it, `FIELD_HOSTS_MESSAGE` included.
+- A setting in the call's parens: a variable mapping describes the call itself and has no members, which is the line ADR-0011 draws, and the offer order, the printed order, and the completion snippet all come from one row; a long call then carries the binding and the mappings in two different brackets.
+- A member of the call's brace block, beside its `in` and `out` mappings: it would sit beside the mappings the delegate computes, but the braces hold lists whose members repeat, where a call names one mapping delegate or none, and it would need a grammar rule where a setting key needs none.
+- A `CallVariableMapper` of its own, tagged `class` or `delegateExpression`: the two attributes Operaton reads are exactly the two tags, so a third spelling cannot be constructed and no slot is unreachable, at the price of a second class-or-expression union in the IR.
+- The `CodeBinding` a service task and both listener kinds already carry: a call activity's delegate would be written the way a service task's is, but its `expression` member has no attribute to lower to and its `fields` slot would be present and always empty.
+- An author-time error, and on import the class with a warning naming the dropped delegate: the author is told at the one moment a choice is still open and the import keeps what the engine keeps, at the price of two hops answering the same question differently.
+- Mirroring the engine on both hops, taking the class in silence: one rule covers both directions, and it reproduces a silent drop on the hop where a diagnostic costs nothing.
+
+## Decision Outcome
+
+Chosen: `mapper` and `mapperDelegate` in the parens, carried by a `CallVariableMapper` of their own, mutually exclusive at author time and resolved to the class with a warning on import.
+
+A variable mapping delegate has the Java shape of a `class` or a `delegate` binding and none of its behaviour, because Operaton hands it no field list: `instantiateDelegateClass` constructs the delegate with a null field-declaration list, and `ClassDelegateUtil.applyFieldDeclaration` returns without setting anything when the list is null.
+Spelling it with those two words would make every site that states the field rule false at once and re-key the rule from which word to which word on which element.
+A distinct word is the honest one anyway: on a service task `class` names what runs, while here the delegate names what computes the data crossing the process boundary.
+
+A variable mapping is one setting on the call rather than a list with members of its own, so it goes where ADR-0011 puts a setting, and that one row is the whole authoring surface: the grammar already accepts any identifier as a setting key, completion derives its offer from the element's own keys, and the printer prints in the same order.
+
+The IR carries a `CallVariableMapper` tagged `class` or `delegateExpression` rather than reusing `CodeBinding`, on two counts the engine decides.
+`CodeBinding`'s `expression` member has no counterpart on a call activity, because Operaton reads exactly two attributes there and declares no `operaton:variableMappingExpression`, so reusing the type would admit a third spelling that a hand-written validator rule then has to refuse.
+Its `fields` slot would be present and always empty, since that slot exists for the two behaviours Operaton injects into and a variable mapping is neither.
+
+Two spellings on one call is an author-time error, delegated to `checkAtMostOneBinding` so the sentence is the one every other exclusive pair on the surface prints.
+Import cannot refuse the same shape: Operaton deploys and runs the document with the class, so under ADR-0012 there is no execution loss to refuse for, and the import takes the class and reports the dropped delegate as shadowed.
+There is no rule between a mapper and the call's `in` and `out` mappings, because the engine runs both.
+The round-trip fixture therefore authors a mapper beside an `in` and an `out` mapping rather than alone, and a reader who adds an exclusivity rule turns it red.
+
+Either spelling names an implementation of Operaton's `DelegateVariableMapping`, whose two methods are `mapInputVariables(DelegateExecution, VariableMap)` and `mapOutputVariables(DelegateExecution, VariableScope)`.
+Nothing on the authoring or the import hop can check that the named class implements it, since `resolveDelegateClass` and `instantiateDelegateClass` resolve the class when the call executes rather than when the document deploys.
+
+### Consequences
+
+- Good, because a document Operaton deploys and runs imports and re-exports.
+- Good, because the field rule stays keyed on a word alone.
+ A mapper carries no injected field and takes none, so that rule needs no element-by-element exception and no site that states it has to change.
+- Good, because a mapper and the declared `in` and `out` mappings sit in one call with no rule between them, which is what the engine does with them.
+- Bad, because the call surface gains two words for a binding shaped in Java exactly like a `class` or a `delegate` binding and deliberately not spelled like one.
+ A reader has to be told why rather than read it off the surface.
+- Bad, because neither the validator nor the importer can check a mapper.
+ The class is resolved when the call executes, so a misspelled name, or one whose class implements nothing, passes both hops and fails in the engine.
+
+### Confirmation
+
+The mapper block in `tests/call-activity.round-trip.test.ts` authors each spelling beside an `in` and an `out` mapping on the same call and asserts the whole IR node, the serialized Operaton attribute, an empty warning list, an identical re-import, the printed setting in its canonical position, and that the printed source revalidates.
+The import table in `packages/transform/test/xml-to-ir.test.ts` pins both attributes under both namespace prefixes and the both-set case, asserting the whole mapper object and the whole warning list rather than membership in either.
+
+## More Information
+
+Related decisions: ADR-0008 (the Operaton moddle extension fork, which declares both attributes, without which moddle drops them on write).
+ADR-0018 (the field rule this spelling keeps keyed on a word alone).
+ADR-0021 (engine attributes as named IR fields, the precedent `CallVariableMapper` extends).
diff --git a/docs/decisions/0019-boundary-events-attached-to-an-activity.md b/docs/decisions/0019-boundary-events-attached-to-an-activity.md
deleted file mode 100644
index 3681e0c..0000000
--- a/docs/decisions/0019-boundary-events-attached-to-an-activity.md
+++ /dev/null
@@ -1,221 +0,0 @@
----
-status: accepted
-date: 2026-07-23
-decision-makers: Marlon Kranz
----
-
-# Boundary Events Attached to an Activity: Host Syntax, Trigger Scope, and a Self-Contained Body
-
-## Context and Problem Statement
-
-Every `on` handler in BPMNscript compiles to an event sub-process that guards its whole enclosing process or subprocess body (ADR-0016).
-BPMN has a second, narrower attachment for the same catch vocabulary: a `bpmn:boundaryEvent` docked directly on one activity, which only catches while that activity is running and, depending on `cancelActivity`, either cancels it the moment the trigger fires or lets it keep running alongside the catch.
-Giving `on` an optional host turns it into this second attachment form without introducing a competing keyword or a second handler construct.
-
-Four questions have to be settled together, because each interacts with a decision already recorded for the host-less handler.
-How is a host spelled without colliding with a handler shape that already exists (`on timer("PT2H")`)?
-Which of the language's eight catchable triggers may attach as a boundary event, and what happens to the two this decision does not admit?
-What does a hosted handler's body compile to, given that the host-less form's meaning must stay exactly what ADR-0016 already fixed it to be?
-And how is a token that appears directly at a boundary event, without ever traversing the main flow, represented in the dominator-based restructuring analysis (ADR-0009) that turns flat IR back into structured DSL?
-
-## Decision Drivers
-
-- The grammar already commits every trigger word to being a soft identifier (ADR-0016), so a new syntax slot cannot recover disambiguation from the trigger word's token type, only from structure the grammar itself contributes at parse time.
-- The host-less `on` handler's meaning, an event sub-process, is exercised by existing golden fixtures and must not move: a hosted handler is a new attachment form layered next to it, not a replacement for it.
-- Operaton's own parser (`BpmnParse.parseBoundaryEvents`) is the actual authority on which triggers, and which host shapes, a boundary event may legally carry; a surface stricter than the engine owes a reason.
-- The restructuring analysis (ADR-0009) already commits to being total over every valid IR.
- A boundary event is the IR's first flow-element shape with outgoing edges but no incoming ones, a shape the analysis has never had to reason about, so the decision has to say explicitly what that shape means for reachability and dominance, not leave it implicit.
-- The honest import contract (ADR-0014) and the compensation decision (ADR-0018) both carry commitments this feature must not invalidate: no silent mangling on import, and compensation stays a subprocess undo block rather than gaining a second attachment mechanism.
-
-## Considered Options
-
-For the host separator:
-
-- A colon between host and trigger (`on Pack: error(X)`)
-- A bare space (`on Pack error(X)`)
-- A postfix clause (`on error(X) at Pack`)
-
-For the trigger scope:
-
-- All eight of the language's catchable triggers as boundary events
-- Every trigger but compensation and cancel
-- A smaller hand-picked subset
-
-For the body shape:
-
-- The body lowers inline into the host's container and rejoins the main flow only through an explicit `goto`
-- The body falls through to whatever statement follows the host
-- The body is wrapped in a container of its own, the way a host-less handler already is
-
-For the CFG treatment:
-
-- A boundary event is wired to the restructuring analysis's virtual entry like a start event
-- It is left unwired entirely
-- It is made the root of a second analysis run over the same container
-
-For the id scheme:
-
-- The boundary event's id is derived from the host and the trigger
-- It reuses the positional `EventSubProcess_` scheme already used for host-less handlers
-
-## Decision Outcome
-
-Five related decisions, chosen together because each keeps the same host-less meaning intact wherever the new attachment form does not have to touch it, and spends new syntax, a new reserved word, or a new pass only where an alternative would either misparse an existing form, mangle an import, or leave an escape chain permanently unreadable by the restructurer.
-
-The colon separator.
-A hosted handler is written `on Pack: error(X) { ... }`: the host name, a colon, then the trigger exactly as a host-less handler already writes it.
-The bare form originally proposed, `on Pack error(X)`, is ambiguous with a handler that already parses today: a host-less handler carrying a code and a hosted handler carrying none both read as `on` followed by two identifiers, because every trigger word lexes as a plain identifier by deliberate design (ADR-0016) rather than as a grammar keyword.
-The ambiguity sits in the token text, not the token type, so no amount of lookahead over token types can tell a host from the trigger that follows it.
-Only the words themselves would, and reserving them as keywords is exactly what ADR-0016 already ruled out.
-The colon resolves the ambiguity at the second token, before either reading has to be committed to: plain two-token lookahead already suffices, well inside what Langium's default parser handles without any special grammar annotation.
-
-Rejected, having been worked through directly: promoting the eight trigger words to real keywords and then re-admitting them as ordinary identifiers through a data-type escape hatch, so `var message: string` keeps parsing.
-This alternative does work, but at a cost disproportionate to what the colon buys for free.
-Every site in the grammar that currently accepts a soft trigger word as an identifier would need a parallel keyword-or-identifier rule, some two dozen individual grammar productions, which reopens the soft-trigger-word decision ADR-0016 already closed for reasons unrelated to boundary events.
-It also does not fully remove the ambiguity: the grammar generator still reports a residual ambiguous alternative between the keyword-led host form and the timer form, resolved by picking whichever alternative the generator orders first and reported only as a warning on the build's stderr, a resolution an author has no visibility into and that a future grammar edit could silently invert.
-The colon leaves no such residual: it is a structural token the grammar decides on unambiguously, not a policy the generator applies silently.
-Also rejected: a postfix host clause, `on error(X) at Pack`, which reuses the word `at` that a timer's date key already claims (`on timer(at: "2026-08-01T09:00:00")`), reopening the exact kind of two-reading collision the colon exists to avoid, just moved to a different position in the handler.
-
-The six-trigger scope.
-Message, timer, signal, and conditional boundary events are interrupting or non-interrupting (`alongside`) on any activity.
-Error is always interrupting, on any activity, reusing the same `alongside: false` restriction already enforced for a host-less error handler rather than adding a boundary-specific rule.
-Escalation is interrupting or non-interrupting but only on a subprocess, an `attempt` block, a call, or a user task.
-Operaton gates that boundary on `attachedActivity.isSubProcessScope()`, and `parseTransaction` sets that flag exactly as `parseSubProcess` does, so an `attempt` block hosts an escalation on the same terms an ordinary block does.
-These restrictions are read from Operaton's own boundary-event parsing rather than invented, so every boundary event the surface admits is one the engine deploys; where the surface is stricter than the engine, as it is for error's cancellation mode and for compensation, this decision says why.
-
-Compensation is excluded on the grounds ADR-0018 already established for it generally: BPMN attaches compensation through a `bpmn:association` and an `isForCompensation` activity, a different attachment mechanism entirely.
-This exclusion preserves ADR-0018's decision rather than superseding it, since `on compensation` remains only a subprocess's own undo block, never a boundary form.
-It also keeps the import-side `refuseIfForCompensation` guard honest for exactly the reason ADR-0018 gives it: an activity marked `isForCompensation` still can never run, because a `bpmn:BoundaryEvent` carrying a `compensateEventDefinition` is refused on import just as it always was, now as one specific refusal inside a boundary event's own import path rather than as a blanket "no boundary events at all" rule.
-Cancel is excluded from these six because Operaton allows a cancel boundary on a transaction alone; ADR-0028 surfaces that container as the `attempt` block and admits one there.
-
-The self-contained body.
-A hosted handler's body lowers inline into the same container as its host, with no wrapping sub-process, as a boundary event followed by the body's own statement chain, terminating in the chain's own implicit end event.
-The only way back into the main flow is an explicit `goto`, uniform across interrupting and non-interrupting handlers alike, matching what a bare `{ }` block already means everywhere else in the language: a scope ends where its last statement ends, unless something inside it names where control goes next.
-
-Rejected: falling through to whatever statement follows the host in the main flow.
-This reads correctly for a "retry, then continue" shape on an interrupting boundary, but it is wrong by default for a non-interrupting (`alongside`) one: the host's own token is still running the main flow forward at the same time the escape chain would also be falling into it, duplicating the token and running the rest of the process twice.
-A single body shape that is correct for both interrupting and non-interrupting boundaries, with rejoining always spelled out by the author, avoids a rule that would otherwise have to special-case `alongside`.
-
-The transparency rule, and a consequence it has that the author cannot see.
-Because a hosted handler's body is not its own container, the scope provider's flow-container walk (the same walk that decides what `goto` may resolve against) has to treat a hosted `on` as transparent: resolving a name from inside such a body walks straight past the handler to the real enclosing process or subprocess, exactly as if the hosted `on` were not there at all.
-This is what makes `goto` legal in both directions across a hosted handler's body, both a `goto` inside the body reaching a main-flow statement and a main-flow `goto` reaching into the body, because both resolve against the one container the lowering actually places their statements in.
-
-This same transparency has a consequence a program's author has no way to observe from the source text alone, and it has to be recorded as a decision rather than left to be discovered as a surprise.
-A host-less `on` written inside a hosted handler's body is not scoped to that escape path.
-Because the hosted handler contributes no container of its own, a host-less handler nested inside it lowers into the same outer container its host lives in, the process or subprocess the hosted handler is itself attached to, and therefore guards that whole outer container, not just the one escape path it is textually written inside.
-The result is valid BPMN that Operaton deploys without complaint, and it follows directly and necessarily from treating a hosted handler as transparent; it is not a bug in the lowering.
-But it widens what a nested handler guards invisibly, purely as a side effect of where it happens to be written, and an author reading only the source has no local signal that this widening occurred.
-
-Boundary events as a second entry into the control-flow graph.
-The restructuring analysis wires every boundary event to its container's virtual entry, unconditionally, the same way it already wires every start event.
-This is the honest model, not a workaround: at runtime, a token genuinely appears at a boundary event the moment its trigger fires while its host is running, without ever traversing a sequence flow into it.
-Without this wiring, a boundary event's whole escape chain has no immediate dominator at all, and the restructurer can recognize an `if`, a `while`, or a clean join only relative to a dominance relationship, so an escape chain with no path from the entry could never be recognized as anything but a sequence of unstructured `goto`s.
-
-The accepted trade-off: a node reachable from both the main flow and an escape chain loses a tight immediate dominator once the boundary event is wired in.
-Its dominator moves up to the shared virtual entry, since neither the main flow's split nor the escape chain alone reaches it unconditionally.
-An `if`/`else` whose join such an escape chain jumps into therefore degrades to plain `goto`s on decompile rather than printing as a structured branch.
-The dominance result itself is correct: a region two independent entry points can both reach is genuinely not dominated by either one's split, so printing it as `goto`s is the restructurer being honest about a graph shape it cannot losslessly re-fold into `if`/`else`.
-
-The degraded output is not always re-readable, though, and the limit is worth stating precisely.
-When the join the chain lands on is a synthesized gateway rather than an authored step, the emitted `goto` names an id that has no statement form, so the printed source carries an unresolved-reference diagnostic and does not re-desugar to the graph it came from.
-That is a property of the `goto` sweep and not of the attachment axis, since the same output arises from a boundary-free program whose `while` body ends in a `goto` into an `if` branch, but an escape chain rejoining a branch join is a natural second way to reach it.
-Decompiling a hand-drawn diagram of that shape yields source a reader must repair by hand.
-Rejected: leaving a boundary event unwired, which makes every escape chain permanently un-restructurable regardless of how simple its own internal control flow is; and running a second, boundary-rooted analysis over the same container, which the restructurer's single-`Emitter`-per-container design does not support, since two independent analyses over one shared node/edge set would have no way to agree on which of them owns a node reachable from both.
-
-Host-derived ids.
-A boundary event's id is `Boundary__`, collision-resolved against the same document-wide `taken` set the implicit start/end ids already share, rather than the positional `EventSubProcess_` scheme a host-less handler's event sub-process already uses.
-Both halves of this id, the host's authored name and the trigger word, are text the author wrote and that survives a round trip verbatim, so the id does not move when the decompiler reorders handlers to the end of a container's statement list the way the trailing-position rule requires.
-A positional id would drift the moment that reordering happened, exactly the instability the existing `normalizeIr` re-key already has to canonicalize away for event sub-processes; a host-derived id needs no equivalent re-key for the generation direction at all.
-
-The one case this id scheme does not fully disambiguate is two boundary handlers sharing a host and a trigger but differing in code.
-`on Pack: error(A)` and `on Pack: error(B)` are legal, non-duplicate handlers under the validator's `(host, trigger, code)` key, yet both base to the identical `Boundary_Pack_error` before collision suffixing runs, so which one keeps the bare id and which one receives the `_2` suffix is positional rather than derived from either code.
-This is stable in the generation direction, since lowering assigns suffixes in statement order, the decompiler reprints handlers in that same order, and re-lowering reproduces it.
-It is exposed on import, though, where a hand-written or externally modeled document may present the two boundary events in whatever order the tool that wrote them chose.
-Folding the code into the base id was rejected: no other id constructor in this scheme sanitizes arbitrary author-supplied text into an id fragment, and doing so here would still need a numeric suffix for the case where two boundaries share every distinguishing property outright (two `timer` boundaries on one host, which carry no engine subscription key and so are never rejected as duplicates).
-The round-trip normalizer compensates on the comparison side instead: its canonical-id function keys a boundary event's signature on the event definition's own payload, not on the printed id, so two IR snapshots compare equal regardless of which physical id each assigns to which occurrence.
-
-### Consequences
-
-- Good, because the colon resolves the host/trigger ambiguity with no reserved word, no residual parser-generator warning, and no change to ADR-0016's soft-trigger-word design.
-- Good, because the six-trigger scope admits only what Operaton's own parser accepts, so a boundary event the validator passes always deploys.
- The converse does not hold: a compensation boundary is refused on ADR-0018's attachment-mechanism grounds rather than on an engine refusal, and Operaton would deploy the document that refusal rejects.
-- Good, because excluding compensation costs nothing new to build: the existing `on compensation` undo block already covers the granularity a boundary compensation event would have reached, and the import-side refusal that keeps `isForCompensation` honest needed no new mechanism, only a new call site for the existing one.
-- Good, because the self-contained body is uniform across interrupting and non-interrupting boundaries, so there is exactly one rule to learn ("the body ends where it ends; rejoin with `goto`") instead of one rule per cancellation mode.
-- Good, because wiring a boundary event to the virtual entry makes every escape chain's own internal control flow, including a nested `if`/`else`, restructurable on exactly the same terms as the main flow, rather than falling back to `goto`s for a construct that has nothing irregular about it on its own.
-- Good, because a host-derived id survives every reordering the decompiler's trailing-position rule performs, without adding a new re-key rule to the round-trip normalizer for the generation direction.
-- Bad, because a node reachable from both the main flow and an escape chain loses a tight immediate dominator, degrading a main-flow `if`/`else` whose join such a chain jumps into into `goto`s on decompile.
- That is accepted as a correct description of a graph shape with two genuine entry points, not worked around.
-- Bad, because a host-less `on` handler written inside a hosted handler's body is silently promoted to guard the outer container the hosted handler itself attaches to, rather than being scoped to just that escape path.
- It is a direct, unavoidable consequence of treating a hosted handler as transparent to the flow-container walk, and one an author cannot see from the source alone.
-- Bad, because two boundary handlers sharing a host and a trigger but differing only in code cannot be told apart by id text alone before the positional collision suffix is applied, which is stable across a recompile but exposed on an import written by another tool.
-
-### Confirmation
-
-`packages/language/test/parsing.test.ts` pins the colon's disambiguation directly: `on Review: timer("PT2H")` parses with `host` set to `'Review'` and `trigger` set to `'timer'`, never confusing the host with the trigger.
-`packages/language/test/scoping.test.ts` pins the transparency rule in both directions (`goto` from inside a hosted body reaching the main flow and back), and `packages/transform/test/ast-to-ir.test.ts` pins the promotion consequence: a host-less handler written inside a hosted body lowers into the outer container.
-`packages/language/test/validating.test.ts` pins the six-trigger scope, the escalation host restriction, and the compensation-has-no-host refusal, each with an exact-message assertion.
-`packages/transform/test/cfg-analysis.test.ts` pins the virtual-entry wiring and the accepted dominance trade-off as an explicit regression case, not an incidental property.
-`packages/transform/test/ir-to-dsl.test.ts` pins the self-contained body, including a nested `if`/`else` inside an escape chain restructuring cleanly and a chain that rejoins the main flow printing as `goto`.
-`packages/transform/test/synthesize-ids.test.ts` pins the host-derived id template and its collision suffixing.
-The frozen `tests/golden/boundary-events.bpmn` fixture and its round-trip suite exercise every one of these decisions together across a real DSL -> XML -> IR -> DSL -> IR cycle, including the import-first direction that is this scheme's one open disambiguation case.
-
-## Pros and Cons of the Options
-
-### The colon separator
-
-- Good, because it resolves the ambiguity at the second token, requiring no lookahead beyond what the parser generator already performs by default.
-- Good, because it leaves every trigger word a soft identifier, touching none of ADR-0016's design.
-- Bad, because it is one more character an author has to remember relative to the originally proposed bare form, for a distinction the bare form cannot actually make.
-
-### Promoting trigger words to keywords, admitted back as identifiers by type
-
-- Good, because a keyword-based host/trigger boundary would need no separator character at all.
-- Bad, because it touches roughly two dozen individual grammar productions rather than one, reversing ADR-0016's soft-word design for the sites it touches.
-- Bad, because the ambiguity is not actually removed, only resolved by the parser generator's own alternative-ordering rule, reported solely as a build-time warning and invisible to anyone not reading the build log line by line.
-
-### A postfix host clause (`on error(X) at Pack`)
-
-- Good, because it reads left-to-right as "catch this, at this activity," which some authors may find more natural than naming the host first.
-- Bad, because `at` already belongs to a timer's date key (`on timer(at: "2026-08-01T09:00:00")`), recreating the same word-level collision the colon exists to avoid, in a different position.
-
-### All eight of the language's catchable triggers as boundary events
-
-- Good, because it would need no per-trigger row distinguishing boundary-legal triggers from the rest.
-- Bad, because it would let the surface author a `bpmn:BoundaryEvent` carrying a `compensateEventDefinition`, a document Operaton's own parser does not treat as an ordinary boundary attachment and that this language deliberately keeps expressed only as a subprocess undo block (ADR-0018).
-
-### Falling through to the host's successor
-
-- Good, because it needs no `goto` at all for the common "retry, then continue" shape.
-- Bad, because it is wrong by default for a non-interrupting boundary: the host's own token is still advancing the main flow, so falling through duplicates it and runs the remainder of the process twice.
-
-### Wrapping a hosted handler's body in its own container
-
-- Good, because it would reuse the host-less handler's existing lowering unmodified, needing no new transparency rule in the scope provider.
-- Bad, because BPMN's own boundary-event semantics require the escape path to be a flow element of the same container as the host, not a nested one; wrapping it would make a `goto` back to the main flow cross a container boundary the same way ADR-0016's rule already forbids for a host-less handler, defeating the entire point of allowing a rejoin.
-
-### Leaving a boundary event unwired in the restructuring analysis
-
-- Good, because it needs no change at all to the analysis's entry-wiring rule.
-- Bad, because every escape chain becomes permanently un-restructurable: not even a chain with no internal branching of its own gets an immediate dominator, so the restructurer could never recognize anything inside it but a flat sequence of `goto`s.
-
-### A second CFG analysis rooted at each boundary event
-
-- Good, because it would keep the main flow's own dominance relationships completely untouched by the presence of any boundary event.
-- Bad, because the restructurer's `Emitter` design holds exactly one analysis per container; a node reachable from both the main flow and an escape chain would have two independent, and potentially disagreeing, dominance answers with no mechanism to reconcile them.
-
-### Reusing the positional `EventSubProcess_` id scheme
-
-- Good, because it would need no new id constructor at all.
-- Bad, because a positional coordinate is exactly the property that drifts once the decompiler moves a handler to the end of its container's statement list, the instability `normalizeIr` already has to canonicalize away for host-less handlers, which a host-derived id avoids needing in the first place.
-
-## More Information
-
-Related decisions: ADR-0016 (soft trigger words and the derived event-root design, both of which this decision reuses untouched: the colon separator exists because trigger words stay soft, and a boundary event's `errorRef`/`escalationRef` derivation is the same one ADR-0016 already established).
-ADR-0017 (the per-trigger payload surfaces, where message, signal, timer, and conditional payloads render identically whether the handler is hosted or not, since a boundary header reuses the same payload-rendering function a host-less handler's header already uses).
-ADR-0018 (compensation through event sub-processes, whose decision this one's exclusion of compensation from the boundary form preserves rather than supersedes, keeping its import-side `isForCompensation` guard valid for the reason stated there).
-ADR-0009 (dominator/post-dominator restructuring, the analysis this decision's CFG-entry wiring extends, and the reason a boundary event's dominance trade-off is described as a correct property of the graph rather than a defect in the restructurer).
-ADR-0010 (deterministic structural ids, the collision-resolution convention a host-derived boundary id reuses, and the contrast against the positional scheme this decision deliberately does not reuse).
-ADR-0013 (the target audience and no-boilerplate rule, the reason a hosted handler reuses the existing `on`/`throw`/`emit` vocabulary rather than introducing a distinct construct for a boundary attachment).
-ADR-0014 (the honest import contract, under which a `bpmn:BoundaryEvent` imports every trigger this decision admits, refusing a compensation boundary and any host outside this same container rather than mangling either into an approximation).
-
-Extended by ADR-0028 (work that can be given up), which adds `cancel` to this attachment axis on the one host kind that can catch it, the `attempt` block.
diff --git a/docs/decisions/0020-intermediate-catch-events.md b/docs/decisions/0020-intermediate-catch-events.md
deleted file mode 100644
index 03ef843..0000000
--- a/docs/decisions/0020-intermediate-catch-events.md
+++ /dev/null
@@ -1,174 +0,0 @@
----
-status: accepted
-date: 2026-07-24
-decision-makers: Marlon Kranz
----
-
-# Intermediate Catch Events: the `await` Keyword, a Synthesized Id, and a Four-Trigger Scope
-
-## Context and Problem Statement
-
-BPMNscript has two ways to react to a trigger so far.
-An `on` handler (ADR-0016) is a racing scope guard: it compiles to an event sub-process that competes with the whole container it guards, firing only if its trigger arrives before the container finishes.
-`throw`/`emit` (ADR-0016, ADR-0017) go the other way, making the current path fire a trigger outward, ending or continuing depending on the verb.
-Neither shape covers a third one BPMN itself has: `bpmn:intermediateCatchEvent`, a node sitting directly on the main sequence flow where the token stops in place and stays stopped until the trigger fires, then carries on to whatever follows.
-Nothing in this language lets an author write "pause here for a message" as a single step in a linear flow; the closest available shape, a hosted `on` handler, guards a whole activity rather than blocking one point in a sequence.
-
-Adding that third shape raises the same three questions every earlier event-layer decision has had to answer for its own construct.
-What word introduces it, given that the grammar's existing trigger words (`message`, `timer`, `signal`, `condition`, and the timer particles) are deliberately soft identifiers rather than keywords, so a new syntax slot cannot lean on token type to stay unambiguous, only on token position?
-Whether the construct needs an author-chosen name at all, since a name is what a `goto` would target and this node has no body to jump back out of?
-And which of BPMN's catchable event kinds this specific attachment point exposes, given that error, escalation, and compensation already have a home elsewhere in this language's model and a plain intermediate catch element is not where BPMN itself lets several of them live?
-
-## Decision Drivers
-
-- The grammar already commits every trigger word and every timer particle to being a soft identifier (ADR-0016), so any new rule sharing that vocabulary has to be checked against the same kind of token-level collision ADR-0019's colon separator had to resolve for boundary events.
- A plausible-looking rule is not enough; it has to be run through the parser generator and shown clean.
-- The restructuring analysis (ADR-0009) and the round-trip id normalizer (ADR-0010) already have settled rules for what does and does not need special handling: only a node with no incoming edge needs virtual-entry wiring, and only a handful of node kinds get re-keyed on compare.
- A new construct should fit those existing rules rather than add exceptions to either one, if its own shape genuinely allows that.
-- The payload surfaces for message, signal, timer, and conditional triggers are already built (ADR-0017) and reused by both `on` handlers and boundary events (ADR-0019).
- A third consumer of the same surfaces should cost one dispatch case per layer, not a second payload grammar.
-- The honest import contract (ADR-0014) means an unsupported form on the wire has to be refused with a diagnostic that names what was refused, not silently dropped and not guessed into an approximation.
-
-## Considered Options
-
-For the keyword:
-
-- `await`
-- `wait`
-- `receive`
-
-For goto-target naming:
-
-- No authored name at all, a synthesized id only
-- An optional `name=ID` slot mirroring how a hosted `on` handler is named
-
-For the trigger scope:
-
-- Message, timer, signal, and conditional only
-- The full set of BPMN event kinds this language already models somewhere, adding error, escalation, compensation, cancel, and link
-- A different hand-picked subset
-
-## Decision Outcome
-
-Three decisions, chosen together for the same reason ADR-0016's and ADR-0019's each were: spend a new reserved word, a new syntax slot, or a wider trigger list only where the alternative genuinely loses something, whether grammar unambiguity, round-trip fidelity, or a meaning this language does not actually have, and reuse what already exists everywhere else.
-
-The keyword is `await`.
-Before committing to it, the rule was built into a live Langium parser with Chevrotain's self-analysis switched on (`createServicesForGrammar`), and the full trigger-payload battery was run through it.
-Every form parsed with zero ambiguity warnings, including the case that actually matters: `await timer("PT1H")`, a bare identifier followed by a parenthesized payload, the same token shape `on timer("PT1H")` already has, resolved to `trigger='timer'` with the duration as its payload, never confusing the trigger for anything else.
-`wait` and `receive` parse exactly as cleanly; the choice between the three is not a parsing question at all, and is settled on what each word actually says.
-`receive` names precisely what a message catch does and nothing else: a signal is broadcast, not addressed to anyone to "receive"; a timer is not received from anywhere; a condition is evaluated, not delivered.
-Using it across all four triggers would mislabel three of them.
-`wait` reads as a generic pause, closer to a fixed delay than to a subscription that resolves when something happens, a fair description of the timer case and a poor one for a message or signal correlation.
-`await` carries the meaning every one of the four cases actually shares: execution stops here until this specific thing resolves, then continues with whatever comes next, which is exactly what an intermediate catch event does at runtime and is the same word this audience already reads that way in mainstream asynchronous code.
-
-Reserving `await` costs nothing existing code already pays for.
-It collides with no trigger word or timer particle already in the grammar, and it appears as a bare identifier nowhere in the committed corpus.
-Unlike `message` or `code`, words ADR-0016 keeps soft precisely because they are ordinary variable names this audience reaches for constantly, `await` is not a word a process author would otherwise want free.
-
-No authored name; the id is synthesized.
-A trial grammar carrying an optional `name=ID` slot ahead of the trigger, mirroring how a hosted `on` handler is named, was run through the same live-parser check and reintroduced the exact ambiguity ADR-0019's colon separator exists to avoid.
-Chevrotain reported "Ambiguous Alternatives Detected inside IntermediateCatchEvent Rule": with a name and a trigger both bare identifiers, nothing decides which of the two the first word after `await` is, and a timer read the wrong way loses the one piece of information deciding which of BPMN's three timer forms the author meant.
-That is not a cosmetic misparse.
-A bare payload, an `at` key, and an `every` key map to a duration, a fixed date, and a repeating cycle respectively, three different things the engine schedules, so losing which one was written is a scheduling error hiding behind a name field nobody asked the catch to have in the first place.
-
-Dropping the name slot removes the ambiguity outright, and it costs the surface nothing a `goto` target ever needed: an intermediate catch has no body, so unlike a hosted `on` handler it never has to be an addressable jump-back point.
-It also matches the one construct already shaped exactly like it.
-`IntermediateThrowEvent`, `emit`'s compiled form, is a one-in/one-out, fall-through main-flow node with no name syntax of its own, and the catch is its topological twin, differing only in whether the token fires forward immediately or stops and waits.
-The catch's id is `Catch_`, generated by `makeIntermediateCatchEventId` from the same structural-coordinate scheme (ADR-0010) `makeThrowEventId` already uses for `Throw_`, and `/^Catch_/` joins `RESERVED_ID_PATTERNS` so an author can never type a name that would collide with one.
-Because there is no name syntax to print in the first place, the decompiler needs none of the reserved-prefix-omission handling a hosted `on` handler's printed name needs; omitting something that was never there is not a rule, it is the absence of one.
-
-One consequence follows from having no name at all, and it is not new.
-A hand-drawn diagram whose second edge or `goto` targets an intermediate catch's synthesized id runs into exactly the same wall every other synthesized, unnameable id already does on decompile: the printed source has nothing to write as the jump's target text, so the edge prints as the existing hand-repair marker rather than a resolvable `goto`.
-This is the same invariant that already governs every `goto` the decompiler emits, that a jump target has to be something the grammar can actually spell, extended to one more node kind rather than a new mechanism built for this one.
-
-The trigger scope is message, timer, signal, and conditional.
-These are exactly the four shapes the shared `readCatchEventDefinition` helper already maps, the same function an `on` handler's start event and a boundary event's catch side both already read through, so admitting them to `await` costs one new dispatch case per layer, not new mapping logic.
-They are also the only four kinds this language treats as something a token can genuinely wait on.
-Error and escalation are always thrown by `throw`/`emit` and always reacted to by a racing `on` handler (ADR-0016); nothing about either one is a value a linear flow blocks on and then continues past, the way a message correlation or a timer's clock is.
-Letting `await` also claim them would mean two independent constructs, a handler and an await, both plausibly "catching" the same trigger, with no rule for which one actually fires when it arrives.
-Compensation has exactly one meaning in this language: the undo block a subprocess declares with `on compensation` (ADR-0018), invoked only by the engine's own compensation machinery, never something with an independent arrival a token waits for.
-Cancel is excluded because BPMN gives it no position an `await` could occupy: a transaction sub-process's boundary catches one, and an end event inside that sub-process raises it.
-ADR-0028 surfaces that container as the `attempt` block and admits both of those positions there, neither of them the pass-through step on the main flow an `await` compiles to.
-Link is BPMN's own off-page connector, built for splitting one diagram across pages; this language's `goto` already does the job of naming a jump inside one textual source, so link would duplicate an existing mechanism for a diagramming problem a single `.bpmnscript` file never has.
-A catch carrying more than one event definition, or `parallelMultiple="true"`, waiting on several triggers on one element at once, is a different modeling shape from the one-trigger-per-`await` grammar this rule commits to, and nothing in the surface's design calls for it.
-
-On import, `bpmn:intermediateCatchEvent` moves from a blanket `UnsupportedElementError` to an honest refuse-or-map split, matching the contract ADR-0014 already sets for every other construct: the four supported kinds map through `readCatchEventDefinition` unchanged, and a link, error, escalation, compensation, or cancel definition, or more than one definition on the same element, is refused by name rather than silently dropped or approximated.
-The XML schema itself does not stop a document from placing one of the excluded definitions on an intermediate catch element, so the refusal is a real check against real input a foreign tool or a hand-edited file can produce, not a defensive branch that never fires.
-
-The catch's topology settles the two passes that would otherwise need new cases for it.
-The restructuring analysis wires only start events and boundary events to its virtual entry (`buildGraph`, `cfg-analysis.ts`); every other flow element, including the existing intermediate throw, is an ordinary node reached by a normal incoming edge, and the catch is one more ordinary node of that kind, so it needs no dominance handling that is not already there.
-The round-trip id normalizer re-keys only gateways, event sub-processes, and boundary events on compare; task and event ids, the catch's own `Catch_` among them, round-trip verbatim exactly like `Throw_` already does.
-Both claims are confirmed by dedicated regression cases rather than left as an assumption the construct happens not to violate.
-
-### Consequences
-
-- Good, because the full payload battery parses with zero ambiguity warnings, so `await` reserves one word and touches no existing grammar rule's disambiguation.
-- Good, because `await` names exactly the behavior all four supported triggers share, execution stops until this resolves, in a word this audience already reads that way, rather than a word accurate for one trigger and approximate for the other three.
-- Good, because dropping the name slot removes the trial ambiguity outright and costs nothing a `goto` target ever needed, since the catch has no body to jump back out of.
-- Good, because the catch is a topological twin of the intermediate throw, so forward emit, decompile, the restructuring analysis, and the round-trip normalizer all reuse existing machinery through one new dispatch case each, rather than new passes.
-- Good, because the four-trigger scope matches `readCatchEventDefinition` exactly, so import gains an honest refusal for exactly the forms it cannot represent instead of either a blanket refusal or a silent drop.
-- Bad, because an author can never give a specific `await` a name to `goto`.
- The only way to reach one is to write it inline where it belongs, and a hand-drawn diagram's stray edge into one prints as a hand-repair marker rather than a resolvable jump, the same limit every other unnameable synthesized id already carries.
-- Bad, because error, escalation, compensation, and cancel stay permanently unreachable from `await` even on a hand-crafted document where the shape looks unremarkable to a reader who does not already know BPMN restricts a plain intermediate catch element to a smaller set of event kinds than a boundary event or an event sub-process's start accepts.
-
-### Confirmation
-
-`packages/language/test/parsing.test.ts` pins the `await` disambiguation directly: each of `await message("...")`, `await timer("...")` in all three of its forms, `await signal("...")`, and `await condition(...)` parses to the field shape its trigger implies, with the timer case pinning that which of the three was written survives, not swallowed into a name.
-A soft-word survival pin confirms `var await` fails to parse, proving the reservation, while `var message: string` and a step named `every` still parse clean.
-`packages/language/test/validating.test.ts` pins the four-trigger acceptance and each trigger's missing payload, alongside the rejection of `await error`, `await escalation`, and `await compensation`, matched on the substrings that name the four legal words and the shape each of those kinds takes instead.
-`await cancel` is refused there too, matched against its whole message, which points at the `end cancel` that gives an `attempt` block up and the `on : cancel` that catches it.
-The refusal of an unknown word after `await` is matched whole as well, so the four accepted kinds and the place each refused kind is written instead are fixed in one string.
-`packages/transform/test/ast-to-ir.test.ts` pins the four lowering cases and that two catches in one body receive distinct `Catch_` ids.
-`packages/transform/test/ir-to-xml.test.ts` pins the four emitted `*EventDefinition` shapes and that no `name` attribute is stamped on the element.
-`packages/transform/test/xml-to-ir.test.ts` pins the four mapped shapes and the refusal of an unsupported trigger, of multiple definitions on one element, and of conditional narrowing attributes inherited from ADR-0017.
-`packages/transform/test/ir-to-dsl.test.ts` pins the four decompiled render lines, each with no `Catch_` token in the output.
-`packages/transform/test/cfg-analysis.test.ts` pins the CFG no-op as an explicit regression case: a catch on the main flow gets a normal immediate dominator, never the virtual entry.
-`packages/transform/test/synthesize-ids.test.ts` pins the id template and its reserved-pattern match.
-The frozen `tests/golden/intermediate-catch.bpmn` fixture and `tests/intermediate-catch.round-trip.test.ts` exercise all four triggers together across a full DSL -> XML -> IR -> DSL cycle, asserting both IR idempotence and that the decompiled source recompiles with zero validation errors.
-A Docker-gated end-to-end test deploys an example awaiting a message mid-flow, correlates it over REST, and proves the instance was genuinely waiting before correlation and reaches completion after, the one property none of the other layers can show on their own, since blocking is a runtime property rather than a compile-time one.
-
-## Pros and Cons of the Options
-
-### `await`
-
-- Good, because it names the one behavior all four supported triggers share, not just the one it happens to fit best.
-- Good, because it reserves a word that costs nothing existing code already relies on.
-- Neutral, because it is one more reserved word alongside `on`, `throw`, `emit`, and `alongside`, a small, fixed set this language already keeps deliberately short.
-
-### `wait`
-
-- Good, because it parses exactly as unambiguously as `await`.
-- Bad, because it reads closer to a fixed delay than to a subscription that resolves when something happens, a description that fits the timer case and undersells the other three.
-
-### `receive`
-
-- Good, because it names the message case precisely.
-- Bad, because it is wrong for the other three: nothing is received from a timer, a signal is broadcast rather than addressed, and a condition is evaluated rather than delivered.
-
-### An optional `name=ID` slot
-
-- Good, because it would let an author give a catch an explicit `goto` target.
-- Bad, because it reintroduces the exact ambiguity ADR-0019's colon separator exists to avoid: Chevrotain flags it directly, and the parser reads the trigger word as a name, losing which of the three timer forms was meant.
-
-### Every BPMN catchable trigger, including error, escalation, compensation, cancel, and link
-
-- Good, because it would need no exclusion list and no refusal diagnostics for the excluded forms.
-- Bad, because error, escalation, and compensation have no "something to wait for" meaning in this language's model.
- They are always thrown and always handled by a racing `on`, and letting `await` also claim them creates two constructs that both plausibly catch the same trigger, with no rule for which one runs.
-- Bad, because BPMN admits no cancel on a plain intermediate catch at all, only on a transaction sub-process's boundary and on the end event that gives that sub-process up, and link duplicates a job `goto` already does for a diagramming problem a single text file never has.
- ADR-0028 surfaces that sub-process as the `attempt` block, where a cancel is written `end cancel` inside the block and caught by `on : cancel` beside it, positions no `await` on the main flow could have taken.
-
-## More Information
-
-Related decisions: ADR-0016 (soft trigger words and the `throw`/`emit` terminality rule, under which `await`'s own trigger and particle words stay soft identifiers, and from whose throw/handler split the reasoning that error and escalation are always thrown, never awaited, follows directly).
-ADR-0017 (the payload surfaces for message, signal, timer, and conditional, all four of which `await` reuses verbatim, including the timer particle-to-definition mapping and the deferred conditional-narrowing refusal).
-ADR-0018 (compensation as a subprocess undo block, the reason compensation gives an `await` nothing to wait for).
-ADR-0019 (boundary events, the source of the live-parser ambiguity-check method this decision reuses twice, and the six-trigger boundary scope this decision's four-trigger catch scope deliberately does not match, since a boundary event's attachment and an intermediate catch's inline wait answer different questions about the same trigger vocabulary).
-ADR-0014 (the honest import contract that `bpmn:intermediateCatchEvent` now follows instead of a blanket refusal).
-ADR-0009 (the dominator-based restructuring analysis the catch needs no new entry-wiring case for, being an ordinary node reached by a normal incoming edge).
-ADR-0010 (the structural-coordinate id scheme `Catch_` follows, and the re-keying rule that already excludes task and event ids, so the catch's id needs no new normalizer case either).
-
-Extended by ADR-0028 (work that can be given up), which surfaces the transaction sub-process as the `attempt` block and gives cancel the two positions BPMN does allow it, leaving this decision's four-trigger scope where it stands: a cancel is written on the end that gives a block up and caught beside that block, never awaited.
-
-Amended by ADR-0035, which admits `link` to the `await` trigger scope, gives `await` the optional trigger-first name slot `emit` already carries, and wires a link catch to the virtual entry beside a start and a boundary event.
-The reasoning above for keeping link off the authoring surface stands as a claim about authoring; ADR-0035 adds link for the import direction, where a modeller's document already carries one and rewriting it to a `goto` would not round-trip.
diff --git a/docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md b/docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md
new file mode 100644
index 0000000..99f7d8b
--- /dev/null
+++ b/docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md
@@ -0,0 +1,90 @@
+---
+status: accepted
+date: 2026-08-27
+decision-makers: Marlon Kranz
+---
+
+# Listeners reuse `on` inside the attribute block
+
+## Context and Problem Statement
+
+Operaton runs user code at points in an element's lifecycle that never appear as flow nodes.
+An execution listener fires when a node starts or ends.
+A task listener fires on one of several user task lifecycle events, and one of them, `timeout`, carries its own timer.
+Each listener names its event and exactly one binding, a class, an expression, a delegate, or a script, which is the same exactly-one rule a service task binding already carries.
+
+BPMNscript already uses `on` to catch a BPMN event: `on error(E409) { ... }` guards a block, and a boundary form docks that catch onto one activity.
+A listener is not a caught event: it never appears in the diagram, and it fires on the element's own lifecycle rather than on something the process throws.
+Writing it as `on create(class: "...")` spells that lifecycle callback with the same word a catch uses, so one word covers two ideas.
+Whether the listener surface earns its own keyword or reuses `on` and accepts that ambiguity is the first question here.
+
+Three more are settled by the engine rather than by taste, because a shape the surface calls legal has to deploy.
+`BpmnParse.parseTaskListeners` accepts six event words, so a surface word for the assignment event that is not the engine's writes an `event` attribute the deployment refuses and cannot read a real file back.
+`BpmnParse.parseTimeoutTaskListener` reads the listener's `id` and adds an error when it is null, since the id is baked into the timer job's handler configuration and keys `TaskDefinition.addTimeoutTaskListener`, so a timeout listener needs an id even though no author writes one.
+`BpmnParse.checkActivityInputOutputSupported` adds an error for an `operaton:inputOutput` on a `bpmn:subProcess` whose `triggeredByEvent` is `true`, which is exactly where a host-less `on` handler's `input` and `output` lines would lower.
+A host-less handler also lowers to two BPMN elements, so a last question follows: which of the two carries the engine attributes written on the statement?
+
+## Decision Drivers
+
+- Every reserved keyword is a name an author permanently loses for a variable or a step, and `listener` is a name a real process might want.
+- A listener's shape is fixed by the engine and checkable while typing: one event from a finite set, exactly one binding, a timer only when the event is `timeout`.
+- A program the validator accepts must deploy, and a document that deploys must import and rebuild to one that still deploys (ADR-0012).
+- One fact about the language lives once: the event words, the parameter hosts and the id scheme are read off one table each.
+- The timer clause, the fenced script block, and the `class`, `expression`, and `delegate` keys exist, so reusing them costs less than specifying new forms for listeners.
+- No new key on the surface for something the engine derives, and no second spelling to document.
+
+## Considered Options
+
+- Reuse `on` inside the attribute block, letting position decide whether it is a listener or a caught event: the language names one idea once, the timer clause and the bindings carry over unchanged, and nothing is reserved, at the price of a meaning that is positional, so a reader must check what encloses the line.
+- Add a distinct `listener { ... }` keyword: the word alone would say the block is an engine callback rather than a flow node, keeping apart two things BPMN itself keeps apart, but it spends a reserved word on an idea the language already has a word for, and every file pays that cost whether or not it registers a listener.
+- Spell every event as the engine does, mint the timeout listener's id on export, and refuse parameters on a host-less handler: one spelling per fact, the wire carries it as written, and the id needs no key the author never reads.
+- Keep a shorter `assign` on the surface and map it to `assignment` on the wire: one event word would then have two spellings, one in the language and one in every diagram and engine log, and the mapping would be the one place the event table is not the whole truth.
+- Give the timeout listener an `id` key, keep the parameter grant, and move the mapping onto the trigger start event: an authored id would survive a round trip, but `BpmnParse.parseStartEvents` calls `ensureNoIoMappingDefined` on every start event, so the nested start cannot take the mapping either, and the key would be the only listener setting the engine derives rather than reads.
+
+## Decision Outcome
+
+Chosen: reuse `on` inside the attribute block, spell every event as the engine does, mint the timeout listener's id on export, and refuse an io mapping on a handler.
+`on` already reads as "when this happens, run that" everywhere it appears, and a keyword buys its clarity with a permanently reserved word.
+Each of the other three rules follows from one engine method and adds nothing to the surface.
+
+The event word is a soft word, like a trigger word: every task listener event lexes as an identifier, and the validator checks each against the element kind it was written on.
+`start` and `end` lex as statement keywords instead, so the grammar names those two as the listener event position's exceptions.
+An `on` clause becomes an execution listener or a task listener by its event word alone, since the two event sets never overlap, so the surface never has to spell the distinction the XML draws between `operaton:executionListener` and `operaton:taskListener`.
+
+Every `timeout` task listener is written with the id `_timeout_`, `n` counting the task's timeout listeners from one in document order.
+An id the document already holds is stepped past, the way a synthesized element's id is (ADR-0010).
+
+An `on` handler takes no `input` or `output` line in either form, since the engine refuses a mapping on a boundary event and on an event sub-process alike.
+The attribute block rule for a handler therefore grants no parameters, and the validator's existing parameter-host message names the kinds that take one.
+
+Engine attributes on a handler statement land on the element that survives printing.
+A hosted handler lowers to one `bpmn:boundaryEvent`, so there is no choice.
+A host-less handler lowers to two elements instead, a `bpmn:subProcess` and a nested trigger `bpmn:startEvent`, and its attributes go on the sub-process because the synthesized start event is elided on print.
+The timer job's own settings, `jobPriority`, `retryCycle` and `exclusive`, go on that trigger start event instead, since the timer job is the start's, and the printer lifts them back into the head.
+
+### Consequences
+
+- Good, because no word is reserved.
+ `listener`, `create`, and `timeout` stay free for a step or a variable name.
+- Good, because the timer clause, the script block, and the three binding keys are reused as they stand.
+ Highlighting, completion, and the binding diagnostic extend to listeners for free.
+- Good, because every program the validator accepts deploys, and a Modeler-authored file with an assignment listener or an id-carrying timeout listener imports without a refusal or a warning.
+- Good, because a listener's binding is a list of its own rather than the members every other element carries.
+ A listener's block nests no form, no input or output parameter, and no other listener; the one member it holds beside its bindings is an injected field (ADR-0018).
+- Bad, because a reader must check the enclosing context to know which `on` they are looking at: inside a task's braces it is a callback, at statement position it is a catch.
+- Bad, because a timeout listener's authored id is replaced by the minted one on a round trip, so a document that names its timeout listeners rebuilds under the tool's names.
+- Bad, because a value a host-less handler's body needs has to be mapped on a step inside the body rather than on the handler.
+
+### Confirmation
+
+`packages/language/test/validating.test.ts` pins `on assignment` accepted and `on assign` refused with the list, and a parameter on either handler form refused with the parameter-host message.
+`packages/transform/test/ir-to-xml.test.ts` pins two timeout listeners on one task written as `_timeout_1` and `_timeout_2`, and the second stepping past a task already holding its id.
+`packages/transform/test/xml-to-ir.test.ts` pins `event="assignment"` imported, `event="assign"` refused, and a listener id consumed without a warning.
+The frozen pairs `tests/golden/listeners.{bpmnscript,bpmn}` and `tests/golden/input-output.{bpmnscript,bpmn}` carry the three shapes and import without a warning.
+
+## More Information
+
+The intermediate representation carries a listener as an event plus a four-way tagged binding mirroring `ServiceTaskBinding`, and a `timeout` task listener also carries the timer the event layer already models.
+Operaton behaviour was read from `BpmnParse.parseTaskListeners`, `BpmnParse.parseTaskListener`, `BpmnParse.parseTimeoutTaskListener`, `BpmnParse.checkActivityInputOutputSupported`, `BpmnParse.ensureNoIoMappingDefined` and `TaskListener`.
+
+Related decisions: ADR-0010 (deterministic structural ids), ADR-0012 (the import contract this surface honors), ADR-0018 (the field a listener's block holds), ADR-0021 (engine attributes as named IR fields).
diff --git a/docs/decisions/0021-external-tasks-as-a-service-task-binding.md b/docs/decisions/0021-external-tasks-as-a-service-task-binding.md
deleted file mode 100644
index 9c40b24..0000000
--- a/docs/decisions/0021-external-tasks-as-a-service-task-binding.md
+++ /dev/null
@@ -1,78 +0,0 @@
----
-status: accepted
-date: 2026-08-16
-decision-makers: Marlon Kranz
----
-
-# External Tasks as a Service Task Binding, Not a Separate Keyword
-
-## Context and Problem Statement
-
-The grammar has two statement keywords that both compile to a `bpmn:serviceTask`: `service`, carrying a `class`, `expression`, or `delegate` binding, and `external`, carrying a `topic`.
-Both keywords take the same shape and produce the identical BPMN element; only the DSL surface distinguishes them.
-The intermediate representation already models this as one type.
-`ServiceTaskBinding` in `packages/transform/src/ir/types.ts` is a tagged union of `class` | `expression` | `delegateExpression` | `external`, and ADR-0006 already describes a service task as bound to "a Java class, a JUEL expression, a delegate expression, or an external topic" in one sentence, not as two separate constructs.
-
-Does the DSL surface need its own keyword for a binding the IR, the validator's discriminator check, and the emitted XML already treat as one of four variants on the same element, or can `external` fold into `service` as that fourth binding?
-
-## Decision Drivers
-
-- ADR-0006 committed the IR to modeling every service-task execution form, including the external-worker form, as one tagged union rather than as separate node kinds.
- A grammar carrying a second keyword for a case the IR already unifies is an asymmetry with no consumer that benefits from it.
-- ADR-0013 commits the language to minimizing required syntax and to naming constructs by what the user means rather than by BPMN vocabulary.
- Two keywords for the same element ask an author to remember an extra word for a distinction the runtime output does not make.
-- A reserved keyword is a name an author can never choose for a variable or a step, so every keyword this language adds is a name it permanently takes away from process authors.
- `external` is an ordinary English word a real process is fairly likely to want for a variable or a step name.
-
-## Considered Options
-
-- Keep `external` as its own statement keyword
-- Fold `external` into `service` as a fourth binding attribute
-
-## Decision Outcome
-
-Chosen option: "Fold `external` into `service` as a fourth binding attribute", because all four service-task execution forms already produce exactly one BPMN element, the IR already unifies them under `ServiceTaskBinding`, and ADR-0013 already commits this language to the smaller keyword surface.
-
-A service task's settings gain `topic` as a fourth legal key alongside `class`, `expression`, and `delegate`.
-The validator's exactly-one-binding check now requires exactly one of the four, naming all four in its error message, and the printer emits `service X(topic: "...")` for the binding the same way it already emits `service X(delegate: "...")` for a delegate expression.
-A fifth binding, `type`, joins the four under the same check (ADR-0042).
-The emitted BPMN XML is unchanged: `operaton:type="external"` alongside `operaton:topic` is exactly what the `external` keyword produced before, since the IR and the XML generator never referenced the DSL keyword in the first place.
-`external` stops being reserved and parses as an ordinary identifier, freeing it for a variable or step name the way any other non-keyword does.
-
-### Consequences
-
-- Good, because there is one keyword surface to teach: an author reads "a service task can call a class, an expression, a delegate expression, or an external worker" as four attributes of the same statement, instead of two statements that happen to compile to the same element.
-- Good, because boundary-event host enumerations, the reserved-step-name check, and every other place the grammar or validator listed the two task kinds side by side collapse to naming `service` once, removing a class of enumeration that had to be kept in sync across the grammar, the validator, and the docs.
-- Good, because `external` is no longer reserved, so a process that wants a variable or step literally named `external` can have one.
-- Bad, because a diagram viewer or a reader skimming XML sees `operaton:type="external"` as a distinguishing marker that the DSL source no longer surfaces as a distinguishing keyword; the binding is still visible, just as a setting in a `service` step's parens rather than as the statement's own word.
-
-## Pros and Cons of the Options
-
-### Keep `external` as its own statement keyword
-
-An external task is, operationally, a different kind of thing from the other three bindings.
-Deploying it means running a separate worker process that polls the engine for work on a topic, rather than shipping code the engine invokes directly inside its own JVM.
-A dedicated keyword would flag that operational fork at the point an author writes the step, the same way the language already uses a dedicated keyword to distinguish a `user` task's human actor from a `service` task's automated one.
-
-- Good, because the keyword itself documents an operational difference, a separately deployed worker, that an attribute value among an element's settings does not surface as prominently.
-- Neutral, because Operaton itself does not model `external` as a distinct element either; the engine's own `bpmn:serviceTask` with `operaton:type="external"` is exactly the asymmetry this option would preserve on the DSL surface only.
-- Bad, because it reserves a second keyword, and therefore a second English word an author can never use as an identifier, for a distinction that produces no distinct BPMN element and that the IR, the validator, and the printer already collapse into one type.
-
-### Fold `external` into `service` as a fourth binding attribute
-
-`topic` becomes a fourth legal key in a `service` task's settings, validated by the same exactly-one-of-four check the other three bindings already share.
-
-- Good, because it matches the IR ADR-0006 already committed to: one `ServiceTaskBinding` union, one emitted element, one DSL statement.
-- Good, because it follows ADR-0013's rule directly: the grammar drops a keyword instead of keeping one that carries no information the runtime output does not already carry elsewhere.
-- Good, because `external` is freed as an ordinary identifier.
-- Bad, because the operational-fork argument above is real: nothing in the `service Ship(topic: "...")` surface tells a reader, without already knowing what `topic` means, that this step runs in a separately deployed process instead of inside the engine.
-
-## More Information
-
-The validator's zero-binding and more-than-one-binding messages name all four keys (`class`, `expression`, `delegate`, `topic`), so the fourth binding is discoverable from the error text alone.
-Related decisions: ADR-0006 (engine-agnostic intermediate representation) already modeled `external` as a `ServiceTaskBinding` variant rather than its own IR node; this decision brings the DSL surface in line with that model.
-ADR-0013 (target users without BPMN knowledge and minimize boilerplate) is the standing rule this decision applies.
-
-Amended by ADR-0038, under which the `topic` binding carries the three things the engine reads beside the topic and under no other binding: a `taskPriority` setting, `property` lines, and `error when ` mappings.
-
-Amended by ADR-0042, which adds `type` as a fifth binding, the engine's own discriminator for a mail or shell task it builds itself.
diff --git a/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md b/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md
new file mode 100644
index 0000000..cd287eb
--- /dev/null
+++ b/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md
@@ -0,0 +1,104 @@
+---
+status: accepted
+date: 2026-08-27
+decision-makers: Marlon Kranz
+---
+
+# Operaton engine attributes as named IR fields
+
+## Context and Problem Statement
+
+The importer reports everything it cannot represent in the `extensionAttribute` warning category, and Operaton's execution settings all land there: `asyncBefore` and its siblings, job priority, the retry cycle, `operaton:inputOutput`, execution and task listeners, and every user-task assignment attribute beyond `assignee` and `formKey`.
+A process that round-trips through this tool comes out without them, which makes the tool unusable on a file a real deployment produced.
+None of these settings changes control flow: an async continuation decides where the engine commits a transaction, an input parameter decides what a delegate reads, and a listener decides what runs alongside a step.
+Carrying them needs no change to gateway synthesis, only somewhere to put roughly twenty values and a mapping on each of the four transforms.
+
+Two carriers make the placement question harder than one field per node.
+A gateway is the one flow node in this surface with no keyword and no name of its own, since `if`, `while`, `do...while`, `parallel`, and a multi-branch `await` each synthesize one or two from block structure and the restructurer elides every one it matches back to a statement.
+Operaton still reads five execution settings off every gateway it deploys, and a file a real deployment produced can set any of the five on any of its gateways.
+A timer's job settings are not all read off the node that carries the timer either: `BpmnParse.parseTimer` builds the job a timer creates from `operaton:exclusive` on the `bpmn:timerEventDefinition` element and from the priority stored on the activity that carries the definition.
+
+Where should these settings live in the IR, under what names, which elements can take which, and which element does each one get written on?
+
+## Decision Drivers
+
+- The IR's shape and its no-vendor-prefix naming convention (ADR-0007), which twenty fields arriving at once have to fit rather than amend.
+- The validator checks attribute keys against element kind, and round-trip idempotence compares IR against IR, so a carried setting has to be a typed field a structural comparison can see, not opaque text.
+- Three settings are not flat: an input or output parameter's value can be a list, a map, or an inline script, and a listener carries an event plus one of four bindings.
+- A setting the script writes must reach the job it names, and the surface keeps one `exclusive` key per statement rather than one key per job an element may create.
+- Every gateway is still synthesized and matched back, so a setting cannot cost a gateway its textual anonymity.
+- The one bracket shape already holds scalar settings in a parens, and a join gateway sits beside its split under one statement, so the two need told-apart spellings inside one parens rather than a second parens.
+- The import contract: what changes execution is refused, what does not is carried or warned, and nothing drops in silence.
+
+## Considered Options
+
+- Named vendor-neutral fields on the IR node, which extend what the IR already does for `assignee`, `formKey`, and the service-task binding and let the type system do the validation work, since a listener's binding is a tagged union of four variants and one with two bindings or none cannot be constructed.
+ A setting nobody has named yet is not carried, so keeping up with the engine means editing code rather than widening a container.
+- A generic extension bag keyed by qualified attribute name, filled from import and written back verbatim, which would carry every Operaton attribute at once, including ones nobody has looked at, but implies an untyped DSL surface where any key compiles and a typo produces an attribute the engine never reads and never complains about.
+- Openly `operaton:`-prefixed field names such as `operatonAsyncBefore`, which would show a reader which fields are BPMN's own and leave room for a second engine, but contradict the naming rule already set (ADR-0007), since `assignee`, `formKey`, and the service-task binding are engine settings under plain names, so a prefix means renaming them or keeping two conventions in one file.
+- A gateway's settings in a parens on the statement head, the join's under a `join`-prefixed spelling, reusing the bracket shape every element already has at no grammar cost beyond a settings slot on five statement rules and putting the join's setting next to the split's, at the price of ten keys a reader tells apart by prefix alone.
+- A second parens for the join gateway, which would need no prefix, but puts two adjacent parens on a statement head, a shape nothing else in the grammar has, and leaves the second one empty on `while`.
+- A `gateway` keyword introducing an authored gateway that carries a setting the way any other named element does, which reserves a word for an element the grammar deliberately keeps unauthored, and an authored gateway gains an id, stops eliding, and breaks the restructurer's round trip.
+- Each timer job setting written on the element the engine reads it from, so the documented spelling runs as written with no new key and no warning, at the price of the lock being written in two places on a timer carrier with an async flag, the one shape that can disagree with itself.
+- A warning on a host-less timer handler's job settings, pointing the author at an explicit `start` inside the body, which leaves a documented spelling unable to reach the job it names and answers with a warning.
+- A second surface key for the timer job's lock, beside the one for the continuation job, which spends a key on a distinction the element the engine reads already makes, and would put one lock key per job on every statement.
+
+## Decision Outcome
+
+Chosen options: named vendor-neutral fields on the IR node, a gateway's settings in a parens on the statement head, and each timer job setting written on the element the engine reads it from.
+The IR already carries `assignee`, `formKey`, and the service-task and call-activity bindings under plain names, so the naming extends a precedent rather than amending one.
+
+Each setting becomes an optional field on the IR node that owns it, named for what it means rather than how it serializes, so the retry cycle is `retryCycle`, not `failedJobRetryTimeCycle`.
+Flow-node settings group into one mixin interface and input/output into a second, both as tagged unions so an illegal combination is unrepresentable: `JobSettings` carries the five job settings alone, and `EngineAttributes` is the wider mixin that adds listeners, for everything that keeps a textual identity to attach one to.
+The naming rule holds at the boundary: values are stored under plain names, and `irToXml` adds the `operaton:` prefix at the one point where it builds the moddle element.
+
+The engine reads the five off a gateway as it does off an activity.
+`BpmnParse.parseExclusiveGateway`, `parseInclusiveGateway`, `parseParallelGateway`, and `parseEventBasedGateway` each call `parseAsynchronousContinuationForActivity` for `asyncBefore`, `asyncAfter`, and `exclusive`, and `createActivityOnScope` for `jobPriority` through `parsePriority`, while `DefaultFailedJobParseListener.parseActivity` reads the retry cycle the same way.
+A gateway has no textual identity to hang an attribute on, so its five settings are authored in a parens on the head of the statement that synthesizes it, the split's under their plain spelling and the join's under `join` plus the spelling.
+One parens rather than two, because a statement already reads as one construct, and a second parens would ask an author to place a setting on a gateway they cannot see or name.
+Execution listeners stay off every gateway kind, since there is nothing there to attach one to.
+
+`parseEventBasedGateway` refuses `asyncAfter` on the gateway it deploys, so a multi-branch `await` head refuses the same setting and an import carrying it throws `UnsupportedEventFeatureError` rather than importing a document that never deployed.
+`joinAsyncAfter` is unaffected, since the join a race falls through to is an ordinary exclusive gateway.
+The printer recovers a gateway's settings onto the statement head it prints them under, and a gateway that reaches no head, an unstructured jump target or a degraded split among them, loses them under the `droppedSetting` warning.
+
+A timer's job settings follow the same rule, each written on the element the engine reads it from.
+`DefaultFailedJobParseListener.parseStartEvent`, `parseBoundaryEvent` and `parseIntermediateCatchEvent` read the retry cycle off the event element when its type is a timer, while `operaton:exclusive` on the event tag is read by `BpmnParse.parseAsynchronousContinuation` alone, which configures the async continuation job, and with no async flag there is no such job.
+A node carrying a timer therefore writes its `exclusive` on the `bpmn:timerEventDefinition` as well as on the event tag: the definition's copy locks the timer job, the tag's copy the continuation job an async flag creates.
+The moddle descriptor declares the attribute on the definition, so it parses as a typed boolean and the default `true` is omitted on write.
+
+A host-less `on timer(...)` handler lowers `jobPriority`, `retryCycle` and `exclusive` onto the trigger start event it synthesizes, and `asyncBefore` and `asyncAfter` onto the event sub-process, whose continuation job they create, since nothing that creates a timer job looks at the sub-process.
+The printer lifts the three back into the `on timer` head as long as the start prints no statement of its own, and leaves them on the `start` line when it does, so an authored start inside the body keeps its settings where it wrote them.
+Every other host-less handler writes its settings on the event sub-process.
+
+The process header's settings follow the same naming rule, and `historyTimeToLive` is the one with a default.
+`HistoryTimeToLiveParser.parseAndValidate` refuses a process without one under the engine's default `enforceHistoryTimeToLive`, so `irToXml` writes `P30D` when the IR names none, and the importer reads exactly that value back as absent to keep the round trip stable.
+An imported document that sets none is warned about, since its rebuilt process deploys where the source did not.
+`isStartableInTasklist` is the one that takes a boolean, and the importer reads it as `BpmnParse.isStartable` does, so any value but a case-insensitive `true` imports as `false`.
+
+### Consequences
+
+- Good, because the validator can restrict attribute keys per element kind, and the vendor prefix stays on the serialization boundary, so the IR reads the same regardless of whether the reader knows Operaton.
+- Good, because the IR stays a typed model: a field on a user task but not a start event is a compile error at every call site, and a round-trip comparison sees a field rather than a string to parse.
+- Good, because a file a real deployment produced imports its gateway settings instead of losing them, and the two `asyncAfter` refusals, on a wait's head and on import, agree with each other and with what Operaton itself refuses to deploy.
+- Good, because `exclusive: false` on a timer start, a hosted timer, an `await timer` and a host-less timer handler configures the job the timer creates, and a host-less `on timer` with `jobPriority` or `retryCycle` deploys a job that carries them.
+- Bad, because every engine setting is a mapping surface on all four transforms: a grammar key, a validator entry, a lowering, an XML mapping, an import read, and a printer case.
+- Bad, because a setting Operaton supports and the IR does not name is dropped with a warning rather than carried.
+- Bad, because ten keys sit in one parens on three statement kinds, so a reader has to know which five are the join's before knowing which gateway a value tunes.
+- Bad, because a gateway that never reaches a statement loses its settings on print; `droppedSetting` reports the loss rather than preventing it.
+- Bad, because a timer carrier with an async flag writes `operaton:exclusive` twice, and a document that sets the two apart imports with one of them changed and a warning.
+
+### Confirmation
+
+The `gateway-settings` golden pair under `tests/golden` carries all five plain and all five join-prefixed keys, both `parallel` element kinds included, and its suite asserts the settings survive DSL to XML to DSL and back; the frozen pair `tests/golden/engine-attributes.{bpmnscript,bpmn}` carries the lock on the hosted timer's definition.
+The validator table in `packages/language/test/validating.test.ts` pins the loop and `await` refusals and the pruned-join warning.
+`packages/transform/test/operaton-moddle.test.ts` pins the typed attribute on the definition and its omission at the default, and `packages/transform/test/ir-to-xml.test.ts` pins the definition and the tag both carrying the lock on a boundary timer and an await, and neither when none is written.
+`packages/transform/test/xml-to-ir.test.ts` pins the gateway carry, the `asyncAfter` refusal, the definition's value on a boundary and an event sub-process start, the tag's as the fallback, the one warning when the two differ, and the warning under which a timer-started event sub-process's own timer job key is dropped.
+`packages/transform/test/ast-to-ir.test.ts` and `packages/transform/test/ir-to-dsl.test.ts` pin the three settings on the synthesized start, the async flags on the sub-process, the lifted head, the authored start keeping its own line, and `droppedSetting`.
+
+## More Information
+
+Each field's serialized form is documented beside its declaration in `packages/transform/src/ir/types.ts`, where the five job settings sit beside `JobSettings`, and the prefix is applied only in `packages/transform/src/ir-to-xml.ts`.
+The join spellings are derived at `JOIN_KEY_BY_ENGINE_KEY` and the statements that take them are listed at `GATEWAY_STATEMENT_RULES`, both in `packages/language/src/vocabulary.ts`.
+The timer behaviour was read from `BpmnParse.parseTimer`, `BpmnParse.parseAsynchronousContinuation`, `BpmnParse.createActivityOnScope` and `DefaultFailedJobParseListener.parseStartEvent`.
+ADR-0007 sets the IR shape and the naming rule this decision applies.
diff --git a/docs/decisions/0022-engine-attributes-as-named-ir-fields.md b/docs/decisions/0022-engine-attributes-as-named-ir-fields.md
deleted file mode 100644
index 50a6598..0000000
--- a/docs/decisions/0022-engine-attributes-as-named-ir-fields.md
+++ /dev/null
@@ -1,74 +0,0 @@
----
-status: accepted
-date: 2026-08-27
-decision-makers: Marlon Kranz
----
-
-# Carry Operaton Engine Attributes as Named IR Fields
-
-## Context and Problem Statement
-
-The importer reports everything it cannot represent in the `extensionAttribute` warning category, and Operaton's execution settings all land there: `asyncBefore` and its siblings, job priority, the retry cycle, `operaton:inputOutput`, execution and task listeners, and every user-task assignment attribute beyond `assignee` and `formKey`.
-A process that round-trips through this tool comes out without them, which makes the tool unusable on a file a real deployment produced.
-None of these settings changes control flow.
-An async continuation decides where the engine commits a transaction, an input parameter decides what a delegate reads, and a listener decides what runs alongside a step.
-Carrying them needs no change to gateway synthesis, only somewhere to put roughly twenty values and a mapping on each of the four transforms.
-
-Where in the IR should these settings live, and under what names?
-
-## Decision Drivers
-
-- ADR-0006 fixed the IR's shape and its no-vendor-prefix naming convention, so twenty fields arriving at once has to fit that convention rather than be assumed to.
-- The validator checks attribute keys against element kind, and round-trip idempotence compares IR against IR, so a carried setting has to be a typed field a structural comparison can see, not opaque text.
-- Three settings are not flat: an input or output parameter's value can be a list, a map, or an inline script, and a listener carries an event plus one of four bindings.
-
-## Considered Options
-
-- Named vendor-neutral fields on the IR node
-- A generic extension bag keyed by qualified attribute name
-- Openly `operaton:`-prefixed field names
-
-## Decision Outcome
-
-Chosen option: "Named vendor-neutral fields on the IR node", because the IR already carries `assignee`, `formKey`, and the service-task and call-activity bindings this way, extending a precedent rather than amending one.
-
-Each setting becomes an optional field on the IR node that owns it, named for what it means rather than how it serializes, so the retry cycle is `retryCycle`, not `failedJobRetryTimeCycle`.
-`asyncBefore`, `asyncAfter`, and `exclusive` are stored only when they diverge from the engine's off/off/on defaults, and flow-node settings group into one mixin interface while input/output groups into a second, both as tagged unions so an illegal combination is unrepresentable.
-The naming rule from ADR-0006 still applies at the boundary: values are stored under plain names, and `irToXml` adds the `operaton:` prefix at the one point where it builds the moddle element.
-
-A gateway synthesized from an `if`, `while`, `do...while`, `parallel`, or a multi-branch `await` has no textual identity to hang an attribute on (ADR-0010).
-Since ADR-0040, its five job settings are authored on the statement head instead.
-
-### Consequences
-
-- Good, because the validator can restrict attribute keys per element kind, and the vendor prefix stays on the serialization boundary, so the IR reads the same regardless of whether the reader knows Operaton.
-- Good, because the IR stays a typed model: a field on a user task but not a start event is a compile error at every call site, and a round-trip comparison sees a field rather than a string to parse.
-- Bad, because every engine setting is now a mapping surface on all four transforms: a grammar key, a validator entry, a lowering, an XML mapping, an import read, and a printer case.
-- Bad, because a setting Operaton supports and the IR does not name is still dropped with a warning rather than carried (`operaton:field` is the current example).
-
-## Pros and Cons of the Options
-
-### Named vendor-neutral fields on the IR node
-
-- Good, because it extends what the IR already does for `assignee`, `formKey`, and the service-task binding, keeping one convention rather than two.
-- Good, because the type system does validation work directly: a listener's binding is a tagged union of four variants, so one with two bindings or none cannot be constructed.
-- Bad, because a setting nobody has named yet is not carried, so keeping up with the engine means editing code rather than widening a container.
-
-### A generic extension bag keyed by qualified attribute name
-
-- Good, because a map filled from import and written back verbatim would carry every Operaton attribute at once, including ones nobody has looked at.
-- Bad, because the DSL surface it implies is untyped: any key would compile, and a typo would produce an attribute the engine never reads and never complains about.
-
-### Openly `operaton:`-prefixed field names
-
-- Good, because fields like `operatonAsyncBefore` would let a reader see which fields are BPMN's own versus a single engine's, and a second engine could later be added without a name collision.
-- Bad, because it contradicts the naming rule ADR-0006 already set: `assignee`, `formKey`, and the service-task binding are engine settings under plain names today, so a prefix means renaming them or keeping two conventions in one file.
-
-## More Information
-
-Each field's serialized form is documented beside its declaration in `packages/transform/src/ir/types.ts`, and the prefix is applied only in `packages/transform/src/ir-to-xml.ts`.
-The import side records what is carried and what is still dropped in `packages/transform/src/xml-to-ir.ts` and `packages/transform/README.md`.
-
-Related decisions: ADR-0006 (IR shape and naming rule, applied here), ADR-0007 (the `operaton:` moddle extension), ADR-0008 and ADR-0010 (block-structured grammar and synthesized ids, which set the gateway limit), ADR-0023 (DSL spelling for listeners).
-
-Amended by ADR-0040, which gives the four gateway kinds the `JobSettings` mixin, authored on the statement head, so the gateway limit above holds for listeners alone.
diff --git a/docs/decisions/0022-repetition-the-for-clause-and-its-per-run-job-settings.md b/docs/decisions/0022-repetition-the-for-clause-and-its-per-run-job-settings.md
new file mode 100644
index 0000000..3a133e5
--- /dev/null
+++ b/docs/decisions/0022-repetition-the-for-clause-and-its-per-run-job-settings.md
@@ -0,0 +1,91 @@
+---
+status: accepted
+date: 2026-08-31
+decision-makers: Marlon Kranz
+---
+
+# Repetition: the `for` clause and its per-run job settings
+
+## Context and Problem Statement
+
+Any BPMN activity may carry a `bpmn:multiInstanceLoopCharacteristics` child, which makes the engine run it once per element of a collection or a fixed number of times.
+A grammar with no way to say so refuses such a document on import and stops the round trip at the first repeated activity.
+That element also carries job settings of its own.
+`BpmnParse.parseAsynchronousContinuationForActivity` hands a multi-instance activity's `bpmn:multiInstanceLoopCharacteristics` element to `parseAsynchronousContinuation`, so the three async settings written there mean one job per run, where the same settings on the activity element mean one job around the whole repetition.
+`DefaultFailedJobParseListener.parseActivity` reads a retry cycle off that element the same way, and a document a real deployment produced can carry any of the four on it.
+
+Where does the repetition sit on a statement, what does it spell, which shapes does the surface decline to carry, and where does a per-run job setting go?
+
+## Decision Drivers
+
+- A keyword binds to what the author means rather than to the BPMN attribute behind it.
+- An imported activity has to come back under the id the document gave it.
+- The import contract refuses what changes the run and warns only about drops that do not.
+- A Langium keyword is lexer-global, so a word spent on a clause is a name lost everywhere in a file.
+- Operaton decides what is legal here, and a surface stricter than the engine owes a reason.
+- The one bracket shape holds every scalar setting in a parens on the statement head, and a per-carrier prefix already tells a second carrier's settings apart inside one parens (ADR-0021).
+
+## Considered Options
+
+- A header clause between a statement's id and its settings, which reads as something done to one statement, which is what the engine does with it, and leaves every statement the settings it already had, at the cost of four keywords and a touch on every statement rule that emits an activity where a settings key would have touched none.
+- Settings keys, `times:`, `each:`, `as:`, and `sequential: true`, which name the BPMN attribute rather than what the author means, and cannot constrain what the clause constrains, since `as: "line"` with no `each:` is writable as a setting and is one of the deployments Operaton refuses.
+- A wrapping statement, `for each x in c { ... }`, which over more than one statement needs a synthesized sub-process to hang the repetition on, so an imported repeated user task comes back inside a container the document never had under an id this tool invented, and which restricted to one statement is this clause with a pair of braces around it.
+- One head for both a count and a collection, since the two are not exclusive in Operaton.
+- Two heads, `for each ... in ...` for a collection and `repeat ` for a count, which would refuse a legal document carrying both or need a third spelling for the combination.
+- The element variable required, which costs an error path and a test to reject content the engine deploys and runs.
+- The element variable optional, matching what the engine accepts.
+- `bpmn:standardLoopCharacteristics` imported as a step that runs once, which accepts every document Operaton deploys and runs, on every host the loop can sit on including an event handler, at the price of dropping the element the document wrote and keeping only a warning that names it.
+- `bpmn:standardLoopCharacteristics` refused, which rejects a document the engine deploys and runs exactly as if the element were absent.
+- Per-run job settings as four keys in the statement's own parens, prefixed `run`, reusing the bracket shape at no grammar cost and reusing the derivation a join gateway's spellings use, so the validator, the printer, and the importer read one function for both prefixes, at the price of a parens holding nine keys at once.
+- A second parens attached to the `for` clause itself, which would sit a run's settings next to the clause that creates the runs, but is a second bracket shape for four settings that already have a home elsewhere.
+- The per-run settings spelled inside the clause's own grammar, which is a phrase grammar rather than a keyed settings list, so folding four settings into it needs new syntax for each one.
+
+## Decision Outcome
+
+Chosen options: the header clause, one head, the optional element variable, importing a standard loop as a step that runs once, and four `run`-prefixed keys in the statement's own parens.
+
+The clause sits between the id and the optional settings on the statements that emit an activity, and `for`, `each`, `sequentially`, and `until` are hard keywords, because in that position only a keyword can tell the parser a clause has started.
+No file in the repo uses any of the four in an identifier position, and the four built into a live parser draw no ambiguity report from Chevrotain's self-analysis.
+
+One head carries both intents because Operaton allows both at once: `MultiInstanceActivityBehavior.resolveNrOfInstances` reads the cardinality first and falls through to the collection, and `evaluateCollectionVariable` still binds the element per run whenever a collection is set.
+Parallel is the unmarked form, because Operaton's `BpmnParse` defaults `isSequential` to false and this tool elides every attribute matching an engine default.
+An element variable is optional with a collection, since `for each in batches` deploys and runs, and refusing it for authoring convenience is the call ADR-0016 declined to make for a receive task with no message name.
+The whole construct is one optional field on the shape the activity nodes already share, so it reaches every kind without adding an arm to any exhaustive switch in the transform.
+
+`runAsyncBefore`, `runAsyncAfter`, `runExclusive`, and `runRetryCycle` sit in the same parens a repeated statement's own five settings already occupy, each spelled by `runSettingKey(key) = prefixedSettingKey('run', key)`, the same function a join gateway's spellings use with a different prefix.
+There is no `runJobPriority`: `createActivityOnScope` reads a job priority off the activity element alone, and `parseActivity` swaps the scope to the multi-instance body before `createActivityOnScope` stores it, so a repeated step's plain `jobPriority` already prices each run's job.
+No statement kind that repeats also synthesizes a gateway, so a `run`-prefixed key and a `join`-prefixed key never both sit in one parens.
+
+On the XML side the loop element carries the plain, unprefixed attribute names, since it is already its own element, distinct from the activity's, and only the DSL surface needs a prefix where the step's settings and the loop's share one parens.
+The moddle declaration makes the plain names legal and typed on that element in the first place: `AsyncCapable.extends` in `operaton-moddle.json` gains `bpmn:MultiInstanceLoopCharacteristics` beside `bpmn:FlowNode`, which upstream camunda-bpmn-moddle already declares there through `Collectable`'s own superclass, and without it the loop element's `asyncBefore` and friends parse into `$attrs` as untyped strings, invisible to every typed reader.
+The four therefore import back as the matching `run`-prefixed settings rather than refusing.
+
+Two refusals on import are this tool's limit rather than the engine's, and both exist because the printer has no second spelling to write the content back in.
+A `bpmn:loopCardinality` body that is neither a run of digits nor an expression refuses, since Operaton's `resolveLoopCardinality` reads `+3` as three while this printer writes a count only as a plain number or as an expression, and an `operaton:elementVariable` or `bpmn:inputDataItem` name outside the grammar's `ID` terminal refuses, since the clause writes the name each run sees as a bare identifier and an element named `größe` would come back as a file this language cannot parse.
+Everything Operaton's own parse rejects, a repetition with neither a count nor a collection among it, refuses rather than importing into a process that cannot deploy.
+
+### Consequences
+
+- Good, because a document the engine runs more than once imports, prints, and recompiles byte for byte, including one carrying async or retry settings on its multi-instance element.
+- Good, because no new IR shape was needed: `LoopCharacteristics` already existed, and every read, write, import, and print helper built for `JobSettings` applies to it unchanged, keyed by a different function.
+- Neutral, because a repeated step's own `asyncBefore` and `asyncAfter` buy one job around the whole loop, `parseAsynchronousContinuationForActivity` reading a host element's async attributes onto the repetition, while `runAsyncBefore` and `runAsyncAfter` in the same parens buy one job per run.
+- Neutral, because `operaton:collection` and `bpmn:loopDataInputRef` are one field to Operaton's `parseMultiInstanceLoopCharacteristics`, read in that order, so they import into one field here and the `operaton:` spelling is what gets written back.
+ The BPMN slot holds the text of a variable name rather than a reference to an element in the document.
+ `bpmn:inputDataItem` and `operaton:elementVariable` pair the same way, and a document setting both spellings of either pair gets a warning naming the one that was dropped.
+- Neutral, because a decompiled program declares `var : any` for every collection it iterates by name, one case of the rule that the print declares every variable it reads.
+- Neutral, because `nrOfInstances`, `nrOfActiveInstances`, `nrOfCompletedInstances`, and `loopCounter` are seeded into the process's variable table wherever something repeats, so `until (nrOfCompletedInstances >= 2)` validates with no declaration.
+ Operaton's `MultiInstanceActivityBehavior` sets `loopCounter`, and its two concrete behaviors set the three counters.
+- Bad, because four ordinary English words stop being available as identifiers anywhere in a file.
+- Bad, because a repeated statement's parens can hold nine keys, five plain and four `run`-prefixed, so a reader has to track which govern the whole loop and which govern each run.
+
+### Confirmation
+
+`packages/language/test/` pins every form of the clause token by token, its placement against the id and the settings, the output-parameter rule, the seeded variables, and what the position completes to, and the validator table in `validating.test.ts` pins the without-a-clause refusal, the `runJobPriority` refusal, and the repeatable kinds that admit the four `run` keys.
+`packages/transform/test/` pins the lowering, each refusal by error class and by a substring of its message, each shadowing warning, the emitted child, the loop element's written attributes, the import back into `LoopCharacteristics`, and the printed line.
+The frozen pair `tests/golden/repetition.{bpmnscript,bpmn}` carries every form of the clause across eight statements of one process and the four `run` keys on at least one repeated statement, and `tests/repetition.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all.
+`tests/e2e/repetition.test.ts` deploys to a real Operaton and drives it over REST, offering a parallel repetition's three runs at once and a sequential one's one at a time on the same activity, ending a repetition after two runs by completion condition, driving a service task by count, binding each element of a collection in turn, never running the step inside at a count of zero, and showing `runAsyncBefore: true` make the job executor create one job per run rather than one job around the whole loop.
+
+## More Information
+
+The clause's fields and the four job settings are declared beside `LoopCharacteristics` in `packages/transform/src/ir/types.ts`, `runSettingKey` and `RUN_ENGINE_KEYS` beside `JOIN_KEY_BY_ENGINE_KEY` in `packages/language/src/vocabulary.ts`, and the moddle declaration in `packages/transform/src/operaton-moddle.json`.
+ADR-0021 carries the per-carrier prefixing this decision reuses with a different prefix.
diff --git a/docs/decisions/0023-event-roots-from-usage-and-declared-codes.md b/docs/decisions/0023-event-roots-from-usage-and-declared-codes.md
new file mode 100644
index 0000000..9861740
--- /dev/null
+++ b/docs/decisions/0023-event-roots-from-usage-and-declared-codes.md
@@ -0,0 +1,120 @@
+---
+status: accepted
+date: 2026-07-17
+decision-makers: Marlon Kranz
+---
+
+# Event root elements derived from usage, and declared codes
+
+## Context and Problem Statement
+
+BPMN keeps an error or an escalation in two places.
+A `bpmn:Error` or `bpmn:Escalation` root element sits under `bpmn:Definitions`, one per code, carrying for errors the message text (`operaton:errorMessage`).
+Every start, end, or intermediate-throw event that catches or raises that code carries its own `errorEventDefinition` or `escalationEventDefinition` and references the root by id.
+Nothing about using a code in an event definition says that a root has to exist, so the bookkeeping sits one level up, in the document's root-element list, away from where the code is written.
+
+The audience writes processes, not BPMN documents, so that list is not theirs to maintain.
+A code still needs an identity the editor can address.
+A site that raises a code and a site that catches it are otherwise two string literals that happen to match, so there is nothing to jump to, nothing to rename, and a typo is a second code rather than a mistake.
+
+Two further properties of the event surface are ones BPMN's own vocabulary does not settle for a textual language.
+What makes a raised event end its path rather than notify and continue, and whether the words naming event kinds and catch-parameter fields have to be reserved in order to work.
+
+## Decision Drivers
+
+- Jump-to-definition, find-references, rename and completion are the contribution a textual DSL makes over a graphical editor, and a payload the language server cannot resolve is where that contribution stops.
+- A mistyped code has to be a diagnostic at the site that wrote it, rather than a second code the export writes a root for.
+- BPMN lets an event definition reference no root at all, a catch-all, so the surface must not force a code where the author wrote none and must not synthesize a root for a code that never resolves to one.
+- The round trip is byte-exact against frozen artifacts, so import has to reproduce the codes a document arrives with, including a code no identifier can spell and a root nothing raises, and a printed statement has to mean the same thing wherever it sits in its block.
+- Whatever resolves a code has to leave every other identifier alone.
+ A bare word in `condition(ready)` or in `if (amount > 100)` is a variable, and the undeclared-variable warning is the diagnostic its author needs.
+- Error and escalation are the first two event kinds, with message, signal, timer and conditional behind them, so the design should not spend a keyword, or accumulate a root-element obligation, that each later kind repeats.
+- Every element is spelled `verb Id(settings)`, so a declaration with a shape of its own would be the one construct left over.
+
+## Considered Options
+
+- Root elements derived from usage, deduped by code, so nothing about the document's root-element list reaches the author.
+- A `Definitions`-level IR root modeling `{ process, rootElements }` explicitly, which mirrors BPMN's own document structure with no derivation step to get wrong, but changes every consumer signature, the CLI, all four transforms and every existing test, to carry data the declaration already supplies, and still leaves something to create, id and look up root objects: the compiler, making the explicit model ceremony, or the author, reintroducing the registry management this decision exists to avoid.
+- A declaration in the process header that every use site references by a bare name, which gives a code a definition to jump to and one home for its message text.
+- No declaration, with the validator checking each site against the set of codes it collects from the document, which needs no scope provider, no linker wording and no filtering of link failures, and keeps a bare word a bare word to the rest of the toolchain, but produces a diagnostic and nothing else, where the editor features that motivate the design read the reference graph.
+- A quoted code at every use site, so a code used once costs one site and no more, but a string literal is invisible to the language server, so the editor features have no reference to read and a typo becomes a second code the export writes a root for.
+- A cross-reference alternative of its own in the shared paren rule, which would put the distinction in the grammar where a reader would find it in one place, but does not parse: `error(OUT_OF_STOCK)` and `condition(ready)` are the same tokens in the same position, and the arm made Chevrotain report `AMBIGUOUS_ALTERNATIVES` and resolve the ambiguity by parsing the condition as a reference.
+- The reference on `VarRef`, the rule every identifier in an expression already builds, with the two readings of a bare word separated by scope.
+- `throw error(...)` as the only spelling for raising an error.
+- A second spelling on the end statement, `end Finished error(...)`, beside the `terminate` and `cancel` an `end` already carries, which would keep a labeled error end its caption, since `end` carries a label and `throw` does not, but makes one IR shape carry two printed forms the printer has to choose between.
+- Terminality decided by the keyword, as a property of the word itself.
+- Terminality decided by statement position, last in a block compiling to an end event and anything else to an intermediate throw, which removes the `emit` keyword but is unsound under round trip.
+- `error`, `escalation`, `code` and `message` lexed as soft, validated identifiers.
+- The same four reserved as keywords, which would draw highlighting and completion from the generated grammar and the parser's keyword table with no provider code, but a Langium keyword is lexer-global, so reserving `message` for the event surface reserves it everywhere and breaks `var message: string` anywhere in a file, on an audience for whom `message`, `code` and `error` are ordinary variable names.
+
+## Decision Outcome
+
+Chosen: root elements derived from usage rather than modeled explicitly, a declaration in the process header that every use site references by a bare name, the reference carried on `VarRef` with the two readings of a bare word separated by scope, terminality decided by the keyword rather than by statement position, and trigger kinds and binding fields lexed as validated identifiers rather than keywords.
+Each keeps BPMN's own bookkeeping out of the author's hands wherever the compiler can carry it, and spends required syntax only where the alternative breaks round-trip fidelity, gives up a cross-reference, or collides with the audience's own variable names.
+
+An error or escalation code is declared once, beside the `var` declarations at the top of the process body, and named wherever it is raised or caught.
+The declaration is `verb Id(settings)` like every other element, so no construct is left over.
+`message` is the text a thrown error carries at runtime, the one piece of root data usage cannot supply, and a code no identifier can spell lives on the declaration as a `code` setting, so the use site is a bare name in every case and the awkward spelling is written once instead of at each of its uses.
+
+Root elements are derived, not modeled.
+`irToXml` walks the whole IR once, collects every distinct error and escalation code in use anywhere in the document, and synthesizes one `bpmn:Error` or `bpmn:Escalation` root per distinct code, wiring every event definition carrying that code to the same root through `errorRef` or `escalationRef`; a declaration adds one root for a code nothing raises.
+A definition without a code, a catch-all, gets no ref and contributes no root, because BPMN treats a ref-less catch as any error, so there is nothing for it to point at.
+Root ids are sanitized from the code and de-collided against the rest of the document, under the same rule a synthesized id follows (ADR-0010).
+
+The name and the code both reach BPMN, `@name` holding the declared name and `errorCode` or `escalationCode` holding the code, and they are the same text wherever the code is identifier-shaped.
+`operaton:errorMessage` lives on the root rather than at the throw site, because two throws sharing a code share one root and a message at the throw site would need a mechanism to reconcile disagreeing copies.
+Import reads the pair back off each root, taking the root's `name` where a declaration can be written with it and minting one from the code otherwise (`claimDeclarationName`, `packages/transform/src/synthesize-ids.ts`), so a root nothing raises imports as a declaration nothing uses.
+An imported document whose two roots share a code but disagree on the message is refused rather than merged, since collapsing them would change what a throw carries at runtime.
+
+The reference sits on `VarRef` rather than on an alternative of its own, and this was measured against the grammar rather than reasoned about: the parser cannot separate `error(OUT_OF_STOCK)` from `condition(ready)`, since both are one identifier inside the same `SettingsParens`.
+So every identifier in every expression builds the same node, and what separates a code from a variable is the scope it resolves in.
+`BpmnScriptScopeProvider` gives a bare word the enclosing process's declarations of its own kind in a code position, and `EMPTY_SCOPE` everywhere else.
+Holding the scope to one kind is what makes `escalation(X)` naming an error fail rather than reach the XML as a second root, since an error and an escalation are separate event definitions even under one code.
+Outside a code position the link fails by design, and `BpmnScriptDocumentValidator` drops that failure before it becomes a diagnostic, so an ordinary expression sees the undeclared-variable warning and nothing else.
+
+Throw and emit decide terminality by keyword, not by position, so `throw` always ends its path and `emit` always continues into whatever follows it.
+A single verb whose compiled form depends on being last in its block looks appealing, one fewer keyword, but it does not survive the round trip.
+Take an IR shape the importer must be able to reproduce, a branch that fires an escalation and rejoins the main flow, `split -> A -> escalation-intermediate-throw -> join`.
+Printed inside its branch block, that statement is the last statement of the block simply because the branch has nothing after it, not because the event ends anything.
+A position-decided desugarer reads "last in block", turns the statement back into an escalation end event on the way in, and drops the join edge the original graph had, so the same printed text would mean two different graphs depending on information the text itself does not carry.
+`throw` also matches the intuition every reader brings from exception handling in a general-purpose language, and both escalation forms, end event and intermediate throw, stay independently printable.
+
+`emit` is a general continuing verb, not an escalation-specific one, so error, which has no continuing BPMN form, has no `emit`, and later event kinds that fire and continue reuse the same verb instead of growing their own.
+Giving every kind its own throw-and-continue verb turns the surface into a vocabulary quiz.
+
+Trigger kinds and binding fields are validated identifiers, not keywords: `on`, `throw`, `emit` and `alongside` are the only words this event layer reserves.
+The reason is the audience, not parsing convenience.
+`message` and `code` are two of the most ordinary variable names in Java-style code, and a Langium keyword is lexer-global, so there is no way to reserve a word only inside `on`, `throw` and `emit` and leave it free everywhere else.
+What a keyword gives away for free is rebuilt explicitly, each part in the mechanism meant for it: a validator checks each word against its small legal set and names the options directly in the diagnostic, a semantic-token provider marks exactly the AST properties that carry meaning, so `on error` highlights `error` while `var error` stays plain, and completion items are offered at the same positions.
+This also pre-pays the next event kinds, since `message` and `signal` become validated trigger values at no additional reserved-word cost.
+
+### Consequences
+
+- Good, because a code has a definition to jump to, a name to rename across every site at once, and a completion list wherever one is legal.
+- Good, because an unresolved code is an error naming the declaration to add, rather than a warning about an undeclared variable that reads as though the author meant a variable.
+- Good, because every user of one code shares exactly one root element, so a code's message text has exactly one place to live, never several that could disagree.
+- Good, because a `bpmn:Error` or `bpmn:Escalation` root nothing raises survives the round trip as a declaration, which a design deriving roots from usage alone has nowhere to put.
+- Good, because a code no identifier can spell has one home, so a document arriving with `order.failed` round-trips without that text appearing at every site that raises it.
+- Good, because terminality is a property of the word printed, readable without looking at what follows in the block.
+- Good, because the soft-word design costs zero additional reserved words when the message and signal triggers arrive.
+- Bad, because every code needs a declaration line, so an author who writes a single `throw error(...)` in a five-step process pays a header line for one use.
+ The cost is accepted rather than argued away: it buys editor behaviour for a code with several sites, and it buys nothing at all for a code with one.
+- Bad, because a name and a code can differ, so a reader of `throw error(OrderFailed)` cannot see the text the engine matches on without reading the header.
+- Bad, because the grammar cannot carry the distinction the surface makes, so it is spread over a scope provider, a linker that rewords the failure, and a document validator that drops the failures the author never asked to resolve.
+ A reader looking for why `condition(ready)` is not a code has three files to find rather than a grammar rule.
+- Bad, because a catch-all definition contributes no root element to inspect, so there is no single element a tool can point to for "this code, however it is caught".
+- Bad, because the soft-word design moves work from the grammar into four separate mechanisms, validator, semantic tokens, completion and parser-error messages, that all have to agree on the same two small word sets.
+- Bad, because a document whose two roots for one code disagree cannot be imported at all, even when the disagreement reads as cosmetic to a human ("Payment declined" versus "Payment was declined").
+ The importer has no way to tell a cosmetic disagreement from a meaningful one, so it refuses both alike.
+
+### Confirmation
+
+`packages/transform/test/ir-to-xml.test.ts` asserts the derivation directly: a document using one code from a handler, a throw, and a catch-all handler produces exactly one root element referenced by every coded use and none by the catch-all, with dedicated cases for id sanitization and collision suffixing.
+`packages/transform/test/ast-to-ir.test.ts` and `ir-to-dsl.test.ts` pin the branch-tail counterexample as a round-trip case: an intermediate escalation throw as the last statement of a branch block prints as `emit` and re-imports as the same intermediate throw, never an end event.
+`packages/language/test/validating.test.ts` pins the soft-word behaviour, `var message: string` and a task named `error` validating cleanly while an unrecognized trigger word produces the options-naming diagnostic, and `scoping.test.ts` pins the scope in both directions, a bare word in a code position resolving to a declaration of its own kind alone and the same word in an `if` condition resolving to nothing.
+The golden pair `tests/golden/event-handlers.{bpmnscript,bpmn}` carries a declaration whose code no name can spell, `error GatewayTimeout(code: "gateway.timeout")`, and a declaration nothing raises or catches, `error STOCK_UNAVAILABLE(...)`, and both gates see them, the byte comparison against the frozen `.bpmn` and the IR-equality comparison across `DSL -> IR -> XML -> IR -> DSL` (`tests/event-handlers.round-trip.test.ts`).
+
+## More Information
+
+ADR-0007 keeps vendor- or serialization-only data at the IR-to-XML boundary rather than in the IR; `errorDecls` and `escalationDecls` are the one exception, and they are an exception because they cannot be derived from usage, not because the boundary rule was relaxed.
+Langium's scope provider, linker and document validator are the three seams the bare-name reference uses, and the reason the soft-word design rebuilds highlighting and completion as explicit language-server providers rather than through the keyword table.
diff --git a/docs/decisions/0023-listeners-on-the-attribute-block.md b/docs/decisions/0023-listeners-on-the-attribute-block.md
deleted file mode 100644
index 040fff5..0000000
--- a/docs/decisions/0023-listeners-on-the-attribute-block.md
+++ /dev/null
@@ -1,87 +0,0 @@
----
-status: accepted
-date: 2026-08-27
-decision-makers: Marlon Kranz
----
-
-# Listeners Reuse `on` Inside the Attribute Block, Not a Second Keyword
-
-## Context and Problem Statement
-
-Operaton runs user code at points in an element's lifecycle that never appear as flow nodes.
-An execution listener fires when a node starts or ends.
-A task listener fires on one of several user task lifecycle events, and one of them, `timeout`, carries its own timer.
-Each listener names its event and exactly one binding, a class, an expression, a delegate, or a script.
-That is the same exactly-one rule a service task binding already carries (ADR-0021).
-
-BPMNscript already uses `on` to catch a BPMN event.
-`on error(E409) { ... }` guards a block (ADR-0016), and a boundary form docks that catch onto one activity (ADR-0019).
-A listener is not a caught event.
-It never appears in the diagram, and it fires on the element's own lifecycle rather than on something the process throws.
-Writing it as `on create(class: "...")` spells that lifecycle callback with the same word a catch uses, so one word now covers two ideas.
-
-This decision settles whether the listener surface earns its own keyword or reuses `on` and accepts that ambiguity.
-A host-less handler also lowers to two BPMN elements, so a second question follows: which one carries the engine attributes written on it?
-
-## Decision Drivers
-
-- Every reserved keyword is a name an author permanently loses for a variable or a step, and `listener` is a name a real process might want (ADR-0016).
-- A listener's shape is fixed by the engine and checkable while typing: one event from a finite set, exactly one binding, a timer only when the event is `timeout`.
-- Whatever the compiler reads has to print back as the same model, and the import contract commits this tool to warning rather than dropping what it cannot write (ADR-0014).
-- The timer clause, the fenced script block, and the `class`, `expression`, and `delegate` keys exist, so reusing them costs less than specifying new forms for listeners.
-
-## Considered Options
-
-- Reuse `on` inside the attribute block, letting position decide whether it is a listener or a caught event
-- Add a distinct `listener { ... }` keyword
-
-## Decision Outcome
-
-Chosen option: "Reuse `on` inside the attribute block", because `on` already reads as "when this happens, run that" everywhere it appears.
-The alternative buys its clarity with a permanently reserved word.
-
-The event word is a soft word, like a trigger word.
-`create`, `assign`, `complete`, `update`, `delete`, and `timeout` lex as identifiers, and the validator checks each against the element kind it was written on.
-`start` and `end` lex as statement keywords instead, so the grammar names those two as the listener event position's exceptions.
-A `timeout` listener carries a timer of its own, written as an `after`, `at`, or `every` particle and a time, for example `on timeout after "PT8H"(delegate: "${escalationHandler}")`.
-Its parens hold `class`, `expression`, or `delegate` under the same exactly-one rule a service task binding carries.
-It can replace them with a fenced script tagged with a language instead, exactly as a script task's body is written.
-An `on` clause becomes an execution listener or a task listener by its event word alone, since the two event sets never overlap.
-On a user task, `on end` registers an execution listener and `on complete` registers a task listener, so the surface never has to spell the distinction the XML draws between `operaton:executionListener` and `operaton:taskListener`.
-
-Engine attributes on a handler statement land on the element that survives printing.
-A hosted handler lowers to one `bpmn:boundaryEvent`, so there is no choice.
-A host-less handler lowers to two elements instead, a `bpmn:subProcess` and a nested trigger `bpmn:startEvent`, and the attributes go on the sub-process because the synthesized start event is elided on print.
-
-### Consequences
-
-- Good, because no word is reserved.
-- Good, because the timer clause, the script block, and the three binding keys are reused as they stand.
- Highlighting, completion, and the binding diagnostic extend to listeners for free.
-- Good, because a listener's binding is a list of its own rather than the members every other element carries.
- A listener still cannot nest a form, an input or output parameter, or another listener.
-- Bad, because a reader must check the enclosing context to know which `on` they are looking at: inside a task's braces it is a callback, at statement position it is a catch.
-
-## Pros and Cons of the Options
-
-### Reuse `on` inside the attribute block
-
-- Good, because the language names one idea once: `on error` and `on create` both mean "when this happens, run that."
-- Good, because the timer clause and the four bindings carry over unchanged.
-- Good, because nothing is reserved, so `listener`, `create`, and `timeout` stay free for a step or a variable name.
-- Bad, because the meaning of `on` becomes positional, so a reader must check what encloses the line before knowing what it does.
-
-### Add a distinct `listener { ... }` keyword
-
-- Good, because the word alone says the block is an engine callback, not a flow node.
-- Good, because it keeps apart two things BPMN itself keeps apart: a caught event is a flow element, a listener is an extension on one.
-- Neutral, because it would still borrow the timer clause and the binding keys.
-- Bad, because it spends a reserved word on an idea the language already has a word for, and every file pays that cost whether or not it registers a listener.
-
-## More Information
-
-The intermediate representation carries a listener as an event plus a four-way tagged binding mirroring `ServiceTaskBinding`.
-A `timeout` task listener also carries the timer the event layer already models.
-Amended by ADR-0032, which gives a listener's own brace block a field member alongside its bindings, because a field is none of the form, parameter, or nested-listener shapes this decision ruled out.
-
-Related decisions: ADR-0016 (soft words), ADR-0017 (the timer particle clause), ADR-0019 (the hosted form), ADR-0021 (the exactly-one binding rule), ADR-0022 (engine attributes as named IR fields), and ADR-0014 (the import contract this surface honors).
diff --git a/docs/decisions/0024-event-trigger-payloads-paren-slot.md b/docs/decisions/0024-event-trigger-payloads-paren-slot.md
new file mode 100644
index 0000000..4a99baa
--- /dev/null
+++ b/docs/decisions/0024-event-trigger-payloads-paren-slot.md
@@ -0,0 +1,76 @@
+---
+status: accepted
+date: 2026-07-18
+decision-makers: Marlon Kranz
+---
+
+# Event trigger payloads in one paren slot
+
+## Context and Problem Statement
+
+The event layer's `on` handler already hosts two trigger kinds, error and escalation, sharing one payload shape: an optional code string and an optional parenthesized binding list.
+Four more trigger kinds join the same `on` surface, message, signal, timer, and conditional, and between them they need three payload shapes the existing rule does not have.
+Message and signal carry a name string, structurally identical to error/escalation's code.
+Timer carries something different: a word choosing which of BPMN's three timer forms applies, followed by a time expression.
+Conditional carries a boolean expression, which wants the same parenthesized slot error/escalation already use for bindings, but for an unrelated purpose: a condition, not a catch parameter.
+
+Fitting three payload shapes into one handler surface, without forking `on` into a separate grammar rule per trigger kind and without reserving a new keyword for each shape, is the central question this decision resolves.
+Two narrower questions ride along with it: how a timer's payload text becomes one of BPMN's three timer definition kinds without silently picking the wrong one, and how much of BPMN's conditional-evaluation narrowing the surface should expose given that the DSL's audience has no reason to know it exists.
+
+## Decision Drivers
+
+- One `on` handler surface has to host all six trigger kinds without forking into six grammar rules or growing a reserved word for every new payload shape, and the soft-word design already in place for trigger names should not be reopened to make room for new payloads.
+- A condition is a real expression, not a string.
+ It needs the same round-trip and variable-participation guarantees the language's `if` conditions already have, not a demotion to opaque text that a validator, a symbol table, or a highlighter cannot see inside.
+- A timer's payload must resolve to exactly one of BPMN's three time-definition kinds.
+ Because a wrong resolution changes what the engine schedules, not just how the text reads, guessing the kind from the value's shape is a real correctness risk, not a cosmetic one.
+- Message and signal need something for the engine to key its subscriptions on, and BPMN keeps that identity in root elements the same way it already does for error and escalation codes, so whatever the surface settles on must not reintroduce the declare-before-use boilerplate the language otherwise avoids.
+- Camunda's conditional-evaluation narrowing attributes change when a condition is checked, so a surface that cannot express them would turn a silent drop on import into a behavior change.
+
+## Considered Options
+
+- One paren slot holding either a binding list or a condition, disambiguated structurally, which needs no new word and keeps the parameter-list reading the binding shape was built for.
+- A binding-marker keyword freeing the parens for bindings only, writing them after a marker such as `as (...)`, which spends a new reserved word on a collision structural lookahead already dissolves for free and breaks the parameter-list reading (`on error(X, code: c, message: m)`, read like `catch (Exception e)`) that motivated the binding shape in the first place.
+- A string-wrapped condition, written as a quoted literal instead of a real expression, which reads worse than the language's own `if (...)` and throws away everything a real expression AST buys, including undeclared-variable checking, type checking, symbol participation, and precise highlighting.
+- Reserving `condition` as a keyword to dispatch the parens, which would be a lexer-global reservation, unusable as an ordinary variable name anywhere in a file, for exactly the reason the trigger and binding words already stay soft identifiers.
+- Timer particles, a bare string for a duration and the keys `at` and `every` for a date and a cycle, as validated identifiers mapping one-to-one to BPMN's three timer forms, which read as the English they mean and cannot silently resolve to the wrong form.
+- A single particle with the timer form inferred from the value's shape, where an ISO date looks like a date and a duration string looks like a duration, which shifts a scheduling decision onto pattern-matching the value's text: a mistyped ISO string, or an expression the engine evaluates at runtime rather than a literal the compiler can inspect, becomes a different timer kind than the one the author meant, with no diagnostic pointing at the mismatch.
+- Deferring the conditional narrowing attributes and refusing them on import rather than dropping them.
+- Surfacing them as a fourth payload shape, narrower than the other three and carrying nothing but an engine-side optimization hint the audience has no reason to know about.
+- Dropping them silently on import, which would leave the imported process re-checking its condition on every variable change instead of the one the original author scoped it to.
+- Message and signal roots derived from usage, keyed by name, with no declaration form, since the name is both the root's only data and its key.
+- An explicit root declaration mirroring the error message declaration, which generalizes a mechanism that exists only because the error message text has no other source to two triggers for which usage already supplies every property in full.
+
+## Decision Outcome
+
+Chosen: one paren slot disambiguated structurally, timer particles mapping one-to-one with no inference, conditional narrowing deferred and refused rather than dropped, and message and signal roots derived from usage.
+Each keeps the same principle: spend a new payload shape or a new reserved word only where the alternative loses something the surface cannot get back, whether round-trip fidelity, execution-accurate scheduling, or truthful runtime semantics, and nowhere else.
+
+The parentheses after a trigger word are the settings parens every statement carries, and the code or name, the catch bindings, a condition and the engine settings all sit in them.
+Which one an item is gets decided structurally, not by which trigger word precedes it.
+An item written `key: value` is a setting, and a catch binding is one, the binding field as its key and the variable as its value: `on error(PAYMENT_FAILED, code: c, message: m)`.
+An item with no key is the payload, a code, a quoted name or a condition expression such as `condition(ready)`.
+No expression opens with an identifier followed by `:`, since the expression sub-language uses `:` only after the `?` of a conditional, so the parser settles the item's kind before any per-trigger legality is considered, and whether a given trigger may use bindings, a condition, or neither is the kind of position rule the validator already owns for every other soft word.
+
+A timer's payload picks one of BPMN's timer forms, a duration, a fixed date, or a repeating cycle, by how the time string is written: bare it is a duration, `timer("PT1H")`, and under the keys `at` and `every` a date or a cycle, `timer(at: "2026-08-01T09:00:00")`.
+The two keys are plain identifiers, checked in position by the validator like every other soft trigger word, not keywords.
+A task listener's `timeout` writes the same choice as a word before the string, `after`, `at` or `every`, as in `on timeout after "PT8H"`.
+
+BPMN's conditional event definition can additionally narrow when a condition is re-evaluated, to a named variable or a specific kind of variable change, rather than re-checking on every change.
+This narrowing is an optional, engine-side evaluation optimization: a handler behaves correctly without it, re-checking on any variable change instead of a specific one, so the surface does not expose it.
+Because the attributes change when the engine re-checks a condition, they are runtime semantics rather than cosmetic metadata, so importing a document that carries them refuses rather than dropping them, and the refusal names the attribute so a future surface has somewhere to land it.
+
+A message or signal root carries exactly one piece of information the engine cares about: its name, which is also the identity the engine keys its subscriptions and correlations on.
+Because the name is both the only data and the natural key, the root is fully derivable from wherever the name is used, whether a handler, a throw, or an emit, with no separate declaration form and no new field on the process's structure, and every use of the same name anywhere in the document resolves to the same root.
+This differs from the error and escalation declarations ADR-0023 settles: a code is declared in the process header and named at each of its sites, which gives it a cross-reference and gives its thrown message one place to live, since two throw sites sharing a code might disagree on wording.
+A message or signal name has neither need, so no declaration form is introduced for one, and ADR-0023's tooling argument would apply to a message and a signal name too, which is a decision of its own and is not taken here.
+
+### Consequences
+
+- Good, because none of the four decisions spends a new reserved word: message, signal, timer, and conditional slot into the existing soft-word and paren machinery at zero reservation cost, the same guarantee the error/escalation design already made for the trigger set as a whole.
+- Good, because one verb pair, `throw` and `emit`, continues to span every trigger kind that has a throw form at all.
+ Signal joins error and escalation under the same rule instead of growing its own vocabulary, so the reader who has learned the rule once for error and escalation does not have to relearn it for signal.
+- Good, because the condition payload stays a real, checkable expression rather than an opaque string, so a condition handler gets the same variable checking, symbol participation, and highlighting an `if` statement already gets.
+- Bad, because a document carrying the conditional narrowing attributes cannot be imported at all, even though the underlying evaluation difference is invisible to a reader who does not already know Camunda's narrowing mechanism exists.
+ The refusal is correct but reads as stricter than the visible difference suggests.
+- Bad, because timer's three particles are one more small vocabulary to learn than a single particle would have been, paid once in exchange for a scheduling decision that cannot silently resolve to the wrong timer kind.
diff --git a/docs/decisions/0024-event-triggers-on-start-end-and-throw.md b/docs/decisions/0024-event-triggers-on-start-end-and-throw.md
deleted file mode 100644
index 67c0f64..0000000
--- a/docs/decisions/0024-event-triggers-on-start-end-and-throw.md
+++ /dev/null
@@ -1,154 +0,0 @@
----
-status: accepted
-date: 2026-08-29
-decision-makers: Marlon Kranz
----
-
-# Event Triggers at the Process Boundary: Four Start Kinds, `terminate` on `end`, and the Message End on `throw`
-
-## Context and Problem Statement
-
-Every event trigger this language writes is caught or raised inside a process that is already running.
-No trigger reaches the two positions that bracket a process.
-So a correlated order, a broadcast, and a nightly schedule all have to be modeled outside the process they start.
-A branch that decides nothing else should continue cannot stop a sibling parked on a user task.
-Seven BPMN elements sit in that gap: a message, signal, timer, or condition start event, a terminate end event, a message end event, and a message intermediate throw.
-Where does each one land, given that `start`, `end`, `throw`, and `emit` could all plausibly carry the clause?
-And which of them does Operaton actually execute?
-The specification is more permissive at the start position than the engine is, in a direction that produces no error.
-
-## Decision Drivers
-
-- One IR shape has to print in exactly one form.
- Otherwise the printer arbitrates on every decompile by a rule invisible to whoever reads the result.
-- The honest import contract (ADR-0014) refuses what changes the run and warns only about drops that do not.
- So Operaton's parser rather than the BPMN specification decides what is worth emitting.
-- Whatever import accepts, the decompiler has to be able to write back.
-- Every trigger word is a soft identifier (ADR-0016), so a new clause stays unambiguous by token position alone.
- A rule that reads unambiguously to a human is not evidence.
-
-## Considered Options
-
-- `throw message("Name")` and `emit message("Name")` for the message end and the message intermediate throw
-- `end E message("Name")`, the message end as a clause on the end statement
-- `end terminate(label: "...")` for the terminate end event
-- `throw terminate`
-- Message, signal, timer, and condition as the triggers a start event carries
-- Every trigger the specification allows on a start event, adding error, escalation, and compensation
-- A start trigger only on a process's own start event
-- A start trigger on any start event, including a sub-process's and an event handler's
-
-## Decision Outcome
-
-Chosen options: `throw message("Name")` and `emit message("Name")` for the message end and the message intermediate throw.
-`end terminate(label: "...")` for the terminate end.
-Message, signal, timer, and condition as the triggers a start event carries.
-A process's own start event as the only position that may carry one.
-The terminate is the one place here where the option costing no grammar was not taken.
-`end` carries the label a diagram's terminate node needs, and `throw` drops one.
-
-`end E message("Name")` still parses, deliberately.
-A kind belonging on `throw` earns a validator message naming the statement to write, instead of a parse error (`endTriggerMessage`).
-The validator refuses the two illegal start positions the same way, each with its own wording (`checkStartEvent`, `packages/language/src/bpmn-script-validator.ts`).
-
-The accepted set is `START_TRIGGERS` (`packages/language/src/vocabulary.ts`).
-Import refuses what lies outside it with wording about the degradation rather than about the element type (`IGNORED_START_SUBJECTS` and `readStartTrigger`).
-A conditional start is accepted rather than refused, because the engine dispatches on it where it ignores an error, escalation, or compensation one, so the set that stops where the engine stops includes it.
-
-A container has one start event, already implied by requiring an explicit `start` to be the first statement of its body (`checkStartPosition`).
-A modeler document carrying two of them is refused by name and by count (`refuseMultipleStartEvents`, `packages/transform/src/xml-to-ir.ts`).
-
-Neither clause needs a new reserved word.
-`name=ID` is mandatory and comes first in both the `StartEvent` and `EndEvent` rules (`packages/language/src/bpmn-script.langium`).
-The second token therefore decides everything: an `ID` is the trigger, a `(` the settings, and a `{` the members.
-Nothing else can appear there, because every statement in this grammar is keyword-led.
-Both rules were built into a live Langium parser with Chevrotain's self-analysis on.
-The generator reported no ambiguity.
-`terminate` and the four start words therefore stay soft identifiers, and `var terminate: string` parses beside `end Done terminate`.
-
-One class of name cannot be imported at all, for a lexical rather than a semantic reason.
-`RAW_TEMPLATE` is declared before `STRING` (`packages/language/src/bpmn-script.langium`), so a quoted name opening with `${` lexes as a raw expression.
-Import refuses such a name wherever a throw or catch position resolves its root (`resolveNamedRootRef`, `packages/transform/src/xml-to-ir.ts`).
-The diagnostic names the `#{...}` form that survives.
-A message start refuses both spellings, because Operaton rejects an expression anywhere in that name.
-
-### Consequences
-
-- Good, because the message end and the message intermediate throw cost no grammar and read like the signal end and signal emit beside them.
- One IR shape keeps one printed form.
-- Good, because the four start triggers reuse the payload surfaces and name-keyed roots ADR-0017 already built.
- The set stops where the engine stops, so an import either runs as it reads or refuses and names the element that stopped it.
-- Bad, because a thrown message carrying a send implementation refuses on import rather than importing without it.
- `operaton:class`, `expression`, `delegateExpression`, `type`, `topic`, and an `` child are what make the engine really send the message.
- Dropping one would turn a real send into a no-op.
-- Bad, because a terminate end always prints, even when its id was synthesized (`isElidedOnPrint`, `packages/transform/src/ir-to-dsl.ts`).
- A modeler document decompiles to `end EndEvent_1 terminate`, and re-parsing reports the reserved prefix (ADR-0010).
-- Bad, because a branch that terminates does not rejoin.
- An `if` whose branch ends at a terminate prints as a `goto`, and a `parallel` whose branch terminates leaves its join with one incoming flow (`joinContinuation`).
-
-### Confirmation
-
-`packages/language/test/parsing.test.ts` and `validating.test.ts` pin the two clauses token by token and every diagnostic behind them.
-`packages/transform/test/xml-to-ir.test.ts` pins each refusal by error class and message.
-`ast-to-ir.test.ts` and `ir-to-dsl.test.ts` pin the lowering and the printed line for every authored shape.
-`packages/cli/test/decompile-contract.e2e.test.ts` pins that a refused trigger exits nonzero and writes no output file.
-The frozen pair `tests/golden/event-positions.{bpmnscript,bpmn}` holds a message start, an `emit message`, a `throw message`, and a terminate end.
-`tests/event-positions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all.
-`tests/new-constructs.round-trip.test.ts` carries the timer, signal, and conditional starts.
-`tests/e2e/event-positions.test.ts` deploys three processes to a real Operaton through Testcontainers.
-A correlated message with no instance to aim at creates one, a broadcast signal creates one with both branches active, and a timer parks a job.
-The terminate cancels a sibling branch parked on a user task, and both thrown messages pass the token through to completion.
-That last result is what shows an implementation-free message throw deploys and runs.
-
-## Pros and Cons of the Options
-
-### `throw message("Name")` and `emit message("Name")`
-
-- Good, because both statements already parse a trigger word, an optional name, and a quoted code, so it needs no grammar.
-- Bad, because an id matching the synthesized shape drops off a printed throw, so a `goto` cannot target a nameless message end.
-
-### `end E message("Name")`
-
-- Bad, because one IR shape would carry two printed forms, and the printer would have to choose per kind.
-
-### `end terminate(label: "...")`
-
-- Good, because `end` carries a label, so a diagram's terminate node keeps its caption through a round trip.
-- Good, because it says what the element does, which is to end rather than to raise.
-
-### `throw terminate`
-
-- Bad, because `throw` means raise this event and end this path, and a terminate raises nothing and ends every path.
-- Bad, because `throw` drops a label, so importing a labeled terminate would warn about a caption a diagram carries.
-
-### Message, signal, timer, and condition on a start
-
-- Good, because these are the four the engine builds a start behavior for, so what is written is what runs.
-
-### Every trigger the specification allows on a start event
-
-- Bad, because Operaton's `BpmnParse` ignores an error, escalation, or compensation definition in `parseProcessDefinitionStartEvent` and builds a `NoneStartEventActivityBehavior` instead.
-- Bad, because such a document would import and print without a diagnostic, then start unconditionally.
-
-### A trigger only on a process's own start
-
-- Good, because Operaton rejects a trigger on an embedded sub-process start, so accepting one would produce an undeployable file.
-- Good, because an event handler's trigger has exactly one home, the `on` header, so nothing competes for it.
-
-### A trigger on any start
-
-- Bad, because a sub-process start carrying one is a deployment failure rather than a degradation.
-
-## More Information
-
-ADR-0026 (task kinds on the authoring surface) supersedes the message-throw implementation refusal recorded above: Operaton reads that implementation off the `bpmn:messageEventDefinition`, so the same attributes on the event itself are inert.
-
-Amended by ADR-0034, which lifts the one-start-per-container rule for a process body and leaves it standing for a subprocess, a transaction, and an event handler.
-
-Related decisions: ADR-0016 (soft trigger words and the `throw`/`emit` terminality rule).
-ADR-0017 (the payload surfaces the four start triggers reuse, including the timer mapping).
-ADR-0014 (the honest import contract behind every refusal here).
-ADR-0013 (the reason a start trigger is a clause rather than a declaration).
-ADR-0020 (the live-parser ambiguity check and the rejected `await ` shape).
-ADR-0009 (the restructuring that prints a terminating branch as a `goto`).
-ADR-0010 (the reserved synthesized-id prefixes a printed terminate end meets).
diff --git a/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md b/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md
deleted file mode 100644
index ca62b85..0000000
--- a/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md
+++ /dev/null
@@ -1,169 +0,0 @@
----
-status: accepted
-date: 2026-08-30
-decision-makers: Marlon Kranz
----
-
-# Inclusive and Event-Based Gateways: Conditioned `parallel` Branches, a Multi-Branch `await`, and No Gateway Label
-
-## Context and Problem Statement
-
-Every BPMN element the language has taken on so far had a construct to hang off.
-`bpmn:inclusiveGateway` and `bpmn:eventBasedGateway` have nothing to attach to, because under ADR-0008 no author ever writes a gateway.
-The desugarer synthesizes every gateway from block structure, and the decompiler elides every one it matches.
-That elision is what makes the round trip idempotent, and the grammar states the premise in its own header (`packages/language/src/bpmn-script.langium`).
-Both kinds are refused on import today (`mapContainerChildren`, `packages/transform/src/xml-to-ir.ts`).
-
-So the question is not which keyword spells "inclusive gateway".
-It is whether block structure can express the intent well enough that the compiler still picks the element.
-A gateway's `name` survives XML to IR to XML, then dies at the print hop, where no gateway has a statement form to carry it (`normalizeContainer`, `tests/helpers/normalize-ir.ts`).
-
-## Decision Drivers
-
-- ADR-0013 binds two rules: a keyword names what the user means rather than the BPMN element, and the compiler must not require what it can infer.
-- Gateway elision is what ADR-0009's decompiler relies on for idempotence.
- A gateway with textual identity gains an authored id and stops round-tripping under ADR-0010's positional scheme.
-- Operaton does not police the two kinds equally: an inclusive gateway gets no structural validation, an event-based gateway a list of them.
-- Trigger words are soft identifiers by design (ADR-0016), so a rule proves nothing until a live parser accepts it.
-- ADR-0014's contract covers `xmlToIr` only, and `irToDsl` returns a bare `string` (`packages/transform/src/ir-to-dsl.ts`).
- A drop on the way out has no channel to be reported through.
-
-## Considered Options
-
-- For the inclusive split, conditioned `parallel` branches headed by `if` and `else`
-- For the inclusive split, branches headed by a bare parenthesized condition
-- For the inclusive split, a separate construct with its own reserved keyword
-- For the fallback branch, the compiler routes the default flow to the join when no `else` is written
-- For the fallback branch, a written `else` required whenever every other branch is conditioned
-- For the race, a multi-branch `await` opened by a brace instead of a trigger word
-- For the race, a distinct keyword such as `race`
-- For the race's join, a synthesized exclusive join the block falls through
-- For the race's join, self-contained branches that rejoin by `goto`
-- For the gateway label, no slot, with the print-side drop reported through a warnings channel out of `irToDsl`
-- For the gateway label, a slot on `if`, `while`, `parallel`, and the two new forms
-- For the gateway label, no slot, and the drop stays silent
-- A `gateway` keyword, or a flat node-and-edge escape hatch
-
-## Decision Outcome
-
-Both gateways stay fully synthesized and fully elided.
-Nothing in the surface names a gateway, and ADR-0010's positional ids keep working unchanged.
-Every form below parsed with zero ambiguity warnings in a live Langium parser with Chevrotain self-analysis on.
-
-A branch of a `parallel` block may be headed by `if (condition)`, by `else`, or by nothing at all.
-The compiler picks the element: no condition anywhere means `bpmn:parallelGateway`, and a condition on any branch means `bpmn:inclusiveGateway` on the fork and the join.
-
-```bpmnscript
-process expense-approval {
- var amount: number
- var repeatCustomer: boolean
-
- parallel {
- if (amount > 10000) { user Audit }
- if (repeatCustomer) { service RecordReceipt(topic: "receipts") }
- else { user ManualTriage }
- }
-}
-```
-
-Operaton takes every non-default flow that has no condition or a true one (`InclusiveGatewayActivityBehavior.java:64-68`), and adds the default only when that set comes out empty (lines 71-83).
-The default flow is always emitted and never asked for.
-With an `else` it points at that branch; without one it goes straight to the join, the rule `lowerIf` already applies at the same position (`packages/transform/src/ast-to-ir.ts`).
-That is a lowering rule rather than a validator rule because `parseInclusiveGateway` validates nothing (`BpmnParse.java:2105-2117`).
-An all-conditional gateway without a default deploys, runs, then throws a stuck execution when nothing matches (`InclusiveGatewayActivityBehavior.java:71-75`).
-A branch carrying no condition is in the taken set every time, which is why an `else` written beside one is rejected: the set never comes out empty and the fallback behind the `else` never runs.
-
-The race is a multi-branch `await`.
-`await` already means the token stops here until this resolves, and a race is that meaning over a set.
-
-```bpmnscript
-process order-shipping {
- await {
- message("PaymentReceived") { service ShipOrder(topic: "shipping") }
- timer("P3D") { user ChaseCustomer }
- }
-}
-```
-
-A branch is a trigger header in the payload grammar `on` and `await` share, its settings, then the body.
-Two branches are the minimum, enforced by the grammar as `ParallelStatement` already does (`bpmn-script.langium`).
-The triggers are the four `await` already takes, which is also everything Operaton accepts in this position (`BpmnParse.java:1549-1594`).
-Reusing the word costs nothing, because `await {` cannot parse today: the `IntermediateCatchEvent` shape takes a trigger word after the keyword (`bpmn-script.langium`).
-
-The race falls through to a synthesized exclusive join, reusing `if`'s join and its pruning when every branch terminates (`pruneUnreachableJoin`, `ast-to-ir.ts`).
-Operaton gives every catch event behind an event-based gateway the start behavior `CANCEL_EVENT_SCOPE` (`BpmnParse.java:1564-1566`), so exactly one branch of a race ever runs.
-
-An inclusive pair reuses `Gateway__fork` and `Gateway__join` unchanged, since exactly one `parallel` statement sits at any structural coordinate.
-The race adds one template, `Gateway__race`, whose segment word joins the reserved-name pattern.
-
-Neither `if`, `while`, `parallel`, nor either new form gains a label slot, and the reason is structural.
-One construct lowers to two gateways, so a single slot carries at most half of what an imported document may hold.
-Instead `irToDsl` gains a warnings channel, and the dropped label is one of the reports it carries; which reports the print hop makes and what each one costs is `PrintWarningCategory` and the warnings built beside it in `packages/transform/src/ir-to-dsl.ts`.
-Its signature moves from `string` to a result carrying source and warnings, as ADR-0014 did to `xmlToIr`, since a channel a caller can skip is a silent drop.
-
-This extends the structured surface rather than breaching it.
-Every gateway is still derived from block structure, none is written, and none is named, so the grammar header's claim holds word for word.
-
-Both gateways move from a blanket refusal to a carry.
-An inclusive gateway carries with no exceptions, reading `name` and `default`, because Operaton refuses nothing about it.
-An event-based gateway carries, refusing three shapes, each following a refusal in Operaton's own parser:
-
-- an outgoing flow whose target is not a `bpmn:intermediateCatchEvent` (`BpmnParse.java:2162`)
-- a downstream catch carrying a link definition (`BpmnParse.java:1583-1585`), which `mapIntermediateCatchEvent` turns away wherever a link appears, so the gateway needs no rule of its own for it
-- a downstream catch reached by more than one path (`BpmnParse.java:4371-4377`), counted over every path in
-
-The last is wider than the engine's rule: Operaton lets a path through where it leaves an event-based gateway, so it takes a catch reached only by such paths, and this refuses that too.
-Neither that shape nor the one Operaton refuses is authorable, so the extra width costs no script anything.
-
-`parseEventBasedGateway` rejects `operaton:asyncAfter` on the gateway itself as well.
-Since ADR-0040 a multi-branch `await` head takes the gateway's job settings, so the validator refuses `asyncAfter` there under the same rule, and the import refuses a document carrying it rather than dropping it.
-
-### Consequences
-
-- Good, because both gateways stay synthesized and elided, so idempotence, the id scheme, and the control-flow analysis need no new rule and no new IR flag.
-- Good, because neither construct spends a reserved word; reserving `race` would take `var race: string` and `user race` out of the language.
-- Bad, because a conditioned `parallel` reads as one construct but compiles to either of two BPMN elements, so a BPMN-literate reader must read every branch.
-- Bad, because `irToDsl` changes its return shape, touching every call site, and the drop that motivated it costs the reader a gateway's name rather than anything the process does.
-- Bad, because the label comes back on a BPMN-to-BPMN pass but never on a BPMN-to-DSL one, so a decompiled and recompiled document loses every gateway label.
-- Bad, because a conditional branch of a race can win without waiting (`EventBasedGatewayActivityBehavior.java:30-46`), which no part of the surface shows.
-- Bad, because an inclusive fork closed on an exclusive merge, the common hand-drawn shape, degrades to `goto`s a reader repairs by hand.
-
-### Confirmation
-
-Parser tests pin a `parallel` block mixing an `if` branch, a plain branch and an `else` branch, and a plain branch opening with a nested `if`/`else`.
-`await { ... }` parses as a race while `await timer("PT1H")` still parses as a single catch.
-Validation tests pin the four-trigger scope, the two-branch minimum as a parse failure, a second `else` branch on one statement, an `else` branch with no conditioned sibling, and an `else` branch beside a sibling carrying no condition.
-Transform tests pin element selection, the default-flow rule, the race's join and its pruning, the inclusive carry, and each refusal by name.
-They also pin elision of both new pairs and the dropped-label warning reaching the caller.
-Golden fixtures assert IR idempotence after normalization and that the decompiled source recompiles cleanly.
-A Docker-gated end-to-end test shows the losing race branch canceled and the inclusive join waiting for exactly the branches taken.
-
-## Pros and Cons of the Options
-
-- Conditioned branches on `parallel` add no keyword, no statement, and no id template, but the element choice is invisible in the block's keyword.
-- A bare parenthesized condition parses as cleanly, but a parenthesized expression opening a line is a shape this language has nowhere else.
-- A separate reserved keyword would make the element visible, but it spends a word and names the BPMN element rather than what the user means.
-- Routing the default to the join reuses a rule `lowerIf` already applies, at the cost of a fallback invisible in the source.
-- Requiring a written `else` would make that fallback visible, but it is required syntax for something the compiler can infer.
-- A multi-branch `await` reserves no word and generalizes a meaning the keyword already carries.
-- A distinct keyword could not be confused with anything, but it costs the language a name authors may want.
-- A synthesized exclusive join with fall-through reuses `if`'s join and cannot duplicate a token, at the cost of a join that sees one.
-- Self-contained branches rejoining by `goto` would match a hosted `on` handler, but would force a `goto` for the common timeout case.
-- No label slot with the drop reported keeps every gateway elided and puts the label on the same channel as the print hop's other reports, at the cost of a signature change.
-- A label slot is the only way a label survives a full BPMN to DSL to BPMN round trip, but a labeled gateway is an authored gateway.
-- Leaving the drop silent costs nothing, and it is the one thing ADR-0014's contract exists to prevent.
-- A `gateway` keyword or a flat node-and-edge form would express every gateway shape, but a gateway that prints re-parses with an authored id.
-
-## More Information
-
-Related decisions: ADR-0008 and ADR-0013 set the surface and its two rules, ADR-0009 gains an inclusive pattern and a race pattern, and ADR-0010 gains one id template.
-ADR-0014 supplies the import contract extended here to the print hop, ADR-0016 the soft trigger words, and ADR-0017 the payload surface a race branch header reuses.
-ADR-0019 supplies the live-parser method, and ADR-0020 the keyword and trigger scope the race inherits.
-Operaton behavior was read from `BpmnParse.java`, `InclusiveGatewayActivityBehavior.java`, and `EventBasedGatewayActivityBehavior.java` in the `operaton/operaton` repository, with line numbers as of the time of writing.
-The documented restrictions are from .
-The dataset behind Compagnucci, Corradini, Fornari and Re (BISE 66(1), 2024, DOI 10.1007/s12599-023-00818-7) has an event-based gateway in roughly 12 percent of its 38,863 models and an inclusive gateway in roughly 6 percent.
-
-Amended by ADR-0035, which admits a link definition on an intermediate catch and an intermediate throw on import.
-The event-based gateway's second refusal now rests on the rule that no sequence flow may enter a link catch, which the importer enforces for every flow and so for a branch of a wait as well.
-
-Amended by ADR-0040, which gives every gateway synthesized here a settings parens on its statement head, so a multi-branch `await` now refuses `asyncAfter` by validation rather than by having nothing to write it on.
diff --git a/docs/decisions/0025-events-at-the-process-boundary.md b/docs/decisions/0025-events-at-the-process-boundary.md
new file mode 100644
index 0000000..25a5504
--- /dev/null
+++ b/docs/decisions/0025-events-at-the-process-boundary.md
@@ -0,0 +1,117 @@
+---
+status: accepted
+date: 2026-08-29
+decision-makers: Marlon Kranz
+---
+
+# Events at the process boundary: starts, ends and throws
+
+## Context and Problem Statement
+
+Most event triggers this language writes are caught or raised inside a process that is already running.
+The two positions that bracket a process are the open question.
+A correlated order, a broadcast, and a nightly schedule would otherwise be modeled outside the process they start, and a branch that decides nothing else should continue could not stop a sibling parked on a user task.
+Seven BPMN elements sit in that gap: a message, signal, timer, or condition start event, a terminate end event, a message end event, and a message intermediate throw.
+Where does each one land, given that `start`, `end`, `throw` and `emit` could all plausibly carry the clause, and which of them does Operaton actually execute?
+The specification is more permissive at the start position than the engine is, in a direction that produces no error.
+
+How many starts a process may carry is the second half of the same question.
+Requiring an explicit `start` as the first statement of its container reads one start event per container off that rule, but Operaton draws the line elsewhere.
+`BpmnParse.parseStartEvents` accepts any number of start events on a process definition, and only a scope that is not a process goes through `BpmnParse.parseScopeStartEvent`, which reports an error on the second start of an embedded sub-process, a transaction, or an event sub-process.
+One start per container is therefore an invariant of a surface, not of the engine, and it costs real files: a modeler document with a plain start beside a message start, the ordinary shape of a process entered both by hand and by correlation.
+So where may a second `start` sit on the page, what does the flow around it mean, and which facts about a start set are worth a diagnostic?
+
+## Decision Drivers
+
+- One IR shape has to print in exactly one form.
+ Otherwise the printer arbitrates on every decompile by a rule invisible to whoever reads the result.
+- The honest import contract refuses what changes the run and warns only about drops that do not, so Operaton's parser rather than the BPMN specification decides what is worth emitting, and a refusal has to name an engine reason.
+- Whatever import accepts, the decompiler has to be able to write back in a form that re-desugars to the same graph.
+- Every trigger word is a soft identifier, so a new clause stays unambiguous by token position alone.
+ A rule that reads unambiguously to a human is not evidence.
+- The page must say what the graph does.
+ Steps run top to bottom and sequence flow is never written out, so a statement the flow runs past without entering would be the one silent exception.
+- The engine's start matrix is silent at deploy time in two places that bite at runtime, and this surface reports what the engine will not.
+- No grammar change for the start set: `Statement` already admits a `start` anywhere in a block, so that half is a question of validation and lowering.
+
+## Considered Options
+
+- `throw message("Name")` and `emit message("Name")` for the message end and the message intermediate throw, which need no grammar, since both statements already parse a trigger word, an optional name, and a quoted code, but drop an id matching the synthesized shape off a printed throw, so a `goto` cannot target a nameless message end.
+- `end E message("Name")`, the message end as a clause on the end statement, under which one IR shape would carry two printed forms and the printer would have to choose per kind.
+- `end terminate(label: "...")` for the terminate end event, which says what the element does, to end rather than to raise, and carries a label, so a diagram's terminate node keeps its caption through a round trip.
+- `throw terminate`, where `throw` means raise this event and end this path while a terminate raises nothing and ends every path, and where the missing label would make an imported labeled terminate warn about a caption a diagram carries.
+- Message, signal, timer and condition on a start, the four the engine builds a start behavior for, so what is written is what runs.
+- Every trigger the specification allows on a start event, adding error, escalation and compensation, which Operaton's `BpmnParse` ignores in `parseProcessDefinitionStartEvent`, building a `NoneStartEventActivityBehavior` instead, so such a document would import and print without a diagnostic and then start unconditionally.
+- A trigger only on a process's own start event, since Operaton rejects a trigger on an embedded sub-process start and an event handler's trigger has exactly one home, the `on` header.
+- A trigger on any start event, including a sub-process's and an event handler's, where a sub-process start carrying one is a deployment failure rather than a degradation.
+- Flat sibling `start` statements in a process body, which need no grammar change and let one file carry the shape a modeler draws.
+- A grouped entry block holding every start, which invents a container the engine has no counterpart for.
+- A process body only by engine rule, or every container with the engine's refusal surfacing at deployment, the second of which hands the author a deployment failure the validator could have named.
+- An error naming the ambiguity when a start follows a statement whose flow is still live.
+- A start the flow runs past, joining its chain to the one before it, which refuses no page, but would be the one place where the page does not say what the graph does.
+
+## Decision Outcome
+
+Chosen: `throw message("Name")` and `emit message("Name")` for the message end and the message intermediate throw, `end terminate(label: "...")` for the terminate end, message, signal, timer and condition as the triggers a start event carries, a process's own start event as the only position that may carry one, and flat sibling `start` statements in a process body with an error for a start after a live chain and a warning for each of the two silent engine facts.
+The terminate is the one place here where the option costing no grammar was not taken: `end` carries the label a diagram's terminate node needs, and `throw` drops one.
+
+`end E message("Name")` still parses, deliberately.
+A kind belonging on `throw` earns a validator message naming the statement to write, instead of a parse error (`endTriggerMessage`), and the validator refuses the two illegal start positions the same way, each with its own wording (`checkStartEvent`, `packages/language/src/bpmn-script-validator.ts`).
+The accepted trigger set is `START_TRIGGERS` (`packages/language/src/vocabulary.ts`), and import refuses what lies outside it with wording about the degradation rather than about the element type (`IGNORED_START_SUBJECTS` and `readStartTrigger`).
+A conditional start is accepted rather than refused, because the engine dispatches on it where it ignores an error, escalation, or compensation one, so the set that stops where the engine stops includes it.
+Operaton also ignores a `bpmn:standardLoopCharacteristics`, and ADR-0022 imports that as a step that runs once with a warning, so the two ignored elements are handled differently on purpose.
+An error, escalation or compensation trigger on a process's own start event is not valid BPMN: the specification allows only none, message, timer, conditional, signal, multiple and parallel multiple there (BPMN 2.0.2, Table 10.84), and keeps the other three for an event sub-process start (Table 10.86).
+Such a document says something no conforming reading can give it, so the import refuses it and the fix belongs in the model.
+A standard loop is valid BPMN that the engine does not implement, and refusing it would reject a conforming document that deploys, against the import contract of ADR-0012.
+
+Neither clause needs a new reserved word.
+`name=ID` is mandatory and comes first in both the `StartEvent` and `EndEvent` rules (`packages/language/src/bpmn-script.langium`), so the second token decides everything: an `ID` is the trigger, a `(` the settings, and a `{` the members.
+Nothing else can appear there, because every statement in this grammar is keyword-led, and both rules were built into a live Langium parser with Chevrotain's self-analysis on, which reported no ambiguity.
+`terminate` and the four start words therefore stay soft identifiers, and `var terminate: string` parses beside `end Done terminate`.
+
+The attributes that make the engine really send a thrown message, `operaton:class`, `expression`, `delegateExpression`, `type`, `topic`, and an `` child, are read off the `bpmn:messageEventDefinition`, which is the surface the task kinds carry; the same attributes on the event element itself are inert.
+
+A `start` opens a chain and takes no incoming flow, so it may sit first in the process body, directly after another `start`, or after a statement whose flow always ends or redirects, and a start after a step whose flow is still live is refused with a message naming the fix, because either reading of that page invents a flow the author did not write.
+That freedom holds for a process body only: an embedded `subprocess`, an `attempt` block, and an event-handler body keep exactly one start, at their head, because `parseScopeStartEvent` rejects the second start of any scope that is not a process definition, and a second start in one of them is refused on import by count (`checkStartEventCount`, `packages/transform/src/xml-to-ir.ts`), naming that engine reason, while a process is let through.
+
+Two warnings report what the engine decides in silence.
+`BpmnParse.selectInitial` makes a lone start of any kind the default, and among several starts only a plain or timer start; with two or more starts and none of those two kinds the process has no default, and `ProcessDefinitionImpl.ensureDefaultInitialExists` throws when such a process is started by key, so the validator warns on the process name.
+`BpmnParse.parseStartFormHandlers` binds a start form to the default start alone, so a `form` block on any other start is parsed and never offered, and the validator warns on that block.
+Both stay warnings: the process deploys and runs, and being entered by message or signal is the point of writing such a process.
+
+The decompiler walks an unnamed plain start ahead of every other, since such a start is elided on print and re-derived by the compiler at the head of the body, so its chain has to be the first one printed, else the entry point is lost on the way back.
+One corner is accepted rather than fixed.
+A start whose first step is a synthesized gateway prints the branch when every start into that gateway prints, so `start A`, `start B`, `if (x) { ... }` round-trips clean, but the corner survives when one of those starts is the elided plain start, the one carrying the minted `StartEvent_` id and no content: its chain is walked first, the `if` printed for it consumes the gateway's out-edges, and the other start prints back as a dropped-edge marker.
+No source spells that shape, since `goto` names an authored statement and never a synthesized gateway, so it reaches the printer through an imported document alone, and the authoring rule that keeps clear of it is to write each start ahead of a named step, which the golden fixture and the `support-ticket` example both obey.
+
+### Consequences
+
+- Good, because the message end and the message intermediate throw cost no grammar and read like the signal end and signal emit beside them.
+ One IR shape keeps one printed form.
+- Good, because the four start triggers reuse the payload surfaces and name-keyed roots ADR-0024 already built.
+ The set stops where the engine stops, so an import either runs as it reads or refuses and names the element that stopped it.
+- Good, because a process entered by hand and by message, a common reason to draw two starts, is one file and one diagram instead of two processes.
+- Good, because the chain rule is one the reader can check on the page, and the desugarer never has to define the ambiguous shape.
+ Operaton does not reject a start event with an incoming flow: `BpmnParse.parseSequenceFlow` has no arm for a start-event destination, and `NoneStartEventActivityBehavior` inherits `FlowNodeActivityBehavior.execute`, which leaves the activity at once, so a start the flow runs past would deploy and run as a pass-through step, and the mistake would show only in history.
+ Refusing the shape is what rules that out.
+- Neutral, because `initiator` is read off every start and set on the process definition by `BpmnParse.parseProcessDefinitionStartEvent`, last one wins, and the validator warns on every start but the last that names one.
+- Neutral, because a message start whose name is already subscribed by another deployed definition fails in `BpmnDeployer.addMessageStartEventSubscription`, a fact about the deployment rather than the file, and out of this tool's reach.
+- Bad, because a terminate end always prints, even when its id was synthesized (`isElidedOnPrint`, `packages/transform/src/ir-to-dsl.ts`).
+ A modeler document decompiles to `end EndEvent_1 terminate`, and re-parsing reports the reserved prefix.
+- Bad, because a branch that terminates does not rejoin.
+ An `if` whose branch ends at a terminate prints as a `goto`, and a `parallel` whose branch terminates leaves its join with one incoming flow (`joinContinuation`).
+- Bad, because the printed form of a process with several starts is not always the authored one: starts entering the same step print back to back above it, even one written after an `end` with a `goto` onto that step.
+ The graph is the same, which the idempotence block asserts, but the reader has to learn the shape.
+- Bad, because the corner above means an imported document with an unnamed plain start into a shared synthesized gateway round-trips with a warning rather than cleanly.
+
+### Confirmation
+
+`packages/language/test/parsing.test.ts` and `validating.test.ts` pin the two clauses token by token and every diagnostic behind them, and the validator rows pin every position a start may take, the chain error, and both warnings.
+`packages/transform/test/xml-to-ir.test.ts` pins each refusal by error class and message, `ast-to-ir.test.ts` and `ir-to-dsl.test.ts` the lowering and the printed line for every authored shape, and `packages/cli/test/decompile-contract.e2e.test.ts` that a refused trigger exits nonzero and writes no output file.
+The frozen pair `tests/golden/event-positions.{bpmnscript,bpmn}` holds a message start, an `emit message`, a `throw message`, and a terminate end, and `tests/event-positions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all; the frozen pair `tests/golden/event-triggers.{bpmnscript,bpmn}` carries the timer, signal, and conditional starts, and the frozen pair `tests/golden/multiple-starts.{bpmnscript,bpmn}` holds four starts across two chains, its suite asserting at every hop that all four keep their trigger and none is the target of a sequence flow.
+`tests/e2e/event-positions.test.ts` deploys to a real Operaton through Testcontainers: a correlated message with no instance to aim at creates one, a broadcast signal creates one with both branches active, a timer parks a job, the terminate cancels a sibling branch parked on a user task, and both thrown messages pass the token through to completion, which is what shows an implementation-free message throw deploys and runs.
+The same suite starts `support-ticket`, a process with a plain and a message start, once by key and once by message, and each instance's history holds exactly its own start and the shared task, which is the assertion a pass-through start would fail.
+
+## More Information
+
+ADR-0024 carries the payload surfaces the four start triggers reuse, including the timer mapping.
diff --git a/docs/decisions/0026-intermediate-catch-events.md b/docs/decisions/0026-intermediate-catch-events.md
new file mode 100644
index 0000000..cbc5040
--- /dev/null
+++ b/docs/decisions/0026-intermediate-catch-events.md
@@ -0,0 +1,67 @@
+---
+status: accepted
+date: 2026-07-24
+decision-makers: Marlon Kranz
+---
+
+# Intermediate catch events
+
+## Context and Problem Statement
+
+An `on` handler is a racing scope guard, compiling to an event sub-process that competes with the whole container it guards, and `throw` and `emit` go the other way, making the current path fire a trigger outward.
+Neither covers `bpmn:intermediateCatchEvent`, a node sitting on the main sequence flow where the token stops in place until the trigger fires and then carries on to whatever follows.
+Three questions settle together: what word introduces it, given that trigger words and timer particles are soft identifiers so only token position can disambiguate; whether it carries an author-chosen name; and which of BPMN's catchable event kinds this attachment point exposes.
+
+## Decision Drivers
+
+- Every trigger word and every timer particle is a soft identifier (ADR-0023), so a rule sharing that vocabulary has to be run through the parser generator and shown clean, not merely look plausible.
+- The payload surfaces for message, signal, timer, and conditional triggers (ADR-0024) are already reused by `on` handlers and boundary events, so a third consumer should cost one dispatch case per layer rather than a second payload grammar.
+- The restructuring analysis and the round-trip id normalizer have settled rules for what needs special handling, and a new construct should fit them rather than add an exception to either.
+- An unsupported form on the wire is refused with a diagnostic naming what was refused, never dropped or approximated (ADR-0012).
+
+## Considered Options
+
+- `await`, which carries the meaning all four cases share, that execution stops here until this specific thing resolves, and is the word this audience already reads that way in mainstream asynchronous code.
+- `wait`, which reads as a generic pause, closer to a fixed delay than to a subscription that resolves when something happens.
+- `receive`, which names precisely what a message catch does and mislabels the other three: a signal is broadcast rather than addressed to anyone, a timer is not received from anywhere, and a condition is evaluated rather than delivered.
+- An optional `name=ID` slot ahead of the trigger word, mirroring how a hosted `on` handler is named.
+- Message, timer, signal, and conditional as the trigger scope, exactly the shapes the shared `readCatchEventDefinition` helper maps, the same function an `on` handler's start event and a boundary event's catch side both read through.
+- Every BPMN catchable trigger, adding error, escalation, compensation, and cancel, which would need no exclusion list and no refusal diagnostics.
+
+## Decision Outcome
+
+Chosen options: `await`, a name that follows the trigger word rather than preceding it, and a scope of message, timer, signal, and conditional.
+Link joins that scope for the import direction (ADR-0030).
+
+Before committing to the keyword, the rule was built into a live Langium parser with Chevrotain's self-analysis switched on (`createServicesForGrammar`) and the full trigger-payload battery was run through it.
+Every form parsed with zero ambiguity warnings, including the case that matters: `await timer("PT1H")`, a bare identifier followed by a parenthesized payload, resolved to `trigger='timer'` with the duration as its payload.
+`wait` and `receive` parse exactly as cleanly, so the choice between the three is not a parsing question.
+Reserving `await` collides with no trigger word or timer particle in the grammar, and it appears as a bare identifier nowhere in the committed corpus, unlike `message` or `code`, which stay soft precisely because they are ordinary variable names.
+
+A trial grammar carrying the name ahead of the trigger was run through the same live-parser check and reported "Ambiguous Alternatives Detected inside IntermediateCatchEvent Rule": with a name and a trigger both bare identifiers, nothing decides which of the two the first word after `await` is.
+That is not a cosmetic misparse.
+A bare payload, an `at` key, and an `every` key map to a duration, a fixed date, and a repeating cycle, three different things the engine schedules, so a timer read as a name loses which one the author wrote.
+The name therefore follows the trigger word, in the position `emit` already spells one.
+
+The four triggers are the only kinds this language treats as something a token can genuinely wait on.
+Error and escalation are always thrown by `throw` or `emit` and always reacted to by a racing `on` handler, so letting `await` claim them would leave two constructs both plausibly catching the same trigger with no rule for which one fires.
+Compensation has one meaning here, the undo block a subprocess declares with `on compensation` (ADR-0028), invoked by the engine's own compensation machinery and never something with an independent arrival.
+BPMN gives cancel no position an `await` could occupy: a transaction sub-process's boundary catches one and an end event inside that sub-process raises it, and ADR-0029 surfaces that container as the `attempt` block with both of those positions.
+
+On import, an excluded definition, or more than one definition on the same element, is refused by name.
+The XML schema does not stop a document from placing one of those on an intermediate catch element, so the refusal is a real check against input a foreign tool or a hand-edited file can produce.
+The catch is the topological twin of `IntermediateThrowEvent`, `emit`'s compiled form, differing only in whether the token fires forward immediately or stops and waits, so the restructuring analysis reads it as an ordinary node with a normal immediate dominator and the round-trip id normalizer re-keys nothing.
+
+### Consequences
+
+- Good, because the full payload battery parses with zero ambiguity warnings, so `await` reserves one word and touches no existing rule's disambiguation.
+- Good, because `await` names exactly the behavior all four triggers share rather than a word accurate for one of them and approximate for the rest.
+- Good, because the catch is a topological twin of the intermediate throw, so forward emit, decompile, the restructuring analysis, and the round-trip normalizer each reuse existing machinery through one dispatch case.
+- Good, because the trigger scope matches `readCatchEventDefinition` exactly, so import gains an honest refusal for the forms it cannot represent instead of a blanket refusal or a silent drop.
+- Bad, because error, escalation, compensation, and cancel stay permanently unreachable from `await`, even on a hand-crafted document where the shape looks unremarkable to a reader who does not know BPMN restricts a plain intermediate catch element to a smaller set than a boundary event or an event sub-process's start accepts.
+
+### Confirmation
+
+`packages/language/test/` pins each trigger's parse, the timer case pinning that which of its three forms was written survives, that `var await` fails to parse while `var message: string` and a step named `every` still parse clean, and each rejected trigger word by its whole message.
+`packages/transform/test/` pins the lowering, the emitted `*EventDefinition` shapes, the mapped shapes and the refusals, the decompiled render lines, the synthesized id, and the control-flow no-op as an explicit regression case: a catch on the main flow gets a normal immediate dominator, never the virtual entry.
+`tests/golden/intermediate-catch.bpmn` and its round-trip suite exercise all four triggers across a full DSL -> XML -> IR -> DSL cycle, and a Docker-gated end-to-end test correlates a message mid-flow to show the instance genuinely waiting before correlation and completing after, the one property no other layer can show.
diff --git a/docs/decisions/0026-task-kinds-on-the-authoring-surface.md b/docs/decisions/0026-task-kinds-on-the-authoring-surface.md
deleted file mode 100644
index d313398..0000000
--- a/docs/decisions/0026-task-kinds-on-the-authoring-surface.md
+++ /dev/null
@@ -1,115 +0,0 @@
----
-status: accepted
-date: 2026-08-30
-decision-makers: Marlon Kranz
----
-
-# Task Kinds on the Authoring Surface: `step`, `send`, `receive`, and `decide`
-
-## Context and Problem Statement
-
-Four BPMN activity tags have no statement in this grammar: `bpmn:task`, `bpmn:sendTask`, `bpmn:receiveTask`, and `bpmn:businessRuleTask`.
-A document carrying one imports as something the printer cannot write back, so the round trip stops there.
-Each of the four needs a word, and every statement keyword is a name an author can never use again.
-Which four words, where do their payloads sit, and how many IR kinds do four tags need?
-
-## Decision Drivers
-
-- ADR-0013 binds a keyword to what the author means rather than to the BPMN element behind it.
-- A statement keyword cannot be soft: `Statement` being keyword-led is what lets `start S` tell its optional trigger from the next statement.
-- A Langium keyword is lexer-global, so a word spent on a statement is a variable and step name lost everywhere in a file.
-- ADR-0014 refuses what changes the run and warns only about drops that do not.
-- ADR-0006 models the IR on what the engine does, so a distinction the engine does not make should not become an IR kind.
-
-## Considered Options
-
-- `step`, `send`, `receive`, and `decide` as the four words
-- `task`, `notify`, `expect`, and `rule`
-- The message name and the decision key in positional slots after the id
-- The message name and the decision key as settings
-- One IR node for the three service-task-like tags
-- One IR kind per tag
-- A receive task with no message name in scope
-- A receive task with no message name refused on import
-
-## Decision Outcome
-
-Chosen options: `step`, `send`, `receive`, and `decide`, every payload in the settings, one IR node for the three service-task-like tags, and the nameless receive task in scope.
-
-Sharing a word with the BPMN tag is not what ADR-0013 forbids.
-`user`, `service`, and `script` already do it, and each is legal because the word means something to a reader who has never seen BPMN.
-`send`, `receive`, and `decide` are the verbs an author means, and ADR-0013 names a decision as a thing a keyword should name.
-`task` was rejected for the generic kind because it is the one candidate carrying no meaning beyond the tag name.
-
-All four are hard keywords, so `step` stops being a variable name, a bare expression identifier, an attribute key, a listener event, a form-field id, and a map key.
-No existing file uses any of the four, and the four rules built into a live Langium parser with Chevrotain's self-analysis on drew no ambiguity report.
-
-`decide` sits close to `if`, which is a decision in the same vocabulary.
-It is accepted because the two never compete for a position: `if` takes a condition and opens branches, and `decide` is a leaf activity naming a decision table.
-
-Every payload sits in the settings rather than in a positional slot, as in `receive AwaitSettlement(label: "Wait for the payment", message: "PaymentSettled")`.
-A label and a message name are both quoted strings, so an unkeyed one has no reading that position can settle.
-The settings also bring reuse, since `call` already names its target there, and `binding` and `version` already carry the rule a decision step needs.
-
-One IR node covers `bpmn:serviceTask`, `bpmn:sendTask`, and `bpmn:businessRuleTask`.
-Operaton's `BpmnParse` runs all three through `parseServiceTaskLike` when the tag carries a `class`, `expression`, `delegate`, or `topic` binding, so they share one node and an optional discriminator picks the tag.
-A business rule task naming an `operaton:decisionRef` goes to Operaton's `parseDmnBusinessRuleTask` instead, and the `decision` binding that form carries is the one variant `service` and `send` refuse.
-A send task therefore has no message semantics of its own: the engine gives it none, and `send X(class: "...")` is what makes it send anything.
-
-A receive task with no message name is in scope in both directions, with no refusal and no warning, as in `receive AwaitPickingSlot(label: "Wait for a picking slot")`.
-Operaton's `parseReceiveTask` subscribes a receive task to a message only when `messageRef` is present, so a nameless one is a wait state the engine's signal API continues.
-A named one derives its `bpmn:Message` root through the collector ADR-0016 already uses, so it shares that root with an `await message` of the same name.
-
-A decision step pins its table with `binding: latest`, `binding: deployment`, or `version: 3`, the call activity's surface reused down to the validator rule and the IR type.
-
-### Consequences
-
-- Good, because a message throw or end may now carry the implementation that makes the engine really send it, so this surface no longer treats every thrown message as a pass-through.
- ADR-0024 refused such a document on import, and that refusal was aimed at the wrong element.
- Operaton's `BpmnParse` calls `isServiceTaskLike` on the `bpmn:messageEventDefinition` in both throw positions and never inspects the `endEvent` or `intermediateThrowEvent`.
- The same attributes written on the event are therefore inert, so the refusal rejected content the engine ignores.
- The carry now lives on the definition, and an implementation on the event warns.
-- Bad, because `operaton:resultVariable` on a thrown message's definition warns away while the binding beside it imports, and Operaton fills it from an `expression` binding's return value.
- Operaton rejects the deployment outright when a `class` or `delegateExpression` binding carries one, so the expression form is the only one that loses anything.
- It is a warned execution change where ADR-0014 asks for a refusal, recorded here as a deliberate boundary rather than left unstated.
-- Bad, because `step` now carries two senses at once: the language's word for the activity that does nothing, and the ordinary noun for any activity in a process.
-
-### Confirmation
-
-`packages/language/test/` and `packages/transform/test/` pin the four statements token by token, every binding rule, the import of each tag, the emitted element with its derived root, and the printed line.
-The frozen pair `tests/golden/task-kinds.{bpmnscript,bpmn}` holds all four kinds, both receive forms, both decision bindings, and a message end carrying an implementation.
-`tests/task-kinds.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all.
-`tests/e2e/task-kinds.test.ts` deploys that process to a real Operaton, walks the token past the step, the send, and the decision, and parks it at the receive task until the message is correlated.
-
-## Pros and Cons of the Options
-
-### `step`, `send`, `receive`, and `decide`
-
-- Good, because each word says what the author is doing, which is the test ADR-0013 sets, and three of the four are the verb itself.
-- Bad, because four ordinary English words stop being available as identifiers anywhere in a file.
-
-### `task`, `notify`, `expect`, and `rule`
-
-- Bad, because `task` names the BPMN tag and nothing else, `notify` narrows a send to a notification, and `expect` and `rule` read worse than the verb beside `user` and `service`.
-
-### One IR node for the three service-task-like tags
-
-- Good, because the discriminator is optional, so every existing service-task literal in the sources and the test suites stays valid untouched, and the binding switch is written once.
-
-### One IR kind per tag
-
-- Bad, because it splits a shape Operaton itself does not split, and copies that binding switch through the writer, the printer, and the reader.
-
-### A receive task with no message name refused
-
-- Bad, because it costs an error path, a message, and a test to reject content the engine executes, which ADR-0014 reserves for shapes that change the run.
-- Bad, because downgrading it to a validator warning is no cheaper: the goldens require a decompiled document to re-validate with no diagnostic at all.
-
-## More Information
-
-Supersedes the paragraph in ADR-0024 recording a thrown message's send implementation as refused on import, since this surface now carries it.
-Related decisions: ADR-0013 (the rule that a keyword names what the author means, and the reason `task` is not one).
-ADR-0014 (the honest import contract behind every refusal and every warning here).
-ADR-0016 (the derived `bpmn:Message` roots a named receive task joins).
-ADR-0021 (the service-task binding set the send task, the decision step, and the message throw all reuse).
-ADR-0024 (the message end and message throw this decision gives an implementation).
diff --git a/docs/decisions/0027-boundary-events-attached-to-an-activity.md b/docs/decisions/0027-boundary-events-attached-to-an-activity.md
new file mode 100644
index 0000000..b926e2c
--- /dev/null
+++ b/docs/decisions/0027-boundary-events-attached-to-an-activity.md
@@ -0,0 +1,96 @@
+---
+status: accepted
+date: 2026-07-23
+decision-makers: Marlon Kranz
+---
+
+# Boundary events attached to an activity
+
+## Context and Problem Statement
+
+A host-less `on` handler compiles to an event sub-process that guards its whole enclosing process or subprocess body, as the decision outcome below records.
+BPMN has a second, narrower attachment for the same catch vocabulary: a `bpmn:boundaryEvent` docked directly on one activity, which only catches while that activity is running and, depending on `cancelActivity`, either cancels it the moment the trigger fires or lets it keep running alongside the catch.
+Giving `on` an optional host turns it into this second attachment form without introducing a competing keyword or a second handler construct.
+Four questions settle together: how a host is spelled without colliding with the handler shape `on timer("PT2H")` that already parses; which of the language's catchable triggers may attach; what a hosted body compiles to, given that the host-less form's meaning must not move; and how a token that appears directly at a boundary event, without ever traversing the main flow, is represented in the dominator-based restructuring analysis (ADR-0014).
+
+## Decision Drivers
+
+- Every trigger word is a soft identifier (ADR-0023), so a new syntax slot cannot recover disambiguation from the trigger word's token type, only from structure the grammar contributes at parse time.
+- The host-less handler's meaning is exercised by existing golden fixtures: a hosted handler is a new attachment form layered next to it, not a replacement.
+- Operaton's own parser (`BpmnParse.parseBoundaryEvents`) is the authority on which triggers, and which host shapes, a boundary event may legally carry; a surface stricter than the engine owes a reason.
+- The restructuring analysis (ADR-0014) is total over every valid IR, and a boundary event is the IR's first flow-element shape with outgoing edges but no incoming ones.
+- The honest import contract (ADR-0012) forbids silent mangling on import, and compensation stays a subprocess undo block (ADR-0028) rather than gaining a second attachment mechanism.
+
+## Considered Options
+
+- A colon separator, `on Pack: error(X)`, which resolves the reading at the second token, well inside the two-token lookahead Langium's default parser handles without any grammar annotation.
+- A bare space, `on Pack error(X)`, which is ambiguous with a handler that parses today, since a host-less handler carrying a code and a hosted handler carrying none both read as `on` followed by two identifiers, and the ambiguity sits in the token text rather than the token type, so no lookahead over token types can tell the two apart.
+- A postfix clause, `on error(X) at Pack`, which reads left-to-right as "catch this, at this activity" but reuses the word `at` that a timer's date key already claims, recreating the same collision in a different position.
+- Promoting the trigger words to real keywords and re-admitting them as identifiers through a data-type escape hatch, which touches roughly two dozen grammar productions rather than one and still leaves a residual ambiguous alternative between the keyword-led host form and the timer form, resolved by the generator's own alternative ordering and reported only as a warning on the build's stderr.
+- A trigger scope read from the engine's own boundary parsing, so every boundary event the surface admits is one the engine deploys.
+- Every catchable trigger the language has, which needs no per-trigger row but would let the surface author a `bpmn:BoundaryEvent` carrying a `compensateEventDefinition`.
+- A self-contained body, wrapping nothing and rejoining only by an explicit `goto`, which matches what a bare `{ }` block already means everywhere else: a scope ends where its last statement ends, unless something inside it names where control goes next.
+- Falling through to whatever statement follows the host, which needs no `goto` for a "retry, then continue" shape on an interrupting boundary but duplicates a non-interrupting host's still-running token and runs the rest of the process twice.
+- Wrapping the body in a container of its own, as a host-less handler already is, which BPMN's boundary-event semantics rule out: the escape path has to be a flow element of the host's own container, so a `goto` back to the main flow would cross a container boundary.
+- Wiring a boundary event to the analysis's virtual entry like a start event, against leaving it unwired, which makes every escape chain permanently un-restructurable regardless of how simple its own control flow is.
+- A second analysis rooted at each boundary event, which would leave the main flow's dominance relationships untouched but is not supported by the restructurer's single-`Emitter`-per-container design, since two independent analyses over one node and edge set would have no way to agree on which of them owns a node reachable from both.
+- A host-derived id, against reusing the positional `EventSubProcess_` scheme, which would drift the moment the decompiler reordered handlers to the end of a container's statement list.
+
+## Decision Outcome
+
+Chosen options: the colon separator, a scope read from the engine, a self-contained body, virtual-entry wiring, and host-derived ids.
+
+The host-less form keeps its meaning.
+A host-less `on` compiles to a `bpmn:subProcess` with `triggeredByEvent="true"` in the container whose body holds the handler, the process, a `subprocess` or an `attempt` block, and so catches while any part of that container runs.
+Its trigger sits on the start event the event sub-process opens with, `alongside` writes that start as `isInterrupting="false"`, and the handler's body lowers as the event sub-process's own body, with its own implicit start and end.
+Its id is the positional `EventSubProcess_` (ADR-0010), and the decompiler prints every event sub-process back as a host-less handler after the container's other statements, in model order, since the compiler numbers each one by its statement index.
+`on compensation` is the host-less handler with a record of its own (ADR-0028), and `cancel` has no host-less form (ADR-0029).
+
+The scope is message, timer, signal, conditional, error, and escalation.
+Escalation is restricted to a subprocess, an `attempt` block, a call, or a user task because Operaton gates that boundary on `attachedActivity.isSubProcessScope()`, and `parseTransaction` sets that flag exactly as `parseSubProcess` does, so an `attempt` block hosts an escalation on the same terms an ordinary block does.
+Error reuses the same `alongside: false` restriction already enforced for a host-less error handler rather than adding a boundary-specific rule.
+Compensation is excluded on the grounds ADR-0028 established for it generally: BPMN attaches compensation through a `bpmn:association` and an `isForCompensation` activity, a different attachment mechanism entirely, which also keeps the import-side `refuseIfForCompensation` guard honest inside the boundary event's own import path.
+Cancel is excluded here because Operaton allows a cancel boundary on a transaction alone, and ADR-0029 surfaces that container as the `attempt` block and admits one there.
+
+Because a hosted handler's body is not its own container, the scope provider's flow-container walk, the same walk that decides what `goto` may resolve against, treats a hosted `on` as transparent: resolving a name from inside such a body walks straight past the handler to the real enclosing process or subprocess.
+That is what makes `goto` legal in both directions across a hosted handler's body, since both a `goto` inside the body reaching the main flow and a main-flow `goto` reaching into the body resolve against the one container the lowering actually places their statements in.
+
+The restructuring analysis wires every boundary event to its container's virtual entry, unconditionally, the same way it already wires every start event.
+This is the honest model: at runtime a token genuinely appears at a boundary event the moment its trigger fires while its host is running, without ever traversing a sequence flow into it.
+Without this wiring, an escape chain has no immediate dominator at all, and the restructurer can recognize an `if`, a `while`, or a clean join only relative to a dominance relationship, so the chain could never print as anything but unstructured `goto`s.
+The accepted trade-off is that a node reachable from both the main flow and an escape chain loses a tight immediate dominator, since neither the main flow's split nor the escape chain alone reaches it unconditionally, so an `if`/`else` whose join such a chain jumps into degrades to plain `goto`s on decompile.
+The dominance result is correct: a region two independent entry points can both reach is genuinely not dominated by either one's split.
+
+A boundary event's id is `Boundary__`, collision-resolved against the same document-wide `taken` set the implicit start and end ids already share.
+The one case this scheme does not fully disambiguate is two boundary handlers sharing a host and a trigger but differing in code: `on Pack: error(A)` and `on Pack: error(B)` are legal, non-duplicate handlers under the validator's `(host, trigger, code)` key, yet both base to the identical `Boundary_Pack_error`, so which one receives the `_2` suffix is positional.
+That is stable in the generation direction, since lowering assigns suffixes in statement order.
+The decompiler reprints such a group in the rank each handler's own id already carries, the id equal to the base first and `_2`, `_3` and so on after it, so a reimport that lists the group in a different order still keeps each handler under its own id.
+Two ids that both fall outside the minted pattern carry no such rank, so nothing but model order settles which prints first, the same exposure to another tool's ordering choice on a first import.
+Folding the code into the base id was rejected: no other id constructor sanitizes arbitrary author-supplied text into an id fragment, and a numeric suffix would still be needed for two `timer` boundaries on one host, which carry no engine subscription key and so are never rejected as duplicates.
+The round-trip normalizer compensates on the comparison side instead, keying a boundary event's signature on the event definition's own payload rather than on the printed id, so two IR snapshots compare equal regardless of which physical id each assigns to which occurrence.
+
+### Consequences
+
+- Good, because the colon resolves the host/trigger ambiguity with no reserved word, no residual parser-generator warning, and no change to ADR-0023's soft-trigger-word design.
+- Good, because the trigger scope admits only what Operaton's own parser accepts, so a boundary event the validator passes carries a trigger the engine deploys.
+ The converse does not hold: a compensation boundary is refused on attachment-mechanism grounds rather than on an engine refusal, and Operaton would deploy the document that refusal rejects.
+- Good, because excluding compensation costs nothing new to build: the existing `on compensation` undo block already covers the granularity a boundary compensation event would have reached, and the import-side refusal needed only a new call site for the existing guard.
+- Good, because the self-contained body is uniform across interrupting and non-interrupting boundaries, so there is one rule to learn instead of one rule per cancellation mode.
+- Good, because wiring a boundary event to the virtual entry makes an escape chain's own control flow, including a nested `if`/`else`, restructurable on the same terms as the main flow.
+- Good, because a host-derived id survives every reordering the decompiler's trailing-position rule performs, without adding a re-key rule for the generation direction.
+- Bad, because a node reachable from both the main flow and an escape chain loses a tight immediate dominator, degrading a main-flow `if`/`else` whose join such a chain jumps into into `goto`s on decompile.
+- Bad, because a host-less `on` handler written inside a hosted handler's body lowers into the outer container and therefore guards that whole container rather than just the escape path it is written inside, which follows necessarily from transparency and which an author cannot see from the source alone.
+- Bad, because two boundary handlers sharing a host and a trigger but differing only in code cannot be told apart by id text until one carries a minted rank.
+ Two such handlers that both import under an id outside the minted pattern still fall back to model order, exposed to whatever order another tool presents them in on a first import.
+
+### Confirmation
+
+`packages/language/test/` pins the colon's disambiguation, the transparency rule in both directions, and the trigger scope, the escalation host restriction, and the compensation-has-no-host refusal, each with an exact-message assertion.
+`packages/transform/test/` pins the promotion consequence of transparency, the virtual-entry wiring and the accepted dominance trade-off as an explicit regression case, the self-contained body including a nested `if`/`else` inside an escape chain restructuring cleanly, and the host-derived id template with its collision suffixing.
+`tests/fuzz-regressions.test.ts` pins the mint-rank print order for two boundary handlers sharing a host and trigger, both when one side already carries a minted id and when both are authored and fall back to model order.
+The frozen `tests/golden/boundary-events.bpmn` fixture and its round-trip suite exercise these decisions together across a real DSL -> XML -> IR -> DSL -> IR cycle, including the import-first direction that is this scheme's one open disambiguation case.
+
+## More Information
+
+Related decisions: ADR-0014 (dominator and post-dominator restructuring, the analysis this decision's entry wiring extends).
+ADR-0029 (the `attempt` block, which adds `cancel` to this attachment axis on the one host kind that can catch it).
diff --git a/docs/decisions/0027-repetition-on-the-authoring-surface.md b/docs/decisions/0027-repetition-on-the-authoring-surface.md
deleted file mode 100644
index 8d9b558..0000000
--- a/docs/decisions/0027-repetition-on-the-authoring-surface.md
+++ /dev/null
@@ -1,136 +0,0 @@
----
-status: accepted
-date: 2026-08-31
-decision-makers: Marlon Kranz
----
-
-# Repetition on the Authoring Surface: the `for` Clause
-
-## Context and Problem Statement
-
-Any BPMN activity may carry a `bpmn:multiInstanceLoopCharacteristics` child, which makes the engine run it once per element of a collection or a fixed number of times.
-This grammar had no way to say so, so a document carrying one refused on import and the round trip stopped there.
-Where does the repetition sit on a statement, what does it spell, and which shapes does the surface decline to carry?
-
-## Decision Drivers
-
-- ADR-0013 binds a keyword to what the author means rather than to the BPMN attribute behind it.
-- ADR-0010 requires an imported activity to come back under the id the document gave it.
-- ADR-0014 refuses what changes the run and warns only about drops that do not.
-- A Langium keyword is lexer-global, so a word spent on a clause is a name lost everywhere in a file.
-- Operaton decides what is legal here, and a surface stricter than the engine owes a reason.
-
-## Considered Options
-
-- A header clause between a statement's id and its settings
-- Settings keys, `times:`, `each:`, `as:`, and `sequential: true`
-- A wrapping statement, `for each x in c { ... }`
-- One head for both a count and a collection
-- Two heads, `for each ... in ...` for a collection and `repeat ` for a count
-- The element variable required
-- The element variable optional
-- `bpmn:standardLoopCharacteristics` imported as a step that runs once
-- `bpmn:standardLoopCharacteristics` refused
-
-## Decision Outcome
-
-Chosen options: the header clause, one head, the optional element variable, and importing a standard loop as a step that runs once.
-
-The clause reads `for each line in lines`, `for each in lines`, `for 3`, or `for 2 each line in lines`, with `sequentially` and `until ()` after it.
-It sits between the id and the optional settings on the nine statements that emit an activity.
-`for`, `each`, `sequentially`, and `until` are hard keywords, because in that position only a keyword can tell the parser a clause has started.
-No file in the repo used any of the four in an identifier position, and the four built into a live parser drew no ambiguity report from Chevrotain's self-analysis.
-
-Settings keys were the cheap option.
-They lose on ADR-0013, whose test `sequential: true` fails outright, being the XML attribute wearing a DSL hat.
-A flat key/value list also cannot constrain what the clause constrains: `as: "line"` with no `each:` is writable as a setting, and it is one of the deployments Operaton refuses.
-
-The wrapping form loses on ADR-0010.
-BPMN attaches a repetition to exactly one activity, so a wrapper holding two statements has to synthesize a sub-process to hang it on.
-An imported repeated user task would then come back inside a container the document never had, under an id this tool invented, which is the identity ADR-0010 exists to protect.
-A wrapper restricted to one statement is this clause with a pair of braces around it.
-
-One head carries both intents because Operaton allows both at once.
-Its `MultiInstanceActivityBehavior.resolveNrOfInstances` reads the cardinality first and falls through to the collection, and `evaluateCollectionVariable` still binds the element per run whenever a collection is set.
-Two heads would have to refuse that combination or grow a third spelling for it.
-
-Parallel is the unmarked form, because Operaton's `BpmnParse` defaults `isSequential` to false and this tool elides every attribute matching an engine default.
-An element variable is optional with a collection: `for each in batches` is a collection that drives the count and binds nothing, which deploys and runs.
-Refusing that for authoring convenience is the call ADR-0026 declined to make for a receive task with no message name.
-
-Operaton reads an `operaton:collection` value containing `{` as an expression and anything else as the literal name of a process variable, so the two readings are not interchangeable.
-A collection spelled as a plain identifier therefore prints bare and everything else prints quoted, since a bare `order.lines` would name a variable that does not exist.
-The whole construct is one optional field on the shape the seven activity nodes already share, so it reached every kind without adding an arm to any exhaustive switch in the transform.
-
-An `asyncBefore`, `asyncAfter`, `exclusive`, or `operaton:failedJobRetryTimeCycle` written on the `multiInstanceLoopCharacteristics` element imports as one of the four `run`-prefixed settings ADR-0041 gives the clause, rather than refusing.
-An `operaton:jobPriority` there is reported as a drop instead, because Operaton reads a job priority only in `createActivityOnScope`, off the step, and the repetition's own scope is never built there, so the setting reaches no job.
-An `operaton:outputParameter` on a repeated step refuses, because Operaton's `checkActivityOutputParameterSupported` rejects the deployment outright, and the validator stops an author writing one for the same reason.
-A `bpmn:standardLoopCharacteristics` imports as a step that runs once, with a warning naming the drop, because Operaton's `parseMultiInstanceLoopCharacteristics` looks only for a `bpmn:multiInstanceLoopCharacteristics` child and returns null otherwise, so the document deploys and the step runs once regardless of what the import does.
-A `bpmn:loopCardinality` body that is neither a run of digits nor an expression refuses, and that one is this tool's limit rather than the engine's.
-Operaton's `resolveLoopCardinality` reads `+3` as three, while this printer writes a count only as a plain number or as an expression, so the body would come back spelled differently.
-An `operaton:elementVariable` or a `bpmn:inputDataItem` name outside the grammar's `ID` terminal refuses on the same ground, since the clause writes the name each run sees as a bare identifier and has no second form for it: an element named `größe` would come back as a file this language cannot parse.
-Operaton's own parse errors refuse here rather than importing into a process that cannot deploy: a repetition with neither a count nor a collection, an element variable with no collection, and an empty `bpmn:loopCardinality`.
-
-### Consequences
-
-- Good, because a document the engine runs more than once now imports, prints, and recompiles byte for byte, where before it stopped the import at the first repeated activity.
-- Bad, because `asyncBefore: true` in a repeated step's settings no longer means what it meant before the clause existed.
- Operaton's `parseAsynchronousContinuationForActivity` reads a host element's async attributes onto the repetition, so it is one job around the whole loop rather than one per run.
- One job per run is a separate setting, `runAsyncBefore`, in the same parens (ADR-0041).
-- Neutral, because `operaton:collection` and `bpmn:loopDataInputRef` are one field to Operaton's `parseMultiInstanceLoopCharacteristics`, read in that order, so they import into one field here and the `operaton:` spelling is what gets written back.
- The BPMN slot holds the text of a variable name rather than a reference to an element in the document.
- `bpmn:inputDataItem` and `operaton:elementVariable` pair the same way, and a document setting both spellings of either pair gets a warning naming the one that was dropped.
-- Neutral, because a decompiled program declares `var : any` for every collection it iterates by name.
-- Neutral, because `nrOfInstances`, `nrOfActiveInstances`, `nrOfCompletedInstances`, and `loopCounter` are seeded into the process's variable table wherever something repeats, so `until (nrOfCompletedInstances >= 2)` validates with no declaration.
- Operaton's `MultiInstanceActivityBehavior` sets `loopCounter`, and its two concrete behaviors set the three counters.
-- Bad, because four ordinary English words stop being available as identifiers anywhere in a file.
-
-### Confirmation
-
-`packages/language/test/` pins every form of the clause token by token, its placement against the id and the settings, the output-parameter rule, the seeded variables, and what the position completes to.
-`packages/transform/test/` pins the lowering, each refusal by error class and by a substring of its message, each shadowing warning, the emitted child, and the printed line.
-The frozen pair `tests/golden/repetition.{bpmnscript,bpmn}` carries every form of the clause across eight statements of one process, and `tests/repetition.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all.
-`tests/e2e/repetition.test.ts` deploys to a real Operaton and drives it over REST, offering a parallel repetition's three runs at once and a sequential one's one at a time on the same activity.
-A completion condition ends a repetition after two runs, a count drives a service task, a collection binds each element in turn, and a count of zero never runs the step inside.
-
-## Pros and Cons of the Options
-
-### A header clause between a statement's id and its settings
-
-- Good, because the repetition reads as something done to one statement, which is what the engine does with it, and every statement keeps the settings it already had.
-- Bad, because it spends four keywords and touches nine grammar rules where the settings-key form would have touched none.
-
-### Settings keys
-
-- Bad, because `sequential: true` names the BPMN attribute rather than what the author means, which is the one thing ADR-0013 rules out.
-
-### A wrapping statement
-
-- Bad, because a wrapper over more than one statement needs a synthesized sub-process, so an imported activity comes back inside a container carrying an id the document never had.
-
-### Two heads
-
-- Bad, because a count and a collection are not exclusive in Operaton, so two heads would refuse a legal document or need a third spelling for the combination.
-
-### The element variable required
-
-- Bad, because a collection with no element variable deploys and runs, so the requirement costs an error path and a test to reject content the engine executes.
-
-### `bpmn:standardLoopCharacteristics` imported as a step that runs once
-
-- Good, because it accepts every document Operaton deploys and runs.
- `parseMultiInstanceLoopCharacteristics` looks only for a `bpmn:multiInstanceLoopCharacteristics` child, so the engine treats a standard loop as absent and runs the step once, the same behavior this import gives it, on every host the loop can sit on, including an event handler.
-- Bad, because the import drops the element the document wrote, keeping only a warning that names it, so a decompiled file carries no trace that the source declared a loop there.
-
-### `bpmn:standardLoopCharacteristics` refused
-
-- Bad, because Operaton deploys and runs a `bpmn:standardLoopCharacteristics` exactly as if it were absent, so refusing it rejects a document the engine executes the same way this import would import it.
-
-## More Information
-
-Amended by ADR-0041, which gives the clause four `run`-prefixed settings that write onto the `multiInstanceLoopCharacteristics` element itself and import an async, exclusive, or retry setting found there instead of refusing it.
-
-Related decisions: ADR-0010 (the synthesized ids a wrapping form would have had to invent).
-ADR-0013 (the rule that a keyword names what the author means, and the reason `sequential: true` is not one).
-ADR-0014 (the honest import contract behind every refusal and every warning here).
-ADR-0026 (the same call on a receive task with no message name, reused here for a collection with no element variable).
diff --git a/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md b/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md
deleted file mode 100644
index 6b28919..0000000
--- a/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md
+++ /dev/null
@@ -1,113 +0,0 @@
----
-status: accepted
-date: 2026-08-31
-decision-makers: Marlon Kranz
----
-
-# Work That Can Be Given Up: the `attempt` Block and the `cancel` Trigger
-
-## Context and Problem Statement
-
-BPMN has a container for work that is abandoned as a unit: a `bpmn:transaction`, given up by an end event inside it carrying a `bpmn:cancelEventDefinition`, caught by a boundary event of the same kind.
-Operaton rejects either cancel position outside such a container, so neither is expressible until the container is.
-This grammar had no container to put them in, so any document carrying the element refused on import and the round trip stopped there.
-What does the container head say, where may a cancel sit, and which shapes does the surface decline to carry?
-
-## Decision Drivers
-
-- ADR-0013 binds a keyword to what the author means rather than to the BPMN element behind it.
-- ADR-0014 refuses what changes the run and warns only about drops that do not.
-- A Langium statement keyword is lexer-global, so a word spent on a head is a name lost everywhere in a file.
-- Operaton decides what deploys here, and a surface stricter than the engine owes a reason.
-
-## Considered Options
-
-- `attempt` as a second head on the existing sub-process rule
-- `transaction` as the head, the BPMN element name
-- A settings key on an ordinary block, `subprocess X(transaction: true)`
-- A grammar rule of its own, with the second AST type that comes with it
-- `cancel` carried on the `end` statement the way `terminate` is, against `throw cancel`
-- A host-less `on cancel` handler
-- A cancel end with no handler refused on import
-- A non-interrupting cancel handler imported as an interrupting one
-
-## Decision Outcome
-
-Chosen options: `attempt` on the existing rule, `cancel` on the `end` statement, no host-less handler, the missing handler warned, and the non-interrupting handler refused.
-
-A block reads `attempt BookAndPay { ... }`, `end BookingAbandoned cancel` inside it gives that block up, and `on BookAndPay: cancel { ... }` catches it.
-
-`transaction` loses twice over.
-It is the BPMN element name, the one thing ADR-0013 rules out.
-It also promises what the engine does not deliver: Operaton's `BpmnParse.parseTransaction` installs the same `SubProcessActivityBehavior` that `parseSubProcess` installs for an ordinary block.
-Nothing is atomic and no transaction protocol runs: the tag buys the engine's acceptance of the two cancel positions, and nothing rolls back except what the author's own undo blocks reverse.
-`attempt` says what the author means, which is to run this block of work and, if it is given up, undo what it finished.
-
-A settings key fails the same test `sequential: true` failed in ADR-0027, since a boolean named after the element is that element wearing a DSL hat.
-A rule of its own loses on cost: the AST node stays `SubProcess`, so the scope provider, the linker, and every rule keyed on a sub-process reach the new head untouched.
-`attempt` is a hard keyword, so the name is lost everywhere in a file, while `cancel` stays a soft word that still lexes as an ordinary identifier.
-
-`cancel` is one word in both positions, which keeps the raise and the catch out of two vocabularies for one event.
-It rides the `end` statement rather than `throw`, because it carries no code, always ends its path, and BPMN raises a cancel from an end event only.
-`throw cancel` and `emit cancel` draw a diagnostic naming the right spelling instead.
-A host-less handler lowers to an event sub-process, and Operaton's `parseScopeStartEvent` fails the deployment of one whose start event carries a cancel definition, so the surface rejects it too.
-
-Four shapes refuse on import, each because Operaton itself rejects the deployment: a cancel end outside such a block, a cancel handler on any other host, a second handler on one block, and the cancel definition on the host-less handler's start event named above.
-`parseEndEvents` accepts a cancel end only where the container holding it is a transaction, so a branch inside the block is still inside it and a nested block is not.
-The two boundary shapes are consecutive checks in `parseBoundaryCancelEventDefinition`.
-A `cancelActivity="false"` handler refuses although the parser accepts it: `parseBoundaryEvents` gives it `ActivityStartBehavior.CONCURRENT_IN_FLOW_SCOPE`, and importing it as interrupting would change the run.
-
-A cancel end whose block carries no handler warns instead, on both sides.
-Parsing the handler is what wires the pair, since `parseBoundaryCancelEventDefinition` calls `setCancelBoundaryEvent` on every `CancelEndEventActivityBehavior` among the block's direct children.
-With no handler that reference stays null, and `CancelEndEventActivityBehavior.execute` opens on an `EnsureUtil.ensureNotNull` for it.
-Taking the handler out of the booking example shows both halves: the document deploys, and the first run stops with "Could not find cancel boundary event for cancel end event Activity(BookingAbandoned): cancelBoundaryEvent is null".
-Refusing would reject a document the engine accepts, which the import contract does not license, so both sides warn and name that runtime failure.
-A handler on a block whose body holds no cancel end warns for the same reason, since only a cancel end hands the run to it, through `CancelEndEventActivityBehavior.doLeave`.
-
-### Consequences
-
-- Good, because a document the engine runs as work that can be given up now imports, prints, and recompiles byte for byte, where before it stopped the import at the container.
-- Neutral, because giving up a block undoes the finished work of every child inside it that carries an undo block, and reaches nothing around it.
- `CompensationUtil.collectCompensateEventSubscriptionsForScope` walks up from the cancel end and stops at the block itself, so no scope outside it is collected.
- `createEventScopeExecution` registers the subscription when a child carrying an undo block completes, and `throwCompensationEvent` signals them newest first.
-- Neutral, because the undo runs first and the escape path second, always in that order.
- `CancelEndEventActivityBehavior.execute` throws the compensation synchronously, and only its `doLeave` hands the run to the handler.
-- Good, because a block written with the new head and carrying no cancel anywhere is an ordinary block of work to the engine, which is what lets an imported transaction round-trip unchanged.
-- Good, because such a block may carry its own `on compensation`, so an enclosing scope can undo it in turn.
- Operaton's `hasCompensationEventSubprocess` asks only whether the handler is a sub-process scope triggered by an event, never what tag the block it undoes carries.
-- Neutral, because `method` and `protocol` are reported on import and never written back, since `parseTransaction` reads no attribute of its own and the imported process runs exactly as the source document does.
-- Bad, because one more ordinary English word stops being available as an identifier anywhere in a file.
-
-### Confirmation
-
-`packages/language/test/` and `packages/transform/test/` pin both heads, both cancel positions, the placement rules, the two pairing warnings, the lowering, and the printed line.
-Every import refusal is pinned by its error class, and each one carrying wording of its own is compared against the whole message rather than a substring of it.
-The exception is a cancel definition on a handler's start event, pinned by the event kind and the definition type the error records.
-The frozen pair `tests/golden/transactions.{bpmnscript,bpmn}` nests the two heads inside each other each way round and hangs a cancel handler and an error handler on one block.
-That block holds an ordinary block with an undo block of its own, so a run that gives it up has finished work to undo.
-`tests/transactions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all.
-`tests/e2e/booking-attempt.test.ts` deploys to a real Operaton and drives it over REST, where the declined run leaves through the handler and the seat held earlier goes back on sale.
-Only the run that gave the block up records it as canceled, and the run that paid carries the booking past the block untouched.
-
-## Pros and Cons of the Options
-
-### `attempt` as a second head on the existing sub-process rule
-
-- Good, because the word names the intent rather than the tag, which is the test ADR-0013 sets.
-- Good, because it costs no AST type and no branch in anything that already asks whether a statement is a sub-process.
-
-### `transaction` as the head
-
-- Bad, because it is the BPMN element name and promises a rollback the engine never performs, so a reader learns the wrong thing twice.
-
-### A host-less `on cancel` handler
-
-- Bad, because it lowers to an event sub-process whose start event Operaton refuses to deploy, and BPMN has nothing that opens on a cancel.
-
-## More Information
-
-Related decisions: ADR-0013 (the rule that a keyword names what the author means, and the reason `transaction` is not one).
-ADR-0014 (the honest import contract behind every refusal and every warning here).
-ADR-0018 (the `on compensation` undo blocks that a given-up block runs, reached here with no `throw compensation` written anywhere in the source).
-ADR-0019 (the boundary attachment this decision extends with one trigger on one host kind).
-ADR-0020 (the intermediate catch that keeps cancel out of its four-trigger scope, whose two BPMN-legal positions this decision surfaces instead).
diff --git a/docs/decisions/0028-compensation-as-subprocess-undo-block.md b/docs/decisions/0028-compensation-as-subprocess-undo-block.md
new file mode 100644
index 0000000..dfc7c9e
--- /dev/null
+++ b/docs/decisions/0028-compensation-as-subprocess-undo-block.md
@@ -0,0 +1,67 @@
+---
+status: accepted
+date: 2026-07-18
+decision-makers: Marlon Kranz
+---
+
+# Compensation as a subprocess undo block
+
+## Context and Problem Statement
+
+BPMN gives a compensation handler two attachment points.
+One is a boundary event on the activity being compensated, connected by an association to a separate activity marked `isForCompensation`, an activity that never runs in the normal flow and only executes when compensation reaches it.
+The other is a compensation event sub-process, a `bpmn:subProcess` with `triggeredByEvent="true"` whose start event carries a `compensateEventDefinition`, nested directly inside the embedded sub-process it compensates.
+The engine treats the two as alternatives for the same intent, reversing the completed work of an activity, but restricts the event-sub-process form to embedded sub-processes, not to a separate process instance a call activity spawns, and caps it at one per sub-process level.
+The engine also does not support `waitForCompletion="false"` on a compensation intermediate throw event: the throw always blocks until the triggered compensation finishes, regardless of what the attribute says.
+The question this decision settles is which of BPMN's two attachment shapes, or a third designed one, the DSL exposes for compensation, and how throwing compensation fits the `on`/`throw`/`emit` vocabulary the rest of the event layer already uses.
+
+## Decision Drivers
+
+- The engine restricts one of the two BPMN attachment forms, the compensation event sub-process, to embedded sub-processes and one per level; a surface that treats both forms as equally general everywhere would produce documents the engine does not support.
+- The audience writes processes without BPMN vocabulary already established for `on`/`throw`/`emit`; a second attachment mechanism, boundary event, association, and a normally-dormant activity, for one event kind breaks the pattern every other trigger kind follows.
+- `waitForCompletion="false"` and `activityRef`-targeted throws only matter in combination with boundary-attached handlers; neither has a use once boundary handlers are out of scope.
+- The honest import contract (ADR-0012) already distinguishes constructs the DSL refuses from ones it silently drops; a construct this decision does not model needs to land on one side of that line, not the other.
+
+## Considered Options
+
+- Compensation handlers only as a subprocess's `on compensation` block, thrown with the existing `throw`/`emit` pair and no code, which reuses the subprocess construct and the event layer's own vocabulary and matches the engine's restriction to an embedded sub-process, at the cost of one wrapping `subprocess` per step where per-activity granularity is wanted.
+- Boundary compensation events with an associated `isForCompensation` activity, which lets a single activity carry its own handler without a wrapper, at the cost of a whole second attachment axis and a kind of activity that is dormant except when triggered, with no equivalent anywhere else in the grammar.
+- A throw targeted at a specific activity by name (`activityRef`), which only has a target worth naming once boundary-attached handlers exist to be the targets, and which would need a reference-resolution mechanism the event layer does not otherwise require.
+- A dedicated `compensate` verb, kept separate from `emit`, which reads unambiguously as compensation but grows the continuing-throw vocabulary to two words for one behavior, and would have to be a reserved keyword, unlike every trigger name and binding field.
+
+## Decision Outcome
+
+Chosen option: "Compensation handlers exist only as a subprocess's `on compensation` block, thrown with the existing `throw`/`emit` pair and no code", because it keeps compensation inside the vocabulary the rest of the event layer already uses, and the rejected alternatives either duplicate a granularity the subprocess construct already provides or only pay for themselves once that duplication exists.
+
+An undo block lowers to a `bpmn:subProcess triggeredByEvent="true"` nested inside the `bpmn:subProcess` it undoes, whose start event carries a `compensateEventDefinition`.
+The at-most-one rule and the container rule are the engine's own restriction rather than a choice of this surface, and a process has no completed enclosing scope to reverse, so `on compensation` at process level is rejected rather than approximated.
+Compensation reaches an undo block by two routes: `throw compensation` or `emit compensation` in an enclosing scope, and a cancel end that gives up an enclosing `attempt` block, where Operaton's `CancelEndEventActivityBehavior` throws the compensation itself before handing the run to that block's cancel handler.
+
+Compensation joins `throw`/`emit` on the same terms every other trigger kind already established (ADR-0023).
+It carries no code, because compensation has no name to correlate against, unlike error or escalation, so there is nothing for a code string to select.
+`emit compensation` always waits for the triggered undo to finish before falling through, matching the engine, which does not honor `waitForCompletion="false"` in the first place; the surface has no attribute for a behavior the engine ignores.
+
+Boundary compensation events, `activityRef`-targeted throws, `waitForCompletion="false"`, and `isForCompensation` activities are all refused on import under the honest import contract (ADR-0012), each with a diagnostic naming the construct rather than a silent drop or an approximation.
+When the boundary event and the `isForCompensation` activity are paired through a `bpmn:association`, the diagnostic also names the compensated activity, the boundary event, and the handler, and prints the `subprocess`/`on compensation` rewrite an author would write by hand.
+
+BPMN's boundary-event form exists chiefly to compensate a single activity rather than a whole embedded sub-process.
+BPMNscript reaches the same granularity without a second mechanism: wrapping the one step that needs to be undoable in its own `subprocess` gives it its own `on compensation` block, scoped to exactly that step.
+
+### Consequences
+
+- Good, because a saga, several steps, each undoable, unwound in reverse when a later step fails, round-trips through the DSL and executes on the engine using only constructs the surface already has: `subprocess`, `on`, `throw`, `emit`.
+- Good, because compensation reuses the same verb pair, the same catch-block reading, and the same container rule every other trigger kind already follows.
+- Good, because an import carrying the boundary-event pattern, a targeted throw, or `waitForCompletion="false"` refuses outright, rather than reporting nothing and reproducing a different execution than the one the imported document specifies.
+- Bad, because an author porting an existing BPMN model that uses boundary compensation events must restructure it, pulling the compensated activity and its handler into a `subprocess`, rather than have the importer translate the pattern automatically.
+- Bad, because per-activity compensation on many independent steps means one small `subprocess` wrapper per step; the surface trades a second attachment mechanism for a per-step wrapping requirement instead.
+- Neutral, because compensation's ordering, reverse of execution, invocation count, and the variable snapshot an undo block sees are entirely engine behavior, outside anything this surface models.
+
+### Confirmation
+
+Round-trip coverage in `packages/transform/test` pins an undo block lowering to a nested `triggeredByEvent` sub-process with exactly one `compensateEventDefinition`-carrying start event, `throw compensation` and `emit compensation` lowering to a compensation end event and a compensation intermediate throw without an `activityRef`, and each of the four refused shapes producing a distinct refusal rather than a silently altered import.
+`packages/language/test/validating.test.ts` pins the container rule, including the rejection of a second undo block in one `subprocess` or one `attempt`.
+Two suites under `tests/e2e/` deploy to a real Operaton and confirm that an undo block executes on a running engine rather than only lowering to the right shape: an `emit compensation` raised by a process-level error handler reaching the undo block of a subprocess that had already completed, and a completed subprocess's undo steps running once the `attempt` block around it is given up.
+
+## More Information
+
+Related decisions: ADR-0029 (work that can be given up, whose cancel end reaches these same undo blocks with no `throw compensation` written anywhere in the source).
diff --git a/docs/decisions/0029-one-bracket-shape-for-every-element.md b/docs/decisions/0029-one-bracket-shape-for-every-element.md
deleted file mode 100644
index 1a1b595..0000000
--- a/docs/decisions/0029-one-bracket-shape-for-every-element.md
+++ /dev/null
@@ -1,150 +0,0 @@
----
-status: accepted
-date: 2026-09-06
----
-
-# One bracket shape for every element
-
-## Context and Problem Statement
-
-The surface spells a value three different ways depending on where the value sits.
-An engine setting takes `=` inside an attribute block, as in `user Review { assignee = "demo" }`.
-A form field type and a variable type take `:`, as in `amount: number` and `var score: number`.
-A label and a trigger particle take bare position, as in `start Placed "Order placed"` and `on Review timer after "PT2H" alongside`.
-
-Nothing about an element decides which of the three applies.
-An author has to remember the spelling per position rather than derive it from the shape, and the printer has to choose one per position rather than emit one form.
-
-The same brace carries two unrelated jobs on top of that.
-`{ }` holds engine settings on a task and holds the flow inside a subprocess, and a reader cannot tell which without reading the contents.
-
-Which surface shape should every element share?
-
-## Decision Drivers
-
-- One rule an author can derive the next line from, rather than three rules indexed by position.
-- A delimiter that says what kind of content it holds, so settings and flow are distinguishable before they are read.
-- No new reserved words, since ADR-0002 puts the vocabulary in one place and ADR-0008 already fixed the grammar as structured rather than prose.
-- A shape the printer can emit mechanically, because every construct round-trips through `ir-to-dsl`.
-- Diff behaviour, since a process under version control is edited attribute by attribute.
-
-## Considered Options
-
-- Colon configuration, where `:` introduces every detail, type, value and label alike
-- Brackets and blocks, where `( )` carries what an element is and `{ }` carries what runs inside it
-- Keyword prose, where small words such as `is`, `means`, `assigned` and `catching` replace the punctuation
-- Off-side, where indentation carries the structure and every detail is a `key: value` child
-- Decorators, where each attribute is its own `@name value` line above a bare `keyword id`
-- Binding form, where every element is written as an assignment, `Id = kind(name = value)`
-
-## Decision Outcome
-
-Chosen option: "Brackets and blocks", because it is the only option that makes the delimiter carry the distinction the three spellings were failing to make.
-
-The shape is `verb Id(attributes) { children }`.
-`( )` holds the scalar settings that describe the element.
-`{ }` holds everything with internal structure, which means the flow inside a container and the member lists on a step.
-An element with no members has no brace, so `user Review(label: "Review the order", assignee: "demo")` is a complete statement, and an element with no settings has no parens.
-
-It adds no reserved words.
-The `:` inside the parens is the separator the form block and the `var` declaration already use, so the change removes two of the three spellings rather than introducing a fourth.
-
-### What the shape alone does not decide
-
-The variant was drafted as a rendering of one process rather than as a grammar, and four of its lines do not parse unambiguously as written.
-Each is settled here, because leaving any of them to the implementation would decide the language by accident.
-
-A handler keeps the colon after its host, as in `on Pack: error(OUT_OF_STOCK, code: c)`.
-Dropping it makes a hostless handler carrying a code and a hosted handler carrying none both read as `on` followed by two identifiers.
-No lookahead separates those, because the trigger words are soft identifiers and the host is a cross-reference the parser cannot resolve while it is parsing.
-The colon is already in the grammar for this reason, so keeping it costs nothing and settles the same question for `throw` and `emit`.
-
-A trigger keeps its own slot, so an event-bearing element reads `verb Id trigger(payload, settings) { children }`.
-The payload is positional, since `TriggerPayloadRule` gives every trigger at most one code, and a key would spell that code's role twice.
-So a message names itself, `start Placed message("OrderReceived")`, and an error names its declaration, `throw error(OUT_OF_STOCK)`.
-An error is raised with `throw` rather than on an end event, which ADR-0024 settled and this decision does not reopen: `throw` already lowers to a `bpmn:endEvent` carrying an `errorEventDefinition`, so an `end` spelling would be a second way to write one element.
-A timer reads its bare payload as a duration, `timer("PT2H")`, and takes `at` or `every` as a key when it means a date or a cycle.
-A listener's own timer clause stays positional, as in `on timeout after "PT8H"(class: "...")`, because a listener is a callback rather than an element and its parens already carry the binding that says what to run.
-
-Form blocks, listeners, io parameters and call mappings live in the braces.
-The parens carry settings that describe the element itself, and those four are lists with their own members, so they belong to the same half of the shape as flow does.
-This is why `{ }` is defined above as everything with internal structure rather than as what runs: a form does not run, and forcing it into a parenthesised value would nest a listener's own settings two levels deep.
-A form field takes the same shape in turn, `id: type "label" = default (settings) { members }`, its constraints in the parens and an enum's values and its property lines in the braces (ADR-0037).
-An external task's `error when ` mappings live in the braces too, one member line each (ADR-0038).
-
-A setting with no value is written as a bare word inside the parens, as in `timer("PT2H", alongside)`.
-The parens therefore hold a payload, then keyed settings, then bare flags, told apart by whether a `:` follows.
-
-### Consequences
-
-- Good, because an author who has seen one element can write any other, and the delimiter alone says whether a construct nests.
-- Good, because the printer emits one shape rather than choosing a spelling per position, which removes a class of difference between an authored file and a generated one.
-- Good, because the brace disambiguation that a settings block and a body block needed disappears; a `{` now opens children and nothing else.
-- Bad, because a bare timer payload means a duration, so `timer("P1D")` and `timer(every: "P1D")` differ by a word while their strings look identical.
-- Bad, because an event binding written `code: c` is no longer distinguishable by shape from a setting, so the rule that a binding declares a variable while a setting reads one moves from the grammar into the validator.
-- Bad, because a lone identifier in parens is a payload rather than a flag, which `condition(ready)` needs an ordering rule to resolve rather than a shape.
-- Bad, because a flag is only bare to the reader: `alongside`, `sequentially` and `local` are keywords and never lex as identifiers, so the grammar names the legal flags rather than accepting any word in that position.
-- Bad, because every example, fixture and document showing the surface is respelled once, and the four TextMate grammars cannot import the vocabulary, so each needs a test that fails when it drifts.
-
-### Confirmation
-
-Every construct prints through `ir-to-dsl` and re-parses through the compiler, so a shape the printer emits but the parser rejects fails the suite rather than reaching a file.
-The golden fixtures under `tests/golden` and the examples under `examples/spring-boot/processes` compile in the suite, so the respelling is confirmed by the same gate that confirms the language.
-
-A handler and a listener are told apart today by which brace encloses them, and this decision puts both in the same one.
-What separates them afterwards is the handler's own body block, which is mandatory where a listener has none once its settings move into the parens.
-The word after `on` plays no part in it, so the parse stays unambiguous even if a trigger word and a listener event ever collide.
-A collision would still leave `on ` meaning two things to a reader and to the validator, so the two vocabularies are held disjoint by a test rather than by the grammar.
-
-The TextMate keyword alternation must stay equal to the grammar's reserved words, since three of the four grammars are generated or copied and are gitignored, which makes a stale artifact invisible in review.
-
-## Pros and Cons of the Options
-
-### Colon configuration
-
-- Good, because there is one character to learn and nothing depends on position.
-- Bad, because it is wordier than what it replaces, with every label becoming its own `label:` line.
-- Bad, because the brace still carries two jobs, which leaves half the original problem in place.
-
-### Brackets and blocks
-
-- Good, because one shape covers every element without adding a keyword.
-- Good, because the two delimiters split the two kinds of content, so nesting is visible before the content is read.
-- Bad, because settings lists are longer and wrap.
-- Bad, because `user Review(...)` reads like a function call rather than a step.
-
-### Keyword prose
-
-- Good, because it reads almost like English and forgives a reader seeing the language for the first time.
-- Bad, because every added word, `is`, `means`, `assigned`, `catching`, has to be learned and spelled correctly, which trades punctuation the author can see for vocabulary the author must recall.
-- Bad, because it adds reserved words to a grammar ADR-0008 kept structured on purpose.
-
-### Off-side
-
-- Good, because it uses the fewest separators of any option and the page stays quiet.
-- Bad, because whitespace becomes meaningful, so a paste at the wrong indentation changes the model instead of failing to parse.
-- Bad, because the printer has to indent exactly on the way back from BPMN, which makes every generated file a whitespace risk.
-
-### Decorators
-
-- Good, because the line is always `keyword id`, and one attribute per line keeps diffs clean.
-- Bad, because it is read bottom-up, with the configuration before the element it belongs to.
-
-### Binding form
-
-- Good, because it sits closest to the data and maps onto a builder API.
-- Bad, because it reads like configuration rather than a flow, and `if` and `on` stand out as foreign in it.
-
-## More Information
-
-The six options were drafted as full renderings of the same process, `order-handling`, so each was judged on a realistic file rather than a fragment.
-
-Amended by ADR-0037, which gives a form field the parens-and-braces shape this decision gives every element.
-
-Amended by ADR-0038, which adds an external task's error mappings to the members the braces hold.
-
-Related decisions: ADR-0008 (the structured grammar this shape fills in, which decided against prose but not the bracket unification).
-ADR-0030 (error and escalation codes as declared names, which fills the one payload slot this shape left holding quoted text).
-ADR-0013 (the target audience, the reason the function-call reading of `user Review(...)` is a cost worth naming rather than ignoring).
-ADR-0017 (the trigger payload surfaces, whose `parens` discriminator changes meaning here, since every trigger now takes parens and the field says which payload they carry).
-ADR-0023 (listeners on the attribute block, whose title describes a block this decision splits).
diff --git a/docs/decisions/0029-work-that-can-be-given-up.md b/docs/decisions/0029-work-that-can-be-given-up.md
new file mode 100644
index 0000000..c6b1328
--- /dev/null
+++ b/docs/decisions/0029-work-that-can-be-given-up.md
@@ -0,0 +1,73 @@
+---
+status: accepted
+date: 2026-08-31
+decision-makers: Marlon Kranz
+---
+
+# Work that can be given up: `attempt` and `cancel`
+
+## Context and Problem Statement
+
+BPMN has a container for work abandoned as a unit: a `bpmn:transaction`, given up by an end event inside it carrying a `bpmn:cancelEventDefinition`, caught by a boundary event of the same kind.
+Operaton rejects either cancel position outside such a container, so neither is expressible until the container is.
+This grammar had no container to put them in, so any document carrying the element refused on import and the round trip stopped there.
+The question this decision settles: what does the container head say, where may a cancel sit, and which shapes does the surface decline to carry?
+
+## Decision Drivers
+
+- ADR-0006 binds a keyword to what the author means rather than to the BPMN element behind it.
+- ADR-0012 refuses what changes the run and warns only about drops that do not.
+- A Langium statement keyword is lexer-global, so a word spent on a head is a name lost everywhere in a file.
+- Operaton decides what deploys here, and a surface stricter than the engine owes a reason.
+
+## Considered Options
+
+- `attempt` as a second head on the existing sub-process rule, which names the intent rather than the tag and costs no AST type and no branch in anything that already asks whether a statement is a sub-process.
+- `transaction` as the head, which is the BPMN element name and promises what the engine does not deliver: `BpmnParse.parseTransaction` installs the same `SubProcessActivityBehavior` that `parseSubProcess` installs for an ordinary block, so nothing is atomic, no transaction protocol runs, and nothing rolls back except what the author's own undo blocks reverse.
+- A settings key on an ordinary block, `subprocess X(transaction: true)`, which fails the same test `sequential: true` failed for repetition (ADR-0022), since a boolean named after the element is that element wearing a DSL hat.
+- A grammar rule of its own, which loses on cost: keeping the AST node `SubProcess` is what lets the scope provider, the linker, and every rule keyed on a sub-process reach the new head untouched.
+- `cancel` carried on the `end` statement the way `terminate` is, rather than `throw cancel`, because it carries no code, always ends its path, and BPMN raises a cancel from an end event only.
+- A host-less `on cancel` handler, which lowers to an event sub-process whose start event `parseScopeStartEvent` fails the deployment of, and which BPMN gives nothing to open on.
+- A cancel end with no handler refused on import, rather than warned about, which would reject a document the engine deploys.
+- A non-interrupting cancel handler imported as an interrupting one, rather than refused, which would change the run.
+
+## Decision Outcome
+
+Chosen options: `attempt` on the existing sub-process rule, `cancel` on the `end` statement, no host-less handler, the missing handler warned rather than refused, and the non-interrupting handler refused.
+
+A block reads `attempt BookAndPay { ... }`, `end BookingAbandoned cancel` inside it gives that block up, and `on BookAndPay: cancel { ... }` catches it.
+`attempt` says what the author means: run this block of work and, if it is given up, undo what it finished.
+It is a hard keyword, so the name is lost everywhere in a file, while `cancel` is one word in both of its positions and stays a soft word that still lexes as an ordinary identifier, keeping the raise and the catch out of two vocabularies for one event.
+
+Four shapes refuse on import, each because Operaton itself rejects the deployment: a cancel end outside an `attempt` block, a cancel handler on any other host, a second handler on one block, and the cancel definition on a host-less handler's start event.
+`parseEndEvents` accepts a cancel end only where the container holding it is a transaction, so a branch inside the block is still inside it and a nested block is not, and the two boundary shapes are consecutive checks in `parseBoundaryCancelEventDefinition`.
+A `cancelActivity="false"` handler refuses although the parser accepts it: `parseBoundaryEvents` gives it `ActivityStartBehavior.CONCURRENT_IN_FLOW_SCOPE`, and importing it as interrupting would change the run.
+
+A cancel end whose block carries no handler warns instead, on both sides.
+Parsing the handler is what wires the pair: `parseBoundaryCancelEventDefinition` calls `setCancelBoundaryEvent` on every `CancelEndEventActivityBehavior` among the block's direct children.
+With no handler that reference stays null, and `CancelEndEventActivityBehavior.execute` opens on an `EnsureUtil.ensureNotNull` for it, so the document deploys and the first run stops with "Could not find cancel boundary event for cancel end event Activity(BookingAbandoned): cancelBoundaryEvent is null".
+Refusing would reject a document the engine accepts, which the import contract does not license, so both sides warn and name that runtime failure.
+A handler on a block whose body holds no cancel end warns for the same reason, since only a cancel end hands the run to it, through `CancelEndEventActivityBehavior.doLeave`.
+
+### Consequences
+
+- Good, because a document the engine runs as work that can be given up now imports, prints, and recompiles byte for byte, where before it stopped the import at the container.
+- Good, because a block written with the new head and carrying no cancel anywhere is an ordinary block of work to the engine, which is what lets an imported transaction round-trip unchanged.
+- Good, because such a block may carry its own `on compensation`, so an enclosing scope can undo it in turn: Operaton's `CompensationUtil.hasCompensationEventSubprocess` asks only whether the handler is a sub-process scope triggered by an event, never what tag the block it undoes carries.
+- Neutral, because giving up a block undoes the finished work of every child inside it that carries an undo block, and reaches nothing around it.
+ `CompensationUtil.collectCompensateEventSubscriptionsForScope` walks up from the cancel end and stops at the block itself, `createEventScopeExecution` registers the subscription when a child carrying an undo block completes, and `throwCompensationEvent` signals them newest first.
+- Neutral, because the undo runs first and the escape path second, always in that order: `CancelEndEventActivityBehavior.execute` throws the compensation synchronously, and only its `doLeave` hands the run to the handler.
+- Neutral, because `method` and `protocol` are reported on import and never written back, since `parseTransaction` reads no attribute of its own and the imported process runs exactly as the source document does.
+- Bad, because one more ordinary English word, `attempt`, stops being available as an identifier anywhere in a file.
+
+### Confirmation
+
+`packages/language/test/` and `packages/transform/test/` pin both heads, both cancel positions, the placement rules, the two pairing warnings, the lowering, and the printed line.
+Every import refusal is pinned by its error class, each one carrying wording of its own compared against the whole message rather than a substring of it; the exception is the cancel definition on a handler's start event, pinned by the event kind and the definition type the error records.
+The frozen pair `tests/golden/transactions.{bpmnscript,bpmn}` nests the two heads inside each other each way round, hangs a cancel handler and an error handler on one block, and holds an ordinary block with an undo block of its own so a run that gives it up has finished work to undo; `tests/transactions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all.
+`tests/e2e/booking-attempt.test.ts` deploys to a real Operaton and drives it over REST: the declined run leaves through the handler and the seat held earlier goes back on sale, only the run that gave the block up records it as canceled, and the run that paid carries the booking past the block untouched.
+
+## More Information
+
+Related decisions: ADR-0012 (the honest import contract behind every refusal and every warning here).
+ADR-0028 (the `on compensation` undo blocks a given-up block runs, reached here with no `throw compensation` written anywhere in the source).
diff --git a/docs/decisions/0030-error-and-escalation-codes-as-declared-names.md b/docs/decisions/0030-error-and-escalation-codes-as-declared-names.md
deleted file mode 100644
index 2cd5f95..0000000
--- a/docs/decisions/0030-error-and-escalation-codes-as-declared-names.md
+++ /dev/null
@@ -1,148 +0,0 @@
----
-status: accepted
-date: 2026-09-07
-decision-makers: Marlon Kranz
----
-
-# Error and escalation codes as declared, referenced names
-
-## Context and Problem Statement
-
-BPMN keeps a code in two places.
-A `bpmn:Error` or `bpmn:Escalation` root element sits under `bpmn:Definitions` and carries the code, and every event definition that raises or catches that code references the root by id.
-ADR-0016 settled that split by deriving the root from usage, so an author writes the code at the use site and the compiler builds the root behind it.
-
-That is enough for a code that is only thrown and caught.
-It does not give the code an identity the editor can address.
-A site that raises a code and a site that catches it are two string literals that happen to match, so there is nothing to jump to, nothing to rename, and a typo in one of them is a second code rather than a mistake.
-ADR-0001 makes editor support the core value of a textual DSL, and a quoted code is the one payload shape none of that support reaches.
-
-What gives an error or escalation code an identity the language server can resolve, without reopening the paren shape ADR-0029 settled?
-
-## Decision Drivers
-
-- Jump-to-definition, find-references, rename and completion are the contribution a textual DSL makes over a graphical editor (ADR-0001).
- A payload the language server cannot resolve is where that contribution stops.
-- A mistyped code has to be a diagnostic at the site that wrote it, rather than a second code the export writes a root for.
-- Every element is spelled `verb Id(settings)` after ADR-0029, and the error declaration is the last construct with a shape of its own.
-- The round trip is byte-exact against frozen artifacts, so import has to reproduce the codes a document arrives with, including a code no identifier can spell and a root nothing raises.
-- Whatever resolves a code has to leave every other identifier alone.
- A bare word in `condition(ready)` or in `if (amount > 100)` is a variable, and the undeclared-variable warning is the diagnostic its author needs.
-
-## Considered Options
-
-For a code's identity:
-
-- A declaration in the process header that every use site references
-- No declaration, with the validator checking each use site against the set of codes it collects from the document
-- A quoted code at each use site, which is what ADR-0016 settled
-
-For where the reference is carried:
-
-- A cross-reference alternative of its own in the shared paren rule
-- The reference on `VarRef`, the rule every identifier in an expression already builds, separated by scope
-
-For raising an error:
-
-- `throw error(...)` as the only spelling
-- A second spelling on the end statement, `end Finished error(...)`
-
-## Decision Outcome
-
-Chosen: a declaration in the process header, referenced by a bare name at every use site, with the reference carried on `VarRef` and the two readings of a bare word separated by scope.
-
-An error or escalation code is declared once, beside the `var` declarations at the top of the process body, and named wherever it is raised or caught.
-
-```bpmnscript
-process order-processing {
- error PAYMENT_DECLINED(message: "The payment was declined")
- error OrderFailed(code: "order.failed")
- escalation MANUAL_REVIEW
-
- user Pay
- throw error(PAYMENT_DECLINED)
-
- on Pay: escalation(MANUAL_REVIEW) { user Review }
-}
-```
-
-The declaration is `verb Id(settings)` like every other element, so ADR-0029's shape covers the surface with no construct left over.
-`message` is the text a thrown error carries at runtime, the one piece of root data usage cannot supply and the reason ADR-0016 gave the declaration a form at all.
-A code no identifier can spell lives on the declaration as a `code` setting, so the use site is a bare name in every case and the awkward spelling is written once instead of at each of its uses.
-
-The name and the code are both carried into BPMN, `@name` holding the declared name and `errorCode` or `escalationCode` holding the code, and they are the same text wherever the code is identifier-shaped.
-Import reads the pair back off each root: `readCodeDecls` synthesizes one declaration per `bpmn:Error` and `bpmn:Escalation`, taking the root's `name` where a declaration can be written with it and minting one from the code otherwise (`claimDeclarationName`, `packages/transform/src/synthesize-ids.ts`).
-A root nothing raises therefore imports as a declaration nothing uses, so `unreferencedRoot` covers `bpmn:Message` and `bpmn:Signal`, plus an error or escalation root carrying no code at all, which nothing can key it by.
-
-The reference sits on `VarRef` rather than on an alternative of its own, and this was measured against the grammar rather than reasoned about.
-The parser cannot separate `error(OUT_OF_STOCK)` from `condition(ready)`, since both are one identifier inside the same `SettingsParens`, and adding a cross-reference arm to `ParenItem` made Chevrotain report `AMBIGUOUS_ALTERNATIVES` and parse the condition as a reference to a declaration.
-So every identifier in every expression builds the same node, and what separates a code from a variable is the scope it resolves in.
-`BpmnScriptScopeProvider` gives a bare word the enclosing process's declarations of its own kind in a code position, and `EMPTY_SCOPE` everywhere else.
-Holding the scope to one kind is what makes `escalation(X)` naming an error fail rather than reach the XML as a second root, since an error and an escalation are separate event definitions even under one code.
-Outside a code position the link fails by design, and `BpmnScriptDocumentValidator` drops that failure before it becomes a diagnostic, so an ordinary expression sees the undeclared-variable warning and nothing else.
-
-Rejected: collecting the codes a document uses and checking each site against that set in the validator.
-It reports the same typo, and it is the smaller implementation, but a diagnostic is all it produces.
-The four editor features above read the reference graph, so a design without a reference gives up exactly what motivates the change.
-
-Rejected: `end Finished error(...)` as a second way to raise an error.
-`throw` is the statement that raises one, which ADR-0024 settled, and it already lowers to a `bpmn:endEvent` carrying an `errorEventDefinition`.
-An `end` spelling adds a reading without adding a meaning, and leaves one IR shape with two printed forms for the printer to choose between, which is the argument ADR-0024 rejected `end E message("Name")` on.
-
-### Consequences
-
-- Good, because a code has a definition to jump to, a name to rename across every site at once, and a completion list wherever one is legal.
-- Good, because an unresolved code is an error naming the declaration to add, rather than a warning about an undeclared variable that reads as though the author meant a variable.
-- Good, because a `bpmn:Error` or `bpmn:Escalation` root nothing raises survives the round trip as a declaration, which a design deriving roots from usage alone has nowhere to put.
-- Good, because a code no identifier can spell has one home, so a document arriving with `order.failed` round-trips without that text appearing at every site that raises it.
-- Bad, because every code needs a declaration line.
- That is the ceremony ADR-0016 declined on purpose, and an author who writes a single `throw error(...)` in a five-step process pays a header line for one use.
- The cost is accepted rather than argued away: it buys editor behaviour for a code with several sites, and it buys nothing at all for a code with one.
-- Bad, because a name and a code can differ, so a reader of `throw error(OrderFailed)` cannot see the text the engine matches on without reading the header.
-- Bad, because the grammar cannot carry the distinction the surface makes, so it is spread over a scope provider, a linker that rewords the failure, and a document validator that drops the failures the author never asked to resolve.
- A reader looking for why `condition(ready)` is not a code has three files to find rather than a grammar rule.
-
-### Confirmation
-
-The golden pair `tests/golden/event-handlers.bpmnscript` and `tests/golden/event-handlers.bpmn` carries the two shapes this decision adds rather than respells: a declaration whose code no name can spell, `error GatewayTimeout(code: "gateway.timeout")`, and a declaration nothing raises or catches, `error STOCK_UNAVAILABLE(...)`.
-Both gates see them, the byte comparison against the frozen `.bpmn` and the IR-equality comparison across `DSL -> IR -> XML -> IR -> DSL`, so a name that fails to reach the root or a root that fails to import as a declaration fails the suite (`tests/event-handlers.round-trip.test.ts`).
-
-`packages/language/test/scoping.test.ts` pins the scope in both directions: a bare word in a code position resolves to a declaration of its own kind and to no declaration of the other kind or of another process, while the same word in an `if` condition resolves to nothing and keeps its undeclared-variable warning.
-
-## Pros and Cons of the Options
-
-### A collected code set checked in the validator
-
-Every use site stays a bare word, and the validator gathers the codes the document declares and reports a site naming none of them.
-
-- Good, because it needs no scope provider, no linker wording and no filtering of link failures: one validator rule replaces three mechanisms.
-- Good, because a bare word stays a bare word to every other part of the toolchain, so nothing else has to know a code position exists.
-- Bad, because it produces a diagnostic and nothing else.
- The editor features that motivate the change read the reference graph, and a validator rule builds none.
-
-### A cross-reference alternative in the shared paren rule
-
-`ParenItem` would gain an arm matching a reference directly, so a code would be a distinct node from an expression and the scope would not have to separate them.
-
-- Good, because the distinction would live in the grammar, where a reader looking for it would find it in one place.
-- Bad, because it does not parse.
- `error(OUT_OF_STOCK)` and `condition(ready)` are the same tokens in the same position, and the arm made Chevrotain report `AMBIGUOUS_ALTERNATIVES` and resolve the ambiguity by parsing the condition as a reference.
-
-### `end Finished error(...)`
-
-An error would also be raisable by naming it on an end statement, next to the `terminate` and `cancel` that an `end` already carries.
-
-- Good, because `end` carries a label and `throw` does not, so a labeled error end would keep its caption.
-- Bad, because one IR shape would carry two printed forms and the printer would have to choose one, which is what ADR-0024 rejected `end E message("Name")` on.
-
-## More Information
-
-Amends ADR-0016, whose derived-root design this decision keeps and whose rejection of explicit declarations it reverses on an argument that decision did not weigh.
-Amends ADR-0014, whose warned-construct list no longer covers a coded error or escalation root, and ADR-0017, whose contrast between message and signal roots and the error root's declaration now runs along a different line.
-
-Related decisions: ADR-0029 (the bracket shape the declaration takes, and the ordering rule that makes a lone identifier in parens a payload).
-ADR-0024 (the verbs at the process boundary, and the reason an error is raised with `throw` alone).
-ADR-0010 (the sanitize-then-suffix collision rule, which the name minted for an unspellable code follows the same way a synthesized id does).
-ADR-0002 (Langium, whose scope provider, linker and document validator are the three seams this decision uses, none of which the grammar could replace).
-
-Amended by ADR-0038, which adds a third site naming a declared code beside a throw and a catch: an external task's `error when ` mapping, whose code resolves in the same scope and derives the same root.
diff --git a/docs/decisions/0030-link-events-for-import-round-trip-symmetry.md b/docs/decisions/0030-link-events-for-import-round-trip-symmetry.md
new file mode 100644
index 0000000..fe33c1f
--- /dev/null
+++ b/docs/decisions/0030-link-events-for-import-round-trip-symmetry.md
@@ -0,0 +1,91 @@
+---
+status: accepted
+date: 2026-09-12
+decision-makers: Marlon Kranz
+---
+
+# Link events for import round-trip symmetry
+
+## Context and Problem Statement
+
+BPMNscript's `await` trigger scope covers message, timer, signal, and conditional, and leaves link out: BPMN's off-page connector for splitting one diagram across pages, a problem a single `.bpmnscript` file never has, since `goto` already names a jump inside one textual source.
+That choice holds for authoring, but it says nothing about import.
+A modeller's existing document can carry a link pair, and the importer refuses `bpmn:linkEventDefinition` by name on both the intermediate throw and the intermediate catch, so such a document does not import at all.
+Reading a link pair as a `goto` would not fix this under the honest import contract (ADR-0012), which lets the importer warn on a drop that changes nothing the engine runs but never lets it rewrite what is on the wire into a shape the surface prefers: a `goto` compiles to one sequence flow, so the re-exported XML would hold one edge where the incoming document held two events.
+The question this decision settles is whether a construct authoring does not need should exist anyway, for better import coverage of existing models and round-trip symmetry.
+
+## Decision Drivers
+
+- Import coverage of existing models.
+ Modelers built on bpmn-js, the Camunda Modeler among them, offer both ends of a link pair, and a document drawn with one is refused today.
+- Round-trip symmetry under ADR-0012.
+ A construct the importer maps has to re-export as the same XML, and a link pair mapped to a `goto` does not.
+- Reuse of what is built.
+ ADR-0024's payload surfaces already carry a quoted name for message and signal, and ADR-0023's terminality rule already decides from the printed words whether a statement ends its chain; a fifth catch kind should cost one dispatch case per layer, not a second mechanism.
+- The engine's own link semantics.
+ Operaton correlates the two ends by name in one table per parsed file, creates no activity for the throw, and rewires flows at deploy time; those facts decide most of the validator rules before this surface gets a say.
+- The cost of a second way to spell a jump.
+ Two constructs with one meaning is a real price, and this decision has to say what is bought for it.
+
+## Considered Options
+
+- Keep refusing a link definition on import, which adds no grammar rule, no vocabulary row, and no second way to jump, but leaves every diagram using BPMN's own off-page connector unimportable, on a refusal the import contract does not ask for, since the engine deploys and runs a link pair.
+- Import a link pair as a `goto`, and never author one, which changes the authoring surface not at all and shows the reader a jump in the form this language already has, but re-exports two events and their definitions as one edge, which round-trip symmetry rules out by construction, and leaves the printer deciding which `goto` edges were links from a fact the IR no longer holds.
+- A full surface, `emit link` and a named `await link`, which maps one-to-one in both directions and reuses the payload surface, the required-name check, and the completion snippet message and signal already have, at the price of two constructs for one jump and a name slot on every `await` to make one kind of `await` nameable.
+
+## Decision Outcome
+
+Chosen: the full surface, `emit link ToRetry("Retry")` and `await link AtRetry("Retry")`, because it is the only option under which a document carrying a link pair imports and re-exports as itself.
+
+The verb is `emit` rather than `throw` because `throw` lowers to `bpmn:endEvent` and a link throw is a `bpmn:intermediateThrowEvent`.
+`emit link` nonetheless ends its chain, as a `goto` does: `BpmnParse.parseIntermediateThrowEvent` records the throw in `eventLinkSources` and returns before creating an activity, so a flow leaving the throw finds no source and `BpmnParse.parseSequenceFlow` reports it as an invalid source, and the token continues at the catch and nowhere else.
+This is the one `emit` whose chain ends; the two printed words `emit link` decide it, never position, so the round-trip argument for terminality read off the text (ADR-0023) still holds.
+
+The link name is quoted text, `link("Retry")`, like a message or signal name.
+It keys the engine's own table and declares nothing, so it is not a declared code in the sense ADR-0023 gives error and escalation.
+`await` gains the optional name slot `emit` has, legal on every trigger rather than on a link catch alone, because a link catch has to be nameable to keep its id across an import and a link-only slot would need a validator rule with no engine reason behind it.
+
+In the IR both ends carry `{ kind: 'link'; linkName: string }` and no flow runs between them.
+The precedent is `BoundaryEvent.attachedToRef`, a node with no incoming flow wired to another by a reference rather than by an edge, not `SubProcess.element`, which is a tag on one node with no graph consequence.
+The IR holds no reference field between the two ends because the engine matches them by name alone; an id reference would be a second fact that can drift from the first.
+The restructuring analysis wires a link catch to its container's virtual entry the same way it wires a start event and a boundary event, since nothing flows into it: without that edge nothing in its chain has an immediate dominator, and a branch inside a rework chain prints as `goto`s instead of an `if`.
+The element `name` attribute is stamped from the link name on both ends: `BpmnParse.parseIntermediateLinkEventCatchBehavior` warns at deploy when the two differ and recommends they match, and modelers render the element name as the label, so stamping it keeps the deployment clean and the diagram readable.
+
+The validator enforces the rules the engine decides, each in the tier the engine's behaviour justifies.
+One `await link` per name in the whole file, at any depth, because `eventLinkTargets` is a field of `BpmnParse` that is never cleared between scopes; this is stricter than `goto`, whose targets are container-scoped, and it is the one place link is less expressive than what it duplicates.
+Both ends in one container, because `BpmnParse.parseScope` parses a nested scope's activities and their flows before the parent registers its own catches, so a throw inside a subprocess never finds a catch in the parent, and `ScopeImpl.findActivityAtLevelOfSubprocess` refuses the other direction.
+A throw with no catch of its name is an error, the analogue of an unresolved `goto`, since `parseSequenceFlow` refuses to deploy a flow into a link source whose name has no target; a catch with no throw is a warning, since the engine deploys it, a modeller's document can carry one, and printed output from an import has to re-validate without an error.
+Engine settings and listeners on `emit link` are refused one per item, because the throw gets no activity, while the same items on `await link` are allowed, because `BpmnParse.parseIntermediateCatchEvent` runs `parseAsynchronousContinuationForActivity` and `parseExecutionListenersOnScope` for a link catch like any other.
+
+Nothing enters the catch is the one rule stricter than the engine: `parseSequenceFlow` gives a flow into the catch an ordinary transition and `IntermediateCatchLinkEventActivityBehavior.execute` leaves at once, so the shape deploys and runs as a pass-through.
+This surface refuses it anyway, because a link target in a diagram never has an incoming flow, so the shape is not one a modeller's document produces, and the printer has to be able to place an imported catch after a statement whose flow has already ended, which a live fall-through into the catch would make ambiguous.
+
+What a link pair buys at runtime is nothing.
+`IntermediateCatchLinkEventActivityBehavior.execute` is a single `leave`, and `HistoryParseListener.parseIntermediateCatchEvent` skips a link catch by type, so history never records it.
+Against a plain sequence flow the pair adds one activity instance that never waits and an optional async point on the catch.
+That is why the argument for this surface is notation fidelity and not execution: a link in a `.bpmnscript` file exists so the file can say what the diagram said.
+`goto` stays the way an author jumps, and link is not recommended in hand-written source.
+
+### Consequences
+
+- Good, because a document carrying a link pair imports instead of being refused, and it re-exports as the same two events under the same name, which the honest import contract (ADR-0012) requires and a `goto` rewrite cannot give.
+- Good, because link costs one dispatch case per layer: a fifth catch kind on the payload surfaces of ADR-0024, one `case 'link'` in each direction of the transform, and one row in the trigger vocabulary.
+- Good, because every `await` is now nameable, so an author can `goto` a specific wait.
+- Bad, because two constructs now spell one jump, and a reader who meets `emit link` has to be told it is a `goto` in the diagram's own notation.
+- Bad, because `emit` is no longer always continuing; `emit link` is the one form that ends its chain, read off the trigger word.
+- Bad, because link names are unique per file where a `goto` target is unique per container, so two subprocesses cannot each have a link called `Retry`.
+- Bad, because a link catch nobody emits is dead code the validator can only warn about, since the engine deploys it and an imported document may carry one.
+- Bad, because an async setting or a listener on `emit link` is refused rather than honoured, and nothing on the surface hints at why until the diagnostic says so.
+- Bad, because the importer and the printer have to handle a graph with two disconnected components.
+
+### Confirmation
+
+`packages/transform/test/` pins the emitted pair in one object, the lowering to two disconnected nodes carrying one link name, and that nothing leaves the throw and nothing enters the catch at either hop.
+`packages/language/test/` pins the name slot on `await`, a `goto` reaching a named await, the completion of `link` after `emit` and after a bare `await` but not in a race branch header, and the table "Validation - link events", one row per validator rule above, each asserting the complete diagnostic list.
+The import half is confirmed by the frozen pair `tests/golden/link-events.{bpmnscript,bpmn}` with `tests/link-events.round-trip.test.ts`, which pins the five link events under their ids and link names at every hop and a printed source that recompiles clean.
+The Docker-gated case in `tests/e2e/event-positions.test.ts` deploys `order-rework` compiled and again round-tripped and runs the same journey on both, the one place the claim that the pair deploys and runs can be shown.
+
+## More Information
+
+Related decisions: ADR-0012 (the honest import contract, under which a rewrite of what is on the wire is not an option).
+ADR-0031 (the race, whose branch headers refuse `link` because the engine refuses a link catch after an event-based gateway).
diff --git a/docs/decisions/0031-carry-bpmn-documentation.md b/docs/decisions/0031-carry-bpmn-documentation.md
deleted file mode 100644
index 9747d6e..0000000
--- a/docs/decisions/0031-carry-bpmn-documentation.md
+++ /dev/null
@@ -1,77 +0,0 @@
----
-status: accepted
-date: 2026-09-10
-decision-makers: Marlon Kranz
----
-
-# Carry bpmn:documentation wherever a name is carried
-
-## Context and Problem Statement
-
-`bpmn:documentation` sits on `bpmn:BaseElement`, so it can appear on almost any element a document declares.
-Import reads the child and drops it, reporting only a warning; there is no authoring surface, so nothing written in this language can produce it on export.
-It is the one piece of authored, human-facing content this tool refuses to carry, which keeps the letter of the honest-import contract (ADR-0014) while working against everything else that contract exists for.
-
-## Decision Drivers
-
-- The honest-import contract (ADR-0014): nothing is dropped in silence, so whatever this surface still cannot carry must be named rather than swallowed.
-- ADR-0029's one bracket shape: a new authoring surface is a setting inside an existing element's parens or it is a new construct, and documentation does not earn a construct of its own.
-- The printer's `Lines = string[]` model indents per entry, so a value spanning several lines needs either a printer change or an escape.
-- BPMN lets an element carry several `bpmn:documentation` children, each with its own `textFormat`.
-
-## Considered Options
-
-For the shape:
-
-- One optional `documentation?: string` on the IR node
-- A list of `{ text, textFormat }` members, one per `bpmn:documentation` child
-
-For the printing:
-
-- Escaping a newline so the value stays a single printed line
-- Reworking the printer to give a setting a multi-line entry
-
-For the placement:
-
-- Every element the transform touches
-- Every element that already carries a name
-
-For the quoting:
-
-- One quoting function serving every setting
-- One quoting function per contract, splitting `quote()` in two
-
-## Decision Outcome
-
-Chosen: one optional `documentation?: string`, escaped onto a single printed line, carried wherever an IR node already carries a `name`, with the quoting function split in two.
-
-Nothing in the corpus writes two `bpmn:documentation` children on one element, so a repeated member on every carrying kind would buy nothing a single optional string does not already give it; a second child, or one carrying a non-plaintext `textFormat`, keeps today's warning under a narrower message instead.
-Escaping the newline keeps the printer's `Lines = string[]` model untouched, and Langium's default `convertString` is the exact inverse of that escape, so import and print stay symmetric without a change to how a line is stored.
-Tying documentation to the name means the positions where it is reported instead are already the positions a label is reported: an element with no slot for a name has no slot for documentation either, so there is one rule rather than two.
-
-One quoting function cannot serve a slot whose value must re-lex as an expression and a slot whose value must not.
-A quoted body beginning with `${` is read as a raw expression, because `RAW_TEMPLATE` is declared before `STRING` in the grammar and matches first, and the reader that unwraps a raw expression strips its quotes without unescaping, so an escape inside such a body survives as two literal characters.
-The settings that carry an expression, a delegate expression, a timer body, an io value, a loop collection, depend on that reading.
-The settings that carry prose, a label, a version tag, a form field label, an error or escalation message, and now documentation, are corrupted by it instead.
-A declared error or escalation code belongs on the prose side too, at the one site that quotes one, because ADR-0030 made a code a declared name and a declared name can never be an expression.
-`quote()` splits on which contract a slot has: it keeps serving the expression-carrying group exactly as it does today, and `quoteLiteral()` takes the prose group, escaping what `quote()` escapes plus a newline plus a leading `$`.
-Carrying documentation is what forces the split, not what creates the fault: the prose settings were already spelled ambiguously, and a value able to hold a newline is the first one that makes the ambiguity reachable.
-
-### Consequences
-
-- Good, because the `documentation` warning category survives with a narrowed meaning instead of disappearing, so a consumer classifying warnings by category needs no migration.
-- Good, because documentation text is carried verbatim: a modeler's pretty-printed whitespace inside the element body comes back byte for byte instead of being trimmed in silence.
-- Bad, because moving any other slot onto `quoteLiteral()` is a behaviour change rather than a refactor.
- Several settings still on `quote()`, such as a topic or a decision reference, can legitimately hold an expression in Operaton, so reclassifying one changes what a recompiled document runs and needs a decision of its own.
-
-### Confirmation
-
-The frozen `documentation` golden pair and its round-trip suite confirm the fixture compiles byte for byte, imports with no warning, and re-parses to the same IR at every hop.
-A parameterized sweep on the import hop and one on the print hop each name every position that carries documentation and every position that reports it instead, so a position wired to neither branch fails the sweep rather than passing in silence.
-
-## More Information
-
-Amends ADR-0014, whose warned-construct list read `bpmn:documentation` on any element; the bullet now names the narrower set this decision leaves warned.
-
-Related decisions: ADR-0029 (the bracket shape that keeps documentation a setting rather than a construct).
-ADR-0030 (the declared-name rule that puts an error or escalation code on the same quoting side as documentation).
diff --git a/docs/decisions/0031-inclusive-and-event-based-gateways.md b/docs/decisions/0031-inclusive-and-event-based-gateways.md
new file mode 100644
index 0000000..2549412
--- /dev/null
+++ b/docs/decisions/0031-inclusive-and-event-based-gateways.md
@@ -0,0 +1,114 @@
+---
+status: accepted
+date: 2026-08-30
+decision-makers: Marlon Kranz
+---
+
+# Inclusive and event-based gateways
+
+## Context and Problem Statement
+
+Every BPMN element the language has taken on so far had a construct to hang off.
+`bpmn:inclusiveGateway` and `bpmn:eventBasedGateway` have nothing to attach to, because under ADR-0011 no author ever writes a gateway.
+The desugarer synthesizes every gateway from block structure, and the decompiler elides every one it matches.
+That elision is what makes the round trip idempotent, and the grammar states the premise in its own header (`packages/language/src/bpmn-script.langium`).
+
+So the question is not which keyword spells "inclusive gateway".
+It is whether block structure can express the intent well enough that the compiler still picks the element, and which of the flows out of such a fork are worth writing.
+A gateway's `name` survives XML to IR to XML, then dies at the print hop, where no gateway has a statement form to carry it (`normalizeContainer`, `tests/helpers/normalize-ir.ts`).
+
+## Decision Drivers
+
+- ADR-0006 binds two rules: a keyword names what the user means rather than the BPMN element, and the compiler must not require what it can infer.
+- Gateway elision is what ADR-0014's decompiler relies on for idempotence.
+ A gateway with textual identity gains an authored id and stops round-tripping under ADR-0010's positional scheme.
+- Operaton does not police the two kinds equally: an inclusive gateway gets no structural validation, an event-based gateway a list of them.
+- The compiler should write no flow the engine can never take, and a valid program must compile.
+- The tool's own output should print back as source the validator accepts.
+- Trigger words are soft identifiers by design (ADR-0023), so a rule proves nothing until a live parser accepts it.
+- A label dropped on the way out needs a channel to be reported through, and ADR-0012's contract covers `xmlToIr` only.
+
+## Considered Options
+
+- For the inclusive split, conditioned `parallel` branches headed by `if` and `else`, which add no keyword, no statement, and no id template, at the cost of an element choice invisible in the block's keyword.
+- For the inclusive split, branches headed by a bare parenthesized condition, which parses as cleanly, but a parenthesized expression opening a line is a shape this language has nowhere else.
+- For the inclusive split, a separate construct with its own reserved keyword, which would make the element visible, but it spends a word and names the BPMN element rather than what the user means.
+- For the fallback flow, reserve one only beside an `else` or when every branch is conditioned, which writes no flow the engine can never take, at the cost of a fork whose flows out differ by branch shape.
+- For the fallback flow, reserve one on every inclusive fork and teach the printer to drop the `else` it prints for a flow that can never be taken, which keeps the lowering uniform, but writes a dead flow the printer then has to hide, and the `else` it prints for one is source the validator refuses.
+- For the fallback flow, a written `else` required whenever every other branch is conditioned, which would make the fallback visible, but it is required syntax for something the compiler can infer.
+- For the race, a multi-branch `await`, which reserves no word and generalizes a meaning the keyword already carries.
+- For the race, a distinct keyword such as `race`, which could not be confused with anything, but it costs the language a name authors may want.
+- For the race's join, a synthesized exclusive join the block falls through, which reuses `if`'s join and cannot duplicate a token, at the cost of a join that sees one.
+- For the race's join, self-contained branches that rejoin by `goto`, which would match a hosted `on` handler, but would force a `goto` for the common timeout case.
+- For a join that exactly one flow reaches, keep it written, which is what lets the printer structure a terminating branch and a guard clause; eliding it and retargeting the flow to the join's successor would satisfy bpmnlint and lose both.
+- For the gateway label, no slot, with the print-side drop reported through a warnings channel out of `irToDsl`, which keeps every gateway elided and puts the label on the same channel as the print hop's other reports, at the cost of a signature change.
+- For the gateway label, a slot on `if`, `while`, `parallel`, and the two new forms, which is the only way a label survives a full BPMN to DSL to BPMN round trip, but a labeled gateway is an authored gateway.
+- For the gateway label, no slot and a silent drop, which costs nothing, and is the one thing ADR-0012's contract exists to prevent.
+- A `gateway` keyword, or a flat node-and-edge escape hatch, which would express every gateway shape, but a gateway that prints re-parses with an authored id.
+
+## Decision Outcome
+
+Both gateways stay fully synthesized and fully elided.
+Nothing in the surface names a gateway, and ADR-0010's positional ids keep working unchanged.
+Every form below parsed with zero ambiguity warnings in a live Langium parser with Chevrotain self-analysis on.
+
+A branch of a `parallel` block may be headed by `if (condition)`, by `else`, or by nothing at all, and the compiler picks the element from what the branches carry rather than from the keyword.
+Operaton takes every non-default flow that has no condition or a true one, and adds the default only when that set comes out empty (`InclusiveGatewayActivityBehavior.execute`), so `lowerParallel` reserves `Flow__default` only where the engine can take it: beside an `else`, or, when every branch carries a condition, running straight to the join, the rule `lowerIf` already applies at the same position, since such a fork with no default deploys, runs, then throws a stuck execution the first time no condition holds.
+Beside a branch carrying no condition that branch is in the taken set every time, so the set never comes out empty, a fallback behind it could never run, and an `else` written there is refused for the same reason.
+All of this is a lowering rule rather than a validator rule because `BpmnParse.parseInclusiveGateway` validates nothing.
+
+Every fall-through carries the reserved exit id, the back-edge included.
+`lowerWhile` wires its back-edge with `body.exitFlowId`, as `lowerDoWhile` does, so a `while` whose body ends in a loop keeps that loop's declared default on the flow the enclosing head takes and `irToXml` accepts the document.
+
+A join that exactly one flow reaches stays written.
+`ExclusiveGatewayActivityBehavior.doLeave` and the parallel and inclusive behaviours run it as a pass-through, and `BpmnParse.validateExclusiveGateway` accepts its single unconditioned outgoing flow.
+The printer recovers a fork whose branch terminates, and a guard clause at the tail of a loop body, from that merge: without it, `parallel { { user A } { end X } } user C` degrades to jumps and `while (x) { if (c) { end R } }` prints as `while (x) { end R }`.
+Eliding such a join waits until the printer structures those shapes without it.
+
+The race is a multi-branch `await`, because `await` already means the token stops here until this resolves and a race is that meaning over a set.
+Reusing the word costs nothing, because `await {` cannot parse otherwise: the `IntermediateCatchEvent` shape takes a trigger word after the keyword.
+Its triggers are the four `await` already takes, which is also everything Operaton accepts in this position (`BpmnParse.parseIntermediateCatchEvent`), and its branches fall through to a synthesized exclusive join, reusing `if`'s join and its pruning when every branch terminates (`pruneUnreachableJoin`).
+Operaton gives every catch event behind an event-based gateway the start behavior `CANCEL_EVENT_SCOPE`, so exactly one branch of a race ever runs.
+
+An inclusive pair reuses `Gateway__fork` and `Gateway__join` unchanged, since exactly one `parallel` statement sits at any structural coordinate.
+The race adds one template, `Gateway__race`, whose segment word joins the reserved-name pattern.
+
+Neither `if`, `while`, `parallel`, nor either new form gains a label slot, and the reason is structural.
+One construct lowers to two gateways, so a single slot carries at most half of what an imported document may hold.
+Instead `irToDsl` returns a result carrying source and warnings rather than a bare `string`, as ADR-0012 did to `xmlToIr`, since a channel a caller can skip is a silent drop.
+
+This extends the structured surface rather than breaching it.
+Every gateway is still derived from block structure, none is written, and none is named, so the grammar header's claim holds word for word.
+
+Both kinds import rather than being refused (`mapContainerChildren`, `packages/transform/src/xml-to-ir.ts`).
+An inclusive gateway reads `name`, `default` and its job settings (ADR-0021), and carries under the one refusal every gateway kind shares: an `operaton:inputOutput` on a gateway is refused, quoting `BpmnParse.checkActivityInputOutputSupported`, which fails the deployment.
+Beyond that it has no refusal of its own.
+An event-based gateway carries, refusing three shapes, each following a refusal in Operaton's own parser: an outgoing flow whose target is not a `bpmn:intermediateCatchEvent` (`BpmnParse.parseEventBasedGateway`), a downstream catch carrying a link definition (`BpmnParse.parseIntermediateCatchEvent`), which the rule that no sequence flow may enter a link catch already refuses for every flow and so for a branch of a race as well, and a downstream catch reached by more than one path (`BpmnParse.parseSequenceFlow`), counted over every path in.
+The last is wider than the engine's rule: Operaton lets a path through where it leaves an event-based gateway, so it takes a catch reached only by such paths, and this refuses that too.
+Neither that shape nor the one Operaton refuses is authorable, so the extra width costs no script anything.
+`parseEventBasedGateway` rejects `operaton:asyncAfter` on the gateway itself as well, so the validator refuses it on a multi-branch `await` head under the same rule, and the import refuses a document carrying it rather than dropping it.
+
+### Consequences
+
+- Good, because both gateways stay synthesized and elided, so idempotence, the id scheme, and the control-flow analysis need no new rule and no new IR flag.
+- Good, because neither construct spends a reserved word; reserving `race` would take `var race: string` and `user race` out of the language.
+- Good, because a compiled inclusive fork beside an unconditioned branch prints back without a warning and re-parses without an error.
+- Good, because a `while` whose body ends in a loop compiles.
+- Bad, because a conditioned `parallel` reads as one construct but compiles to either of two BPMN elements, so a BPMN-literate reader must read every branch.
+- Bad, because `irToDsl` changes its return shape, touching every call site, and the drop that motivated it costs the reader a gateway's name rather than anything the process does.
+- Bad, because the label comes back on a BPMN-to-BPMN pass but never on a BPMN-to-DSL one, so a decompiled and recompiled document loses every gateway label.
+- Bad, because a conditional branch of a race can win without waiting (`EventBasedGatewayActivityBehavior.execute`), which no part of the surface shows.
+- Bad, because an inclusive fork closed on an exclusive merge, the common hand-drawn shape, degrades to `goto`s a reader repairs by hand.
+- Bad, because a join that exactly one flow reaches stays in the document, where bpmnlint reports it as superfluous and the auto-layout draws the default edge into it across the terminating branch's end event.
+
+### Confirmation
+
+`packages/language/test/` pins a `parallel` block mixing an `if` branch, a plain branch and an `else` branch, `await { ... }` parsing as a race while `await timer("PT1H")` still parses as a single catch, and each validation rule on the two heads.
+`packages/transform/test/` pins element selection, the four fallback shapes in one table, the nested `while` with the reserved id on its back-edge through `irToXml`, the race's join and its pruning, the inclusive carry, each refusal by name, elision of both new pairs, the dropped-label warning reaching the caller, and a modeled fallback running straight into the merge coming back as the model without that edge.
+The frozen pairs `tests/golden/branch-and-race.{bpmnscript,bpmn}` and `tests/golden/gateway-settings.{bpmnscript,bpmn}` each hold an inclusive fork beside an unconditioned branch with no `default` attribute, asserting IR idempotence after normalization and that the decompiled source recompiles cleanly, and a Docker-gated end-to-end test shows the losing race branch canceled and the inclusive join waiting for exactly the branches taken.
+
+## More Information
+
+Operaton behavior was read from `BpmnParse.java`, `InclusiveGatewayActivityBehavior.java`, `ExclusiveGatewayActivityBehavior.java` and `EventBasedGatewayActivityBehavior.java` in the `operaton/operaton` repository.
+The documented restrictions are from .
+The dataset behind Compagnucci, Corradini, Fornari and Re (BISE 66(1), 2024, DOI 10.1007/s12599-023-00818-7) has an event-based gateway in roughly 12 percent of its 38,863 models and an inclusive gateway in roughly 6 percent.
diff --git a/docs/decisions/0032-expressions-raw-templates-and-escapes.md b/docs/decisions/0032-expressions-raw-templates-and-escapes.md
new file mode 100644
index 0000000..5ad191a
--- /dev/null
+++ b/docs/decisions/0032-expressions-raw-templates-and-escapes.md
@@ -0,0 +1,53 @@
+---
+status: accepted
+date: 2026-09-14
+decision-makers: Marlon Kranz
+---
+
+# Expressions: raw templates, escapes, and what is lowered as EL
+
+## Context and Problem Statement
+
+A quoted string whose body opens with `${` or `#{` is a raw template, the escape hatch for an expression outside the JUEL subset the grammar spells bare.
+Its body reaches the engine as expression text, so the reader that unquotes the token and the printer that quotes it back have to be exact inverses, or a compile and a decompile change what the engine evaluates.
+Imported documents carry both openers, and a quoted body may hold a quote, a backslash or a line break.
+How is a raw template read, printed and nested, and which opener does it keep?
+
+## Decision Drivers
+
+- The printer's quoting and the reader's unquoting have to be exact inverses, or every round trip changes the text.
+- Operaton reads `${` and `#{` the same way, in `Scanner.isEvalStart` and everywhere above it, so the surface should carry either without rewriting.
+- A backslash has no meaning outside a string literal in JUEL, where `Scanner.nextEval` refuses one as an invalid character.
+- A message name the engine evaluates is a shape ADR-0012 carries rather than refuses.
+
+## Considered Options
+
+- Read a raw template with the string escapes, accept either opener and print it back as written, which makes one escaper the inverse of one reader and leaves the engine's spelling alone.
+- Read the token verbatim, unescape at each consumption point, and rewrite `#{` to `${` on the way in, which spreads the inverse over every consumption point and changes text the engine evaluates identically either way.
+- Read the token verbatim and stop the printer from escaping, which loses a quote, a backslash or a line break inside a body on the way back out.
+
+## Decision Outcome
+
+Chosen option: read a raw template with the string escapes, accept either opener and print it back as written, because it makes one escaper the inverse of one reader and leaves the engine's spelling alone.
+
+`BpmnScriptValueConverter` converts a `RAW_TEMPLATE` token with `ValueConverter.convertString`, the routine `STRING` goes through, so `RawExpr.raw` holds the unquoted body with `\"`, `\\`, `\n`, `\r` and `\t` resolved.
+One escaper in `packages/transform/src/juel.ts` maps those five back, and both `quote()` and `renderRawFallback` print through it.
+Moving a slot from `quote()` to `quoteLiteral()` is a behaviour change rather than a refactor, and needs a decision of its own: settings still on `quote()`, such as a topic or a decision reference, can legitimately hold an expression in Operaton, so reclassifying one changes what a recompiled document runs.
+The terminal opens on `${` or `#{`; `renderExpression` returns a top-level raw template verbatim, and `renderRawFallback` prints a raw body with the opener it was read with.
+A raw template nested under an operator splices its body in parentheses, `!"${x}"` rendering `${!(x)}`, since JUEL has no `${` token inside an expression and `JuelExpressionManager.createValueExpression` rejects `${${x}}` at deployment.
+A composite raw template such as `"${a} and ${b}"` has no one body to splice, and the validator refuses it under an operator.
+A prose value that happens to open with either opener, a label, a map key, a form value, goes through `quoteLiteral()`, which puts a backslash before it, `"\${..."`, so the value lexes as `STRING` and reads back byte for byte.
+The importer carries a message or signal name opening with `${` as written, since `BpmnParse.parseMessages` and `BpmnParse.parseSignals` evaluate every name through `createExpression` and the shape deploys; a message start keeps its refusal, since the engine rejects an expression there.
+
+### Consequences
+
+- Good, because printing and re-parsing a raw template gives back the same text, with a quote, a backslash or a line break inside.
+- Good, because a Camunda-authored `#{...}` expression imports, compiles and deploys unchanged, in every position the surface has.
+- Good, because an expression name on a message end, emit, handler start or signal imports instead of refusing a document the engine deploys.
+- Bad, because a backslash inside a raw template is an escape, so `\"` reaches the engine as one character; an author who wants the engine to see a backslash writes two.
+- Bad, because a structured `#{...}` body in a condition or cardinality comes back inside `${...}`; the import warns about that one rewrite.
+
+### Confirmation
+
+The `renderExpression` table in `packages/language/test/parsing.test.ts` pins the converter's read, both openers and the nested splice, and `packages/transform/test/juel.test.ts` re-parses every printed raw fallback through the grammar and asserts the body it reads back.
+`packages/transform/test/ir-to-dsl.test.ts` pins the escaped map key, the one-line io value, the `#{` priority and form default, and `packages/transform/test/xml-to-ir.test.ts` pins the expression names that import and the five body positions that report a rewrite.
diff --git a/docs/decisions/0032-field-injection-as-a-parameter-direction.md b/docs/decisions/0032-field-injection-as-a-parameter-direction.md
deleted file mode 100644
index 97dfab5..0000000
--- a/docs/decisions/0032-field-injection-as-a-parameter-direction.md
+++ /dev/null
@@ -1,172 +0,0 @@
----
-status: accepted
-date: 2026-09-10
-decision-makers: Marlon Kranz
----
-
-# Field Injection as a Third Parameter Direction, Form References on User Tasks Only
-
-## Context and Problem Statement
-
-Two Operaton surfaces this language does not yet reach both attach to an element the DSL already models, rather than to a new BPMN construct.
-A service task, a send task, a business rule task, and both listener kinds can carry Operaton fields: name and value pairs injected into the Java bean the class or delegate binding instantiates.
-A user task can carry a form reference by id and version, an alternative to the fixed form key the surface already writes.
-Neither needs a new flow node or a new statement keyword; each needs a place inside a member block or a settings list an element already has.
-
-The open question for fields is where that place is.
-`IoParameter` already spells `input greeting = "hello"` and `output result = "${x}"` inside the brace block a service-task-like statement opens, and a field is the same shape: a name, an `=`, and a value that is either a literal or a raw expression.
-Reusing that rule costs nothing if a field behaves like an io parameter everywhere it can legally sit, and Operaton's own parser has to say where that is, because its moddle schema admits a field almost anywhere `extensionElements` can appear while its parser reads one off only a narrow set of bindings.
-A field also has two XML slots for the same runtime value, `stringValue` as an attribute and `` as a child, and the DSL surface has to pick one spelling rather than grow a second vocabulary for a distinction no author can act on.
-
-The open question for form references is narrower: which element gets `formRef`, and what has to go with it.
-Operaton's schema allows it on a user task and on a start event; this language's user task already carries `formKey`, so `formRef` is the natural second setting there.
-Its own parser is stricter than its schema again: a form reference with no binding, or a form reference beside a form key, both refuse the deployment rather than merely reading oddly.
-
-## Decision Drivers
-
-- Every reserved keyword is a name an author permanently loses.
- ADR-0023 already rejected a `listener` keyword on that ground, and ADR-0029 lists no new reserved words among its own drivers.
-- Operaton's own parser decides what is legal, not what its schema merely allows to be written.
- A placement rule read off the schema alone would carry an attribute the engine silently ignores rather than one it acts on.
-- An illegal combination should be unrepresentable in the IR's type rather than merely checked for, continuing the convention ADR-0022 set for engine attributes generally.
-- The import contract commits this tool to refusing or warning on what it cannot honestly carry, never dropping something that would change what the engine does in silence (ADR-0014).
-
-## Considered Options
-
-For field injection:
-
-- Reuse `IoParameter` as a third direction, `field`
-- A dedicated `FieldInjection` grammar rule, with `field` a hard keyword
-- A nested `fields { }` sub-block inside the existing member block
-- Two DSL spellings, one for `stringValue` and one for ``
-
-For form references:
-
-- `formRef` on a user task only, matching where `formKey` already sits
-- `formRef` on a user task and a start event, matching Operaton's schema
-
-## Decision Outcome
-
-Two decisions, chosen together because each rests on the same kind of evidence: what Operaton's own parser reads off an element, not what its schema merely permits to be written there.
-
-Field injection reuses `IoParameter` as a third direction.
-`field greeting = "hello"` sits beside `input` and `output` inside the same member block a service task, a send task, and a business rule task already open, and, after this decision, inside a listener's own block too.
-`IoParameter` already parses `direction=ID name=ID '=' value=IoValue` for any identifier in the direction position, so the grammar accepts `field` with no new rule at all.
-The direction word stays a soft word exactly as `input` and `output` already are, free for a variable or a step name everywhere else in a file, and it is the validator, not the parser, that checks which directions a given owner allows.
-Zero grammar rule is spent on the direction itself; the one grammar edit this decision needs is the brace block a listener gains, described below.
-A fourth direction, `property`, joins the three the same way for a form field's block (ADR-0037).
-
-Rejected: a dedicated `FieldInjection` rule with `field` a hard keyword.
-It would reserve a word for an idea `IoParameter` already has room for, the exact cost ADR-0023 already refused to pay for `listener`, and it would still need the same class-and-delegate placement rule and the same one-spelling value rule regardless of which grammar rule carries it.
-Rejected: a nested `fields { }` sub-block inside the existing member block.
-It adds a second member-block shape nested inside the first for no gain over letting `field` repeat directly where `input` and `output` already do, and every reader of the outer block would have to look one level deeper for the identical list.
-
-The brace block is forced, not chosen, once a field needs to repeat.
-Spelling it as a setting instead, `service S(field: "greeting=hello")`, hits two limits the settings surface already has.
-`checkDuplicateKeys` rejects a repeated setting key, so a second field on the same element could not repeat `field:`, and a setting's value is typed as an expression, which admits no map literal that could hold several named fields at once.
-A member block, where `params+=IoParameter*` already repeats freely, is the only surface with room for more than one.
-
-The same block carries fields on both listener kinds, amending ADR-0023 rather than contradicting it.
-Operaton hands a field list to a class-bound and a delegate-expression-bound execution listener and task listener exactly as it does to a class-bound or delegate-expression-bound service task.
-Carrying fields on a task but not on a listener would leave one of the two import sites for a dropped field still reporting a drop for a construct this tool otherwise claims to carry.
-`on (...) { }` gains a brace block holding `params+=IoParameter*`, the same rule a service task's own block already uses, which gives a listener a member block it did not have before.
-ADR-0023 recorded that absence as deliberate, so it is amended rather than left to read as contradicted: a field is none of the three things that ADR ruled out.
-It is not a form, not an input or output parameter, and not a nested listener.
-
-A field's value has one DSL spelling regardless of which of Operaton's two XML slots it lowers to.
-A quoted string lowers to the `stringValue` attribute, and a `"${...}"` raw expression lowers to an `` child, the same literal-versus-expression split `renderIoValue` already draws when printing an io parameter's own value.
-On import, an `` child, the moddle's third value slot, carries into the same IR text `stringValue` would and exports back as `stringValue`, with a warning naming the rewrite.
-The moddle already normalizes an `expression` attribute into a child element on its own, so a normalizing round trip is not new here.
-The one shape this rule cannot close is a `stringValue` that reads `${...}`.
-Writing it back would turn a literal Operaton injects verbatim into an expression Operaton evaluates instead, so that shape keeps a drop warning naming the reason rather than being carried.
-
-Rejected: two DSL spellings, one mirroring `stringValue` and one mirroring ``.
-The two spellings would name a distinction in Operaton's XML that carries no distinction in what the engine does with either shape until an author writes `${...}`, at which point the one-spelling rule already draws exactly that line.
-
-Where a field is legal follows the engine, not the schema.
-Operaton's `BpmnParse.parseServiceTaskLike` hands `parseFieldDeclarations` to `ClassDelegateActivityBehavior`, to `ServiceTaskDelegateExpressionActivityBehavior`, and to the mail and shell behaviours it instantiates itself (a binding this surface carries since ADR-0042); nothing else takes one.
-`ServiceTaskExpressionActivityBehavior` is built from an expression and a result variable with no field list, and `parseExternalServiceTask` is never handed one either.
-`parseExecutionListener` and `parseTaskListener` repeat the identical split for both listener kinds, neither of which binds a built-in type.
-So on a task a field rides a `class:`, a `delegate:`, or a `type:` binding; an `expression:` binding, a `topic:` binding, and a `decision:` binding all take none.
-On a listener, which has no `type:` binding, a field rides `class:` or `delegate:` alone.
-The validator reports a field written elsewhere as an error naming the bindings that do take one, and an imported field found under any other binding keeps a drop warning rather than being carried into a binding the engine would never read it from.
-
-`formRef` lands on a user task only, matching where `formKey` already sits.
-Operaton's schema also allows a `formRef` on a start event, but this language does not carry `formKey` on a start event either.
-A start owns only a label and an optional form block with no key of its own.
-Giving `formRef` a start-event home while `formKey` still has none there would fix half of an existing asymmetry and leave the other half standing, a larger, separate change to that asymmetry rather than a scoping decision that belongs here.
-It is a deliberate scope, not an oversight, and the start-event side is a follow-up.
-
-Rejected: `formRef` on a user task and a start event, matching Operaton's schema.
-It would close the gap between what this surface accepts and what Operaton allows, but a start event here carries no `formKey` either, so adding `formRef` alone would fix one half of a pre-existing asymmetry while leaving the other half standing.
-
-A `formRef` requires a binding, because Operaton's own parser requires one.
-`BpmnParse.parseFormDefinition` calls `addError` when `formKey` and `formRef` are both present on one task, and calls `addError` again when `formRef` is present with a binding that is absent or outside `latest`, `deployment`, or `version`.
-Both shapes make the file undeployable rather than merely unusual.
-The IR carries a form reference as a key and a binding with the binding required, which is what makes the invalid state unrepresentable rather than merely refused later.
-The validator errors on both shapes the same way, and the importer refuses both on the way in, the same rule ADR-0028 already applies to a cancel end outside a transaction block: when Operaton itself refuses to deploy a shape, the importer refuses it too, rather than warning and carrying something that would never run.
-
-### Consequences
-
-- Good, because no word is reserved for either decision.
- `field` stays available as a variable or a step name, and `formRef` is one more setting key rather than a keyword.
-- Good, because a field reuses the io-parameter machinery already built for `input` and `output`: the duplicate-key check, the highlighting, and the completion all extend to it for free, the same way ADR-0023 already got the timer clause and the four bindings for free by reusing them for listeners.
-- Good, because both illegal states are unrepresentable rather than merely checked.
- A field has no binding-key slot to sit in on an expression, a topic, or a decision binding, and a form reference with no binding cannot be constructed in the IR at all.
-- Good, because a document Operaton itself refuses to deploy is refused on import rather than accepted into a state the compiler could not have produced.
-- Bad, because a listener's brace block now holds two different kinds of member under the same grammar rule, an io parameter and a field, so a reader has to check the direction word rather than the rule name to know which one they are looking at.
-- Bad, because the field-placement rule is a fact about Operaton's parser, not its schema: class and delegate on a task or a listener, plus a task's `type` binding since ADR-0042.
- It has to be documented rather than discoverable from the moddle alone.
-
-## Pros and Cons of the Options
-
-### Reuse `IoParameter` as a third direction
-
-- Good, because `IoParameter` already parses any identifier in the direction position, so `field` costs no grammar rule and no reserved word.
-- Good, because the duplicate-key check, the highlighting, and the completion all extend to the new direction unchanged.
-- Bad, because one grammar rule then carries two kinds of member, so a reader tells an injected field from an io parameter by the direction word rather than by the rule.
-
-### A dedicated `FieldInjection` rule
-
-- Good, because the rule name alone says what a member is, with no direction word to read.
-- Bad, because `field` becomes a word an author permanently loses, the cost ADR-0023 already refused to pay for `listener`.
-- Bad, because the placement rule and the value rule are the same whichever rule carries them, so the reservation buys nothing.
-
-### A nested `fields { }` sub-block
-
-- Good, because it collects an element's fields under one head.
-- Bad, because it nests a second member-block shape inside the first, where `input` and `output` already repeat with none.
-- Bad, because every reader of the outer block has to look one level deeper for the same list.
-
-### Two DSL spellings for the two XML slots
-
-- Good, because the slot the exporter writes is then visible in the source.
-- Bad, because the two slots behave identically until the value reads `${...}`, which the single spelling already tells apart on its own.
-- Bad, because choosing between them requires knowing Operaton's XML, which is what the surface exists to avoid.
-
-### `formRef` on a user task only
-
-- Good, because it sits beside `formKey`, so both ways of naming a form are written and read in one place.
-- Bad, because Operaton's schema also admits a start event, whose form reference this surface then cannot express.
-
-### `formRef` on a user task and a start event
-
-- Good, because it accepts every placement the schema allows.
-- Bad, because a start event carries no `formKey` here either, so it closes one half of that asymmetry and leaves the other standing.
-
-## More Information
-
-Related decisions: ADR-0006 (the IR's vendor-neutral naming, which a field's name and value pair and a form reference's key and binding pair both follow).
-ADR-0014 (the honest import contract behind every refusal and warning here).
-ADR-0021 (folding a fourth service-task binding into an existing keyword rather than adding one, the same reuse-over-reservation reasoning field injection follows for `IoParameter`).
-ADR-0022 (engine attributes as named IR fields, the precedent a field and a form reference both extend).
-ADR-0023 (the listener surface this decision amends).
-ADR-0028 (the refuse-on-parser-rejection rule this decision reuses for a form reference with no binding).
-ADR-0029 (no new reserved words among its own drivers, the same driver behind reusing `IoParameter` here).
-
-Amended by ADR-0037, which adds `property` as a fourth direction, a member of a form field's block, by the same reuse of `IoParameter`.
-
-Amended by ADR-0038, under which the `property` direction rides a `topic` binding too, on a `service`, `send`, or `decide` task, the one direction a topic binding takes.
-A field never rides `topic`; it rides `class`, `delegate`, and, since ADR-0042, a task's `type` binding instead.
-
-Amended by ADR-0042, which adds a task's built-in `type` binding as a third carrier of a field, beside `class` and `delegate`.
diff --git a/docs/decisions/0033-call-activity-variable-mapping.md b/docs/decisions/0033-call-activity-variable-mapping.md
deleted file mode 100644
index 788fbb4..0000000
--- a/docs/decisions/0033-call-activity-variable-mapping.md
+++ /dev/null
@@ -1,158 +0,0 @@
----
-status: accepted
-date: 2026-09-11
-decision-makers: Marlon Kranz
----
-
-# Carry a call activity's variable mapping under its own two setting keys
-
-## Context and Problem Statement
-
-Operaton lets a call activity name a Java class or a bean expression that computes its in and out variable mapping in code, written as `operaton:variableMappingClass` or `operaton:variableMappingDelegateExpression` on the element.
-Import refused both, and the message it threw said the delegate replaces the `operaton:in`/`operaton:out` mapping and that importing it would pass no variables into or out of the called process.
-The engine's own parser and behaviour class say otherwise.
-`BpmnParse.parseCallActivity` reads `operaton:in` and `operaton:out` unconditionally, with no guard on either attribute.
-`CallableElementActivityBehavior.execute` builds the variable map from the in mappings and hands the already populated map to the delegate, which mutates it.
-`CallableElementActivityBehavior.passOutputVariables` applies the out mappings first and runs the delegate after.
-Both mechanisms run on each side with the delegate last, so a delegate overrides a declared mapping rather than replacing it, and a document carrying one loses nothing on the way in.
-
-What is open is everything else: what the two attributes spell on the authoring surface, whether that spelling sits in the call's parens or its braces, what shape the IR gives the pair, and what happens on a document that sets both at once.
-
-## Decision Drivers
-
-- The honest import contract (ADR-0014) reserves a refusal for loss that changes execution, and the engine runs a declared mapping and a delegate alike.
-- ADR-0032 states where an injected field is legal as a rule about binding words, `class` and `delegate`, and that rule stays keyed on a word alone only while no other binding spells itself with them.
-- ADR-0029 puts a setting that describes the element in the parens and a list with members of its own in the braces.
-- An illegal combination should be unrepresentable in the IR's type rather than merely checked for, the convention ADR-0022 set for engine attributes.
-- Operaton resolves a document setting both attributes with an if/else-if in `BpmnParse.parseCallActivity`: the class wins and the delegate expression is dropped with no diagnostic.
-
-## Considered Options
-
-For the spelling:
-
-- `mapper` for the class and `mapperDelegate` for the expression
-- `class` and `delegate`, the words a service task's own bindings use
-
-For the placement:
-
-- A setting in the call's parens
-- A member of the call's brace block, beside its `in` and `out` mappings
-
-For the IR shape:
-
-- A `CallVariableMapper` of its own, tagged `class` or `delegateExpression`
-- The `CodeBinding` a service task and both listener kinds already carry
-
-For a document setting both attributes:
-
-- An author-time error, and on import the class with a warning naming the dropped delegate
-- Mirroring the engine on both hops, taking the class in silence
-
-## Decision Outcome
-
-Chosen: `mapper` and `mapperDelegate` in the parens, carried by a `CallVariableMapper` of their own, mutually exclusive at author time and resolved to the class with a warning on import.
-
-The spelling is the decision the rest hangs off.
-A variable mapping delegate has the Java shape of a `class` or a `delegate` binding and none of its behaviour, because Operaton hands it no field list: `instantiateDelegateClass` constructs the delegate with a null field-declaration list, and `ClassDelegateUtil.applyFieldDeclaration` returns without setting anything when the list is null.
-ADR-0032's rule that a field rides a `class` or a `delegate` binding and no other is therefore still a rule about binding words, and seven sites in the source and the documentation state it that way.
-Spelling a variable mapping with those two words would make all seven false at once and re-key the rule from which word to which word on which element.
-It would cost a user-facing diagnostic too, since `FIELD_HOSTS_MESSAGE` is derived from the `fields` flag on `ATTRIBUTE_BLOCK_RULES`: flipping a call activity to carry fields would rewrite the sentence every element that hosts none prints.
-A distinct word is also the honest one.
-On a service task `class` names what runs; here the delegate names what computes the data crossing the process boundary, which is a different job that happens to share a Java shape.
-
-The placement follows from what the thing is.
-A variable mapping is one setting on the call rather than a list with members of its own, so it goes where ADR-0029 puts a setting, and `CallActivity.own` gains the two keys after `businessKey`.
-That one row is the whole authoring surface: the grammar already accepts any identifier as a setting key, completion derives its offer from `own`, and the printer prints in the same order.
-
-The IR carries a `CallVariableMapper` tagged `class` or `delegateExpression` rather than reusing `CodeBinding`, on two counts the engine decides.
-`CodeBinding`'s `expression` member has no counterpart on a call activity, because Operaton reads exactly two attributes there and declares no `operaton:variableMappingExpression`; reusing the type would admit a third spelling that a hand-written validator rule then has to refuse, re-creating by hand the exclusion a tagged union states for free.
-And `CodeBinding`'s `fields` slot would be present and always empty, since that slot exists for the two behaviours Operaton injects into and a variable mapping is neither.
-
-Two spellings on one call is an author-time error, delegated to `checkAtMostOneBinding` so the sentence is the one every other exclusive pair on the surface prints.
-Import cannot refuse the same shape: Operaton deploys and runs the document with the class, so under ADR-0014 there is no execution loss to refuse for.
-The import takes the class and reports the dropped delegate as shadowed, the report an implementation attribute outranked by a higher-ranked one already gets.
-
-There is no rule between a mapper and the call's `in` and `out` mappings, because the engine runs both.
-That is the claim the refusal rested on, so the round-trip fixture authors a mapper beside an `in` and an `out` mapping rather than alone, and a reader who adds an exclusivity rule turns it red.
-
-Either spelling names an implementation of Operaton's `DelegateVariableMapping`, whose two methods are `mapInputVariables(DelegateExecution, VariableMap)` and `mapOutputVariables(DelegateExecution, VariableScope)`.
-Nothing on the authoring or the import hop can check that the named class implements it, since `resolveDelegateClass` and `instantiateDelegateClass` resolve the class when the call executes rather than when the document deploys, so a wrong parent class is a runtime failure.
-
-`calledElementTenantId` stays refused, by the same function and for a reason that holds: it pins which tenant the engine resolves the called process against, so dropping it changes which process runs.
-It is the one attribute on a call activity this surface still refuses.
-
-### Consequences
-
-- Good, because a document Operaton deploys and runs imports and re-exports, instead of being refused on a reading of the engine that the engine's own source contradicts.
-- Good, because the field rule stays keyed on a word alone.
- A mapper carries no injected field and takes none, so ADR-0032 needs no element-by-element exception and no site that states it has to change.
-- Good, because a mapper and the declared `in` and `out` mappings sit in one call with no rule between them, which is what the engine does with them.
-- Bad, because the call surface gains two words for a binding shaped in Java exactly like a `class` or a `delegate` binding and deliberately not spelled like one.
- A reader has to be told why rather than read it off the surface.
-- Bad, because neither the validator nor the importer can check a mapper.
- The class is resolved when the call executes, so a misspelled name, or one whose class implements nothing, passes both hops and fails in the engine.
-
-### Confirmation
-
-The mapper block in `tests/call-activity.round-trip.test.ts` authors each spelling beside an `in` and an `out` mapping on the same call and asserts the whole IR node, the serialized Operaton attribute, an empty warning list, an identical re-import, the printed setting in its canonical position, and that the printed source revalidates.
-The import table in `packages/transform/test/xml-to-ir.test.ts` pins both attributes under both namespace prefixes and the both-set case, asserting the whole mapper object and the whole warning list rather than membership in either.
-
-## Pros and Cons of the Options
-
-### `mapper` and `mapperDelegate`
-
-- Good, because the field rule stays a rule about the words `class` and `delegate`, true at every site that states it.
-- Good, because `ATTRIBUTE_BLOCK_RULES.CallActivity` keeps `fields: false`, so the derived diagnostic naming what may host a field is untouched.
-- Bad, because two words on the call surface describe a Java binding without using either of the two words the surface uses for one everywhere else.
-
-### `class` and `delegate`
-
-- Good, because the words match the Java shape and the attribute names the engine reads.
-- Bad, because a field rides a `class` or a `delegate` binding and no other, and a call activity's variable mapping receives none, so the rule would have to name an element as well as a word.
-- Bad, because seven sites state that rule as it stands, and `FIELD_HOSTS_MESSAGE` derives a user-facing sentence from the flag that would have to move.
-
-### A setting in the call's parens
-
-- Good, because a variable mapping describes the call itself and has no members, which is the line ADR-0029 draws.
-- Good, because the offer order, the printed order, and the completion snippet all come from the one `own` row.
-- Bad, because a long call then carries the binding and the mappings in two different brackets.
-
-### A member of the call's brace block
-
-- Good, because it would sit beside the `in` and `out` mappings the delegate computes.
-- Bad, because the braces hold lists whose members repeat, and a call names one mapping delegate or none.
-- Bad, because it would need a grammar rule where a setting key needs none.
-
-### A `CallVariableMapper` of its own
-
-- Good, because the two attributes Operaton reads are exactly the two tags, so a third spelling cannot be constructed.
-- Good, because no slot on it is unreachable.
-- Bad, because a second class-or-expression union sits beside `CodeBinding` in the IR, and a reader has to know why they are not one type.
-
-### The existing `CodeBinding`
-
-- Good, because a call activity's delegate is written the way a service task's is.
-- Bad, because its `expression` member has no attribute to lower to, so the type would admit a shape a validator rule then refuses by hand.
-- Bad, because its `fields` slot would be present and always empty on a binding Operaton injects nothing into.
-
-### An author-time error, the class with a warning on import
-
-- Good, because the author is told at the one moment a choice is still open.
-- Good, because the import keeps what the engine keeps and names what it drops, which is the two-tier contract.
-- Bad, because the two hops answer the same question differently, so the rule has to be read twice.
-
-### Mirroring the engine on both hops
-
-- Good, because one rule covers both directions.
-- Bad, because it reproduces a silent drop on the hop where a diagnostic costs nothing, which is what the surface exists to avoid.
-
-## More Information
-
-Amended by ADR-0042, which adds `type` on a task to the binding words ADR-0032's rule names; the two words and the seven sites above are as they stood at this decision, which is unchanged.
-
-Amends ADR-0014, whose refused-construct list read "a call activity naming a resolution shape the surface cannot write back"; the bullet now names the four shapes this decision leaves refused.
-
-Related decisions: ADR-0029 (the bracket shape that keeps a variable mapping a setting in the parens).
-ADR-0032 (the field rule this spelling keeps keyed on a word alone).
-ADR-0022 (engine attributes as named IR fields, the precedent `CallVariableMapper` extends).
-ADR-0007 (the Operaton moddle extension fork, which has to declare both attributes or moddle drops them on write).
diff --git a/docs/decisions/0034-multiple-start-events-on-a-process.md b/docs/decisions/0034-multiple-start-events-on-a-process.md
deleted file mode 100644
index e8797b8..0000000
--- a/docs/decisions/0034-multiple-start-events-on-a-process.md
+++ /dev/null
@@ -1,106 +0,0 @@
----
-status: accepted
-date: 2026-09-12
-decision-makers: Marlon Kranz
----
-
-# Multiple start events on a process
-
-## Context and Problem Statement
-
-ADR-0024 required an explicit `start` to be the first statement of its container and read one start event per container off that rule.
-Operaton draws the line elsewhere.
-`BpmnParse.parseStartEvents` accepts any number of start events on a process definition, and only a scope that is not a process goes through `BpmnParse.parseScopeStartEvent`, which reports an error on the second start of an embedded sub-process, a transaction, or an event sub-process.
-So "one start per container" was an invariant of this surface, not of the engine.
-It cost real files: a modeler document with a plain start beside a message start, the ordinary shape of a process entered both by hand and by correlation, was refused on import by name and by count.
-The two components that would have to carry several starts already did.
-The restructuring analysis wires every start event to its container's virtual entry (ADR-0019), and the printer walks every start it finds.
-Only the validator, the desugarer's chaining, and the import refusal stood in the way.
-Where may a second `start` sit on the page, what does the flow around it mean, and which facts about a start set are worth a diagnostic?
-
-## Decision Drivers
-
-- No grammar change: `Statement` already admits a `start` anywhere in a block, so the question is one of validation and lowering, not of syntax.
-- The page must say what the graph does.
- Steps run top to bottom and sequence flow is never written out, so a statement the flow runs past without entering would be the one silent exception.
-- The honest import contract (ADR-0014): a shape the engine deploys and runs must import, and a refusal must name an engine reason.
-- The engine's start matrix is silent at deploy time in two places that bite at runtime, and this surface reports what the engine will not.
-- Whatever import accepts, the decompiler has to print in a form that re-desugars to the same graph.
-
-## Considered Options
-
-For the surface:
-
-- Flat sibling `start` statements in the process body
-- A grouped entry block holding every start
-
-For a start that follows a statement whose flow is still live:
-
-- An error naming the ambiguity
-- A start the flow runs past, joining its chain to the one before it
-
-For the two facts the engine leaves silent, no default start and a form on a start that is not the default:
-
-- A warning
-- An error
-- Documentation only
-
-For the scope:
-
-- A process body only, by engine rule
-- Every container, with the engine's refusal surfacing at deployment
-
-## Decision Outcome
-
-Chosen: flat sibling `start` statements, a process body only, an error for a start after a live chain, and a warning for each of the two silent engine facts.
-
-A `start` opens a chain and takes no incoming flow.
-It may therefore sit first in the process body, directly after another `start`, or after a statement whose flow always ends or redirects: an `end`, a `throw`, a `goto`, or a compound whose every branch does one of those.
-Starts written back to back open one chain together, so `start A`, `start B`, `user T` enters `T` from both.
-A start after an `end` opens a chain of its own and reaches a shared step by `goto`, which is the shape the decompiler prints.
-A start after a step whose flow is still live is refused with a message naming the fix, because either reading of that page invents a flow the author did not write.
-No grammar changed for any of this.
-
-The rule holds for a process body only.
-An embedded `subprocess`, an `attempt` block, and an event-handler body keep exactly one start, at their head, because `BpmnParse.parseScopeStartEvent` rejects the second start of any scope that is not a process definition.
-Import lifts the refusal for a process and keeps it for a sub-process and a transaction, naming that engine reason.
-
-Two warnings report what the engine decides in silence.
-`BpmnParse.selectInitial` makes a lone start of any kind the default, and among several starts only a plain or timer start; with two or more starts and none of those two kinds the process has no default, and `ProcessDefinitionImpl.ensureDefaultInitialExists` throws when such a process is started by key.
-The validator warns on the process name.
-`BpmnParse.parseStartFormHandlers` binds a start form to the default start alone, so a `form` block on any other start is parsed and never offered, and the validator warns on that block.
-Both stay warnings: the process deploys and runs, and being entered by message or signal is the point of writing such a process.
-Two plain or timer starts on one process is a deployment error `selectInitial` reports itself, so this surface does not repeat it and treats the first as the default.
-
-The decompiler walks an unnamed plain start ahead of every other.
-Such a start is elided on print and re-derived by the compiler at the head of the body, so its chain has to be the first one printed, else the entry point is lost on the way back.
-
-One corner is accepted rather than fixed, the one ADR-0019 already records for a boundary chain.
-A start whose first step is a synthesized gateway, as in `start A`, `start B`, `if (x) { ... }`, compiles to correct XML but prints back as a dropped-edge marker, because the gateway's out-edges were consumed by the `if` printed for the first chain.
-The authoring rule that follows: write each start ahead of a named step.
-The golden fixture and the `support-ticket` example both obey it.
-
-### Consequences
-
-- Good, because a process entered by hand and by message, a common reason to draw two starts, is one file and one diagram instead of two processes.
-- Good, because the chain rule is one the reader can check on the page, and the desugarer never has to define the ambiguous shape.
- Operaton does not reject a start event with an incoming flow: `BpmnParse.parseSequenceFlow` has no arm for a start-event destination, and `NoneStartEventActivityBehavior` inherits `FlowNodeActivityBehavior.execute`, which leaves the activity at once, so a start the flow runs past would deploy and run as a pass-through step, and the mistake would show only in history.
- Refusing the shape is what rules that out.
-- Neutral, because `initiator` is read off every start and set on the process definition by `BpmnParse.parseProcessDefinitionStartEvent`, last one wins, and the validator warns on every start but the last that names one.
-- Neutral, because a message start whose name is already subscribed by another deployed definition fails in `BpmnDeployer.addMessageStartEventSubscription`, a fact about the deployment rather than the file, and out of this tool's reach.
-- Bad, because the printed form of a process with several starts is not the authored one: every start after the first chain prints after that chain's `end`, followed by a `goto` onto the shared step.
- The graph is the same, which the idempotence block asserts, but the reader has to learn the shape.
-- Bad, because the corner above means a start into a synthesized gateway round-trips with a warning rather than cleanly.
-
-### Confirmation
-
-The frozen pair `tests/golden/multiple-starts.{bpmnscript,bpmn}` holds four starts across two chains, and its suite asserts at every hop that all four keep their trigger and none is the target of a sequence flow.
-`tests/e2e/event-positions.test.ts` deploys `support-ticket`, a process with a plain and a message start, to a real Operaton and starts it once by key and once by message; each instance's history holds exactly its own start and the shared task, which is the assertion a pass-through start would fail.
-The validator rows in `packages/language/test/validating.test.ts` pin every position a start may take, the chain error, and both warnings.
-
-## More Information
-
-Amends ADR-0024, whose Decision Outcome stated one start per container; that now holds for a subprocess, a transaction, and an event handler only.
-
-Related decisions: ADR-0019 (every start wired to the virtual entry, and the synthesized-gateway corner this decision inherits).
-ADR-0014 (the honest import contract under which the process-level refusal is lifted).
diff --git a/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md b/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md
deleted file mode 100644
index 82ec762..0000000
--- a/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md
+++ /dev/null
@@ -1,173 +0,0 @@
----
-status: accepted
-date: 2026-09-12
-decision-makers: Marlon Kranz
----
-
-# Link events for import round-trip symmetry
-
-## Context and Problem Statement
-
-ADR-0020 set the `await` trigger scope at message, timer, signal, and conditional, and left link out by name.
-Its reason: link is BPMN's own off-page connector, built for splitting one diagram across pages, and `goto` already names a jump inside one textual source, so link would duplicate an existing mechanism for a diagramming problem a single `.bpmnscript` file never has.
-That reasoning is correct and it stays.
-It is a claim about authoring, about what a person writing this language needs, and nothing here contradicts it.
-
-It is silent about the other direction.
-A modeller's existing document can carry a link pair, and the importer refuses `bpmn:linkEventDefinition` by name on both the intermediate throw and the intermediate catch, so such a document does not import at all.
-The one approximation on offer does not fit the import contract.
-ADR-0014 lets the importer warn on a drop that changes nothing the engine runs and requires a refusal for everything else; at no point does it let the importer rewrite what is on the wire into a shape the surface prefers.
-Reading a link pair as a `goto` would be that rewrite.
-A `goto` compiles to one sequence flow, so the re-exported XML holds one edge where the incoming document held two events, and the author's own notation is gone.
-The document that comes out is not the document that went in.
-
-So the question is whether a construct this language does not need for authoring should exist anyway, for the sake of what a diagram already says.
-Two reasons answer yes: better support for existing models, and round-trip symmetry.
-The second rules out every answer short of a full surface.
-
-## Decision Drivers
-
-- Import coverage of existing models.
- Modelers built on bpmn-js, the Camunda Modeler among them, offer both ends of a link pair, and a document drawn with one is refused today.
-- Round-trip symmetry under ADR-0014.
- A construct the importer maps has to re-export as the same XML, and a link pair mapped to a `goto` does not.
-- Reuse of what is built.
- ADR-0017's payload surfaces already carry a quoted name for message and signal, and ADR-0016's terminality rule already decides from the printed words whether a statement ends its chain; a fifth catch kind should cost one dispatch case per layer, not a second mechanism.
-- The engine's own link semantics.
- Operaton correlates the two ends by name in one table per parsed file, creates no activity for the throw, and rewires flows at deploy time; those facts decide most of the validator rules before this surface gets a say.
-- The cost of a second way to spell a jump.
- Two constructs with one meaning is a real price, and this decision has to say what is bought for it.
-
-## Considered Options
-
-- Keep refusing a link definition on import, as ADR-0020 decided
-- Import a link pair as a `goto`, and never author one
-- A full surface, `emit link` and a named `await link`
-
-## Decision Outcome
-
-Chosen: the full surface, `emit link ToRetry("Retry")` and `await link AtRetry("Retry")`, because it is the only option under which a document carrying a link pair imports and re-exports as itself.
-
-The surface reads name after trigger, the way `throw` and `emit` already spell a named event statement.
-The grammar comment on those two rules gives the reason: one token of lookahead places the name, since an `ID` after the trigger predicts a name and anything else ends the statement, every statement being keyword-led.
-Name first is the shape ADR-0020 tried on `await` and Chevrotain reported as ambiguous, and it would also break every `emit` in the corpus.
-The verb is `emit` rather than `throw` because `throw` lowers to `bpmn:endEvent` and a link throw is a `bpmn:intermediateThrowEvent`; a `throw link` is refused with a message pointing at `emit link`.
-`emit link` nonetheless ends its chain, as a `goto` does.
-`BpmnParse.parseIntermediateThrowEvent` records the throw in `eventLinkSources` and returns before creating an activity, so a flow leaving the throw finds no source and `BpmnParse.parseSequenceFlow` reports it as an invalid source; the token continues at the catch and nowhere else.
-This is the one `emit` whose chain ends, and the two printed words `emit link` decide it, never position, so ADR-0016's round-trip argument for terminality read off the text still holds.
-
-The link name is quoted text, `link("Retry")`, like a message or signal name.
-It keys the engine's own table and declares nothing, so it is not a declared code in the sense ADR-0030 gives error and escalation, and it reuses the required-name check, the bareword diagnostic, and the completion snippet the quoted kinds already have.
-
-`await` gains the optional name slot `emit` has, in the same trigger-first position.
-The name is legal on every trigger, as in `await message Paid("Paid")`, and the id is synthesized when it is omitted.
-This reverses ADR-0020's "no authored name" for every `await`, not only for a link catch, because a link catch has to be nameable to keep its id across an import and a link-only slot would need a validator rule with no engine reason behind it.
-A named `await` is a `goto` target and takes part in the duplicate-name and reserved-name rules like any named step.
-A link catch is the one exception: it is entered by `emit link` of the same name and by nothing else, so a `goto` onto one is refused.
-No `await` of any kind is a boundary host.
-
-In the IR both ends carry `{ kind: 'link'; linkName: string }` and no flow runs between them.
-The precedent is `BoundaryEvent.attachedToRef`, a node with no incoming flow wired to another by a reference rather than by an edge, not `SubProcess.element`, which is a tag on one node with no graph consequence.
-The IR holds no reference field between the two ends because the engine matches them by name alone; an id reference would be a second fact that can drift from the first.
-The restructuring analysis wires a link catch to its container's virtual entry the same way it wires a start event and a boundary event, since nothing flows into it: without that edge nothing in its chain has an immediate dominator, and a branch inside a rework chain prints as `goto`s instead of an `if`.
-The element `name` attribute is stamped from the link name on both ends.
-`BpmnParse.parseIntermediateLinkEventCatchBehavior` warns at deploy when the two differ and recommends they match, and modelers render the element name as the label, so stamping it keeps the deployment clean and the diagram readable.
-
-The validator enforces the rules the engine decides, each in the tier the engine's behaviour justifies.
-
-- One `await link` per name in the whole file, at any depth.
- `eventLinkTargets` is a field of `BpmnParse` that is never cleared between scopes, and `parseIntermediateLinkEventCatchBehavior` refuses the second catch of a name it already holds.
- This is stricter than `goto`, whose targets are container-scoped, and it is the one place link is less expressive than what it duplicates.
-- Both ends in one container.
- `BpmnParse.parseScope` parses a nested scope's activities and their flows before the parent registers its own catches, so a throw inside a subprocess never finds a catch in the parent, and `ScopeImpl.findActivityAtLevelOfSubprocess` refuses the other direction.
-- Nothing leaves the throw.
- The engine reports such a flow as an invalid source, as above.
-- Nothing enters the catch.
- Here the engine is more lenient than this surface: `BpmnParse.parseSequenceFlow` gives a flow into the catch an ordinary transition, and `IntermediateCatchLinkEventActivityBehavior.execute` leaves at once, so the shape deploys and runs as a pass-through.
- This surface refuses it anyway, on two grounds.
- A link target in a diagram never has an incoming flow, so the shape is not one a modeller's document produces, and the printer has to be able to place an imported catch after a statement whose flow has already ended, which a live fall-through into the catch would make ambiguous.
- The statement before an `await link` must therefore end its chain: an `end`, a `throw`, a `goto`, an `emit link`, or a compound whose every branch does one of those.
-- No link in a branch of a multi-branch `await`.
- `BpmnParse.parseIntermediateCatchEvent` refuses a link catch after an event-based gateway.
-- A throw with no catch of its name is an error.
- `BpmnParse.parseSequenceFlow` refuses to deploy a flow into a link source whose name has no target, the analogue of an unresolved `goto`.
-- A catch with no throw of its name is a warning.
- The engine deploys it, and a comment beside the rewiring says so and leaves the warning unwritten; a modeller's document can carry one, and printed output from an import has to re-validate without an error.
-- Engine settings and listeners on `emit link` are refused, one error per item.
- The throw gets no activity, so `asyncBefore`, `jobPriority`, and every listener written there would never run.
- The same items on `await link` are allowed, because `BpmnParse.parseIntermediateCatchEvent` runs `parseAsynchronousContinuationForActivity` and `parseExecutionListenersOnScope` for a link catch like any other.
-- A link into a `parallel` or multi-branch `await` branch from outside is refused, mirroring the `goto` rule, because the join would never complete.
-- Many throws to one catch stay legal, as they are in the engine.
-
-What a link pair buys at runtime is nothing.
-`IntermediateCatchLinkEventActivityBehavior.execute` is a single `leave`, and `HistoryParseListener.parseIntermediateCatchEvent` skips a link catch by type, so history never records it.
-Against a plain sequence flow the pair adds one activity instance that never waits and an optional async point on the catch.
-That is why the argument for this surface is notation fidelity and not execution: a link in a `.bpmnscript` file exists so the file can say what the diagram said.
-
-`goto` stays the way an author jumps, and link is not recommended in hand-written source.
-A `goto` target is scoped to its container and needs no name that is unique across the file, it draws no deploy-time table, and it prints as the same one edge it compiles to.
-The READMEs say so beside the link rules.
-
-### Consequences
-
-- Good, because a document carrying a link pair imports instead of being refused, and it re-exports as the same two events under the same name.
-- Good, because the round trip is symmetric: what the importer maps, the compiler emits unchanged, which ADR-0014 requires and a `goto` rewrite cannot give.
-- Good, because link costs one dispatch case per layer: a fifth catch kind on the payload surfaces of ADR-0017, one `case 'link'` in each direction of the transform, and one row in the trigger vocabulary.
-- Good, because every `await` is now nameable, so an author can `goto` a specific wait, which ADR-0020 listed as its own bad consequence.
-- Bad, because two constructs now spell one jump.
- A reader who meets `emit link` has to be told it is a `goto` in the diagram's own notation, and the READMEs carry that sentence.
-- Bad, because `emit` is no longer always continuing; `emit link` is the one form that ends its chain, read off the trigger word.
-- Bad, because link names are unique per file where a `goto` target is unique per container, so two subprocesses cannot each have a link called `Retry`.
-- Bad, because a link catch nobody emits is dead code the validator can only warn about, since the engine deploys it and an imported document may carry one.
-- Bad, because an async setting or a listener on `emit link` is refused rather than honoured, and nothing on the surface hints at why until the diagnostic says so.
-- Bad, because the importer and the printer have to handle a graph with two disconnected components, the work that makes the import half of this decision real and is confirmed under the names below.
-
-### Confirmation
-
-`packages/transform/test/ir-to-xml.test.ts` pins the emitted pair in one object: "emits a link pair as two named events sharing one link name, nothing leaving the throw, nothing entering the catch, both laid out".
-`packages/language/test/parsing.test.ts` pins the name slot, "`await` takes an optional name between the trigger and the payload, as `emit` does", and `packages/language/test/scoping.test.ts` pins "a goto reaches a named await".
-`packages/transform/test/ast-to-ir.test.ts` pins "lowers a link pair as two disconnected nodes carrying one link name: nothing leaves the throw, nothing enters the catch".
-`packages/language/test/completion.test.ts` pins that `link` is offered after `emit` and after a bare `await`, and "a race branch header offers every await trigger but link".
-`packages/language/test/validating.test.ts` carries the table "Validation - link events", one row per rule above, each asserting the complete diagnostic list, from "a link pair in one container is clean, and the catch opens a new chain after the throw" through "throw link points at emit link".
-The import half is confirmed by the frozen pair `tests/golden/link-events.{bpmnscript,bpmn}` with `tests/link-events.round-trip.test.ts`, which pins the five link events under their ids and link names at every hop, no flow leaving a throw or entering a catch, and a printed source that recompiles clean.
-The Docker-gated case in `tests/e2e/event-positions.test.ts` deploys `order-rework` compiled and again round-tripped, and runs the same journey on both, the one place the claim that the pair deploys and runs can be shown.
-
-## Pros and Cons of the Options
-
-### Keep refusing a link definition on import
-
-- Good, because the surface stays where ADR-0020 put it, with four catch kinds and no second way to jump.
-- Good, because no grammar rule, vocabulary row, or validator rule is added.
-- Bad, because every diagram using BPMN's own off-page connector is unimportable, and the modelers this tool's users draw in offer one.
-- Bad, because the refusal is not one ADR-0014 asks for: the engine deploys and runs a link pair, so there is no execution loss behind it.
-
-### Import a link pair as a `goto`, and never author one
-
-- Good, because the authoring surface does not change at all.
-- Good, because the reader of the printed source sees the jump in the form this language already has.
-- Bad, because re-export changes the XML.
- The `goto` compiles to one sequence flow, so two events and their definitions come out as one edge and the diagram's notation is lost.
- Under ADR-0014 that is an approximation, and round-trip symmetry rules it out by construction.
-- Bad, because the printer would have to decide which `goto` edges were links, a fact the IR would no longer hold.
-
-### A full surface, `emit link` and a named `await link`
-
-- Good, because the import maps one-to-one and the export reproduces it.
-- Good, because the payload surface, the required-name check, and the completion snippet are the ones message and signal already use.
-- Bad, because the language now has two constructs for one jump, and has to say in its own documentation which one to write.
-- Bad, because every `await` gains a name slot to make one kind of `await` nameable.
-
-## More Information
-
-Amends ADR-0020, whose four-trigger scope and "no authored name" hold for the reasons given there as claims about authoring; the trigger scope gains `link` and every `await` gains an optional name for the import direction.
-
-Amends ADR-0025, whose event-based gateway refusal relied on `mapIntermediateCatchEvent` turning a link away wherever it appeared; that refusal now rests on the importer's rule that no flow enters a link catch.
-
-Related decisions: ADR-0014 (the honest import contract, under which a rewrite of what is on the wire is not an option).
-ADR-0016 (the terminality rule; `emit link` is the one `emit` that ends its chain, but the two printed words decide it, so terminality is still read off the text and never off position).
-ADR-0017 (the payload surfaces the link name reuses).
-ADR-0019 (boundary events, whose `attachedToRef` is the precedent for a node with no incoming flow wired by a reference).
-ADR-0025 (the race, whose branch headers refuse `link` because the engine refuses a link catch after an event-based gateway).
-ADR-0030 (declared codes, which a link name is not).
-
-Engine behaviour was read from `BpmnParse.java`, `ScopeImpl.java`, `IntermediateCatchLinkEventActivityBehavior.java`, and `HistoryParseListener.java` in operaton-engine 2.1.0, with line numbers as of that version: `eventLinkTargets` and `eventLinkSources` at `BpmnParse.java:342-343`; `parseScope` calling `parseActivities` before `parseIntermediateCatchEvents` at 767-768; `parseIntermediateCatchEvent` at 1547-1600, with the event-based gateway refusal at 1582, the async continuation at 1559, and the listeners at 1594; `parseIntermediateLinkEventCatchBehavior` at 1603-1623, with the duplicate refusal at 1612 and the name warning at 1617; `parseIntermediateThrowEvent`'s early return at 1651-1657; `parseSequenceFlow`'s rewiring at 4331-4344, its invalid-source error at 4366, and the ordinary transition at 4392; `ScopeImpl.findActivityAtLevelOfSubprocess` at 91-102; `IntermediateCatchLinkEventActivityBehavior.execute` at 29-32; and `HistoryParseListener.parseIntermediateCatchEvent` at 202-207.
diff --git a/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md b/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md
deleted file mode 100644
index 132b44d..0000000
--- a/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md
+++ /dev/null
@@ -1,143 +0,0 @@
----
-status: accepted
-date: 2026-09-12
-decision-makers: Marlon Kranz
----
-
-# Elide a synthesized end only at its block tail
-
-## Context and Problem Statement
-
-The decompiler leaves out a plain end event whose id carries a synthesized prefix and holds nothing printable.
-The argument was that the compiler mints one at the same position when the printed source is read back, so nothing is lost.
-That argument lived in a code comment on `isElidedOnPrint` and nowhere else, and it is wrong by one word.
-The compiler mints a synthesized end in two places, `lowerContainerBody` and `lowerBoundaryHandler`, and both put it at the tail of a block.
-It never mints one inside a branch or loop body, whose exit it wires to the join or the loop head instead.
-So "the same position" holds only where the elided end was that tail: at its block's own depth, with no flow statement of the same block printed after it.
-
-The printer has several reasons to print a flow statement after such an end.
-A second start's chain, an orphan chain such as a link catch's or one reached only by `goto`, and the final `goto` sweep all append to the same block.
-Sequence flow is never written out, so the compiler wires the statement before the missing end into whatever follows it on the page.
-The end's predecessor, which ended the process in the model, now continues into another chain.
-
-The smallest source that shows it:
-
-```bpmnscript
-process p {
- error E
- var x: boolean
- start S
- step A
- if (x) {
- throw error Named(E)
- }
- step X
-}
-```
-
-The compiler ends `X` in `EndEvent_p`.
-Walking the guard, the printer wrote the then-branch as `goto Named`, then `step X`, left `EndEvent_p` out, and appended the orphan chain `throw error Named(E)` right after it.
-Read back, `Flow_X_EndEvent_p` had become `Flow_X_Named`, and not one warning said so.
-`X` ended the process in the model and threw in the document that came back.
-A modeller reaches the same shape without writing any of this: the `EndEvent_1` a modelling tool mints, drawn as the else-path of a guard whose then-branch throws, is the repro's model.
-Seven shapes lose a terminal this way.
-Five of them hold one start: the repro, a tool-minted end as a guard's else-path or as its then-branch, two branches each to its own end, and a race whose branches each end.
-The other two hold several: two starts each with its own end, and one chain holding both an elided start and an elided end.
-ADR-0034 made those two loud, since a start after a live chain is now an error; the five with one start reported nothing.
-What should the printer do with a plain synthesized end it cannot leave out without rewiring its chain?
-
-## Decision Drivers
-
-- The page says what the graph does.
- Steps run top to bottom and the flow between them is never written out, so a statement missing from the page is a flow the reader cannot see.
-- The honest import contract (ADR-0014): what the round trip cannot carry is reported, never dropped without a word.
-- No change to the printed form of an existing document.
- Every synthesized plain end in the golden pairs under `tests/golden/` sits at a container or boundary tail.
-- One mechanism rather than two.
- ADR-0034 already prints a second plain start under its reserved id and reports it, and the same shape serves an end.
-- The reserved-name error (ADR-0010) as the channel that names the fix: the validator's message on the printed line tells the reader which element to rename.
-
-## Considered Options
-
-- Print the end under its reserved id wherever it is not its block's tail, with the existing `refusedStatement` report
-- Inline an authored terminal in a guard clause when the split's route is its only incoming flow, in `branchStaysInRegion`
-- Defer the chain holding the elided end to the last position in its block
-
-## Decision Outcome
-
-Chosen: print the end under its reserved id wherever it is not its block's tail, because it closes every shape with the mechanism ADR-0034 already established and moves no golden.
-
-`isElidedOnPrint` stays the model-level predicate and keeps answering "the printer may drop this end".
-`warnRefusedStatements`, `forwardToRealTarget` and the importer's `warnElidedNamedDrop` read that answer and keep their meaning.
-Whether the printer does drop the end is a fact about print order, known only once every chain of the block is on the page, so that half of the decision moves into the emitter.
-Inside a nested body, `emitNode` prints the end at once and reports it.
-The compiler wires such a body to its join or loop head and mints no end there, so there is nothing to re-derive one from.
-At the top level, `emitNode` records the end with the position it would have printed at and pushes nothing.
-Once the block's passes have run, `Emitter.emit` walks those records.
-An end nothing followed was the tail and stays out.
-Any other is spliced back at its position and reported under the `refusedStatement` category, one report per printed end, in page order.
-A boundary handler body is one chain in an array of its own, so its end always sits at that array's end and stays elided, which is the implicit end ADR-0019 describes.
-The printed source draws exactly one validator error, on the printed `end` line, and nothing else.
-Renaming the id in the model makes the source clean and round-trips the document structurally unchanged.
-
-The import warning on a labelled plain end changes with it.
-The importer cannot know which position the printer will give an end, so its message now describes both outcomes.
-Where the script can do without the end, it is left out and its label with it; anywhere else it prints under a name the script refuses.
-Either way the fix it names is a rename in the diagram.
-The message for a start was exact and does not change.
-
-### Consequences
-
-- Good, because all seven shapes close, five of them going from an unreported rewrite to a refused source.
-- Good, because no golden's printed form changes, so a document decompiled before this decision decompiles to the same text after it.
-- Good, because the shape the reader meets is the one ADR-0034 already taught for a second plain start.
- The reader finds a reserved id on the page, a print warning, and a validator error naming the element to rename.
-- Bad, because a modeller's document with a default-named plain end that is not last now prints a reserved id and is refused until renamed, where before it printed clean and ran differently.
-- Bad, because the import-side label warning can no longer say what happens to the label, only the two things that can.
-- Neutral, because a jump into such an end is still dropped and marked (ADR-0009).
- `forwardToRealTarget` reads the model-level answer, and at the top level the jump is printed before the end's position is known.
- For a plain end in the same container the re-derived tail end is the same terminal, so the process runs the same and the marker is the only cost.
-- Neutral, because inlining an authored terminal in a guard clause landed as a change of its own, printing `event-handlers` and `transactions` inline instead of as `goto` plus a trailing statement.
-
-### Confirmation
-
-The table `irToDsl: a synthesized plain end that is not its block's tail` in `packages/transform/test/ir-to-dsl.test.ts` holds four rows.
-The guard shape is resolved by inlining the terminal, which the first row shows: `a guard's throw with a single incoming flow inlines, so its tail end stays elided rather than deferred`.
-The other three rows pin the deferral: `a plain end whose chain a later goto-reached step follows prints before that step, and its label rides along`, `a plain end inside a branch prints at once, so the branch does not fall through`, and `of three chains each ending in its own plain end, the first two print in place and the last stays elided`.
-Every row asserts the whole printed source and the whole report list, re-validates the source with the reserved-id error alone, and re-desugars it to the same graph up to id normalization.
-Every round-trip suite under `tests/` validates the source printed from its golden with zero error diagnostics, and none of them changed, which is the proof that no golden's printed form moved.
-The documentation row `a start and an end whose ids this tool writes for itself carry it and report that no script can spell it back` in `packages/transform/test/xml-to-ir.test.ts` pins the import warning's wording.
-
-## Pros and Cons of the Options
-
-### Print the end under its reserved id wherever it is not its block's tail
-
-- Good, because it covers every shape: the position test does not care why a statement followed the end.
-- Good, because the printed source is refused with one error that names the line, and the fix is a rename in the model.
-- Good, because the goldens keep their printed form.
-- Bad, because a modeller's document that used to print clean is now refused until renamed.
-- Bad, because "tail" is decidable only after the block's passes, so the emitter carries a deferred list and a resolve step that a reader has to find.
-
-### Inline an authored terminal in a guard clause
-
-Extend `branchStaysInRegion` so a branch entry that terminates before the join, and whose only incoming flow is the split's route, prints inside the branch whether or not its id is synthesized.
-
-- Good, because it fixes the guard shape at its root: the throw prints inside the `if`, the end after it is the tail again, and nothing is deferred.
-- Good, because two goldens, `event-handlers` and `transactions`, would print closer to their authored sources.
-- Bad, because it fixes two of the seven shapes and none of the others: a guard is not what puts a second start's chain or a race branch after the end.
-- Bad, because it changes printed output for documents people already have.
-
-### Defer the chain holding the elided end to the last position in its block
-
-- Bad, because it conflicts with the elided-start-first rule of ADR-0034 whenever one chain holds both an elided start and an elided end.
-- Bad, because with several elided ends only one chain can be last.
-- Bad, because printing the end in place covers every shape this would, so it is dominated.
-
-## More Information
-
-Amends ADR-0014, whose warned-construct list said the statement carrying a synthesized start's or end's label is left out whole; that holds for a start, and for an end only at its block's tail.
-
-Related decisions: ADR-0009 (the dropped-jump rule this decision leaves in place).
-ADR-0010 (the reserved-name error that names the fix, and the consequence bullet this decision makes precise).
-ADR-0019 (the boundary body whose end stays elided).
-ADR-0034 (the elided-start-first rule this decision composes with, and the second-plain-start shape it reuses).
diff --git a/docs/decisions/0037-form-field-constraints-values-and-properties.md b/docs/decisions/0037-form-field-constraints-values-and-properties.md
deleted file mode 100644
index cfbd363..0000000
--- a/docs/decisions/0037-form-field-constraints-values-and-properties.md
+++ /dev/null
@@ -1,131 +0,0 @@
----
-status: accepted
-date: 2026-09-13
-decision-makers: Marlon Kranz
----
-
-# Form field constraints, values and properties
-
-## Context and Problem Statement
-
-A form field drawn in the Modeler carries four things this surface could not spell: a validation list, an enum's values, a date pattern, and a property list.
-`DefaultFormHandler.parseValidation` reads each `operaton:constraint` by `name` and `config` and hands it to `FormValidators.createValidator`; `DefaultFormHandler.parseProperties` reads each `operaton:property` by `id` and `value`.
-`FormTypes.parseFormPropertyType` reads `datePattern` on a `date` field and the `operaton:value` children on an `enum` field.
-A field here was `id: type "label" = default`, so an imported enum field refused the document and an imported constraint, pattern, or property dropped with a warning.
-Where do the four go on a field, and what does the import do with a shape the engine deploys but this surface cannot check?
-
-## Decision Drivers
-
-- No new reserved word, the driver ADR-0029 lists and ADR-0032 held to for `field`.
-- The one bracket shape of ADR-0029: scalar settings in the parens, structured members in the braces.
-- A closed set of constraint names the validator can check.
-- The import contract of ADR-0014: refuse what the engine refuses, warn on what changes, drop nothing in silence.
-- Typed IR fields as ADR-0022 requires, so an illegal constraint name is a type error.
-
-## Considered Options
-
-- Constraints as settings in the field's parens, values and properties as members of its braces
-- Constraints as member lines in the braces, beside the values
-- A nested `validation { }` block inside the field
-- `enum` and `property` as soft words, or as keywords
-- Constraints in the IR as an ordered list, or as flat named fields
-- A fixed `validator:` key, or an open key set
-
-## Decision Outcome
-
-Chosen option: constraints in the parens and members in the braces, both words soft, an ordered constraint list, and a closed key set.
-It is the ADR-0029 shape applied to a field at no grammar cost.
-
-A field reads `id: type "label" = default (settings) { members }`.
-The type is `string`, `number`, `boolean`, `date`, or `enum`.
-The grammar's type rule is `VarType | ID`, so `enum` costs no keyword, and the validator holds the word to the five and names them on a mistyped type.
-An `enum` field's variable is a string, since `EnumFormType.convertValue` stores the chosen value's id.
-
-The parens take seven constraint keys and `pattern`.
-`required: true` and `readonly: true` take the literal `true` alone.
-`RequiredValidator.validate` and `ReadOnlyValidator.validate` never read a configuration, and `false` has no representation in a constraint list, so `required: false` is an error.
-`min`, `max`, `minlength`, and `maxlength` take an integer literal or a quoted integer.
-A `number` field is an Operaton `long`, and `AbstractNumericValidator.validate` parses the bound with `Long.parseLong`, the submitted value's own parser, so only an integer survives every submission.
-The numeric bounds fit a `number` field and the length bounds a `string` field, since `AbstractNumericValidator.validate` and `AbstractTextValueValidator.validate` throw on a submitted value of any other Java type.
-`validator` takes what `class:` takes, a class name or a `${...}` expression, which is what `FormValidators.createValidator` reads off `config`.
-`pattern` is not a constraint but the `datePattern` that `FormTypes.parseFormPropertyType` reads on a `date` field alone, so it is legal there and an error on any other type.
-Constraints are written in the order the engine validates them, and the IR keeps that order as a list keyed by a closed name union.
-
-The braces take an enum's values, one `id "label"` line each, and `property key = "value"` lines.
-A value line on a field that is not an `enum` is an error, and so is a repeated value id.
-An `enum` with no values is a warning, as an empty branch body is: the engine deploys it and then rejects every submitted value.
-A literal default naming no value is an error, since `FormFieldHandler.createFormField` converts the default through the type on every render, so the form never opens.
-`property` is the fourth `IoParameter` direction beside `input`, `output`, and `field`, the same move ADR-0032 made.
-The direction word is soft, and the duplicate-key check and the completion come for free.
-A property's value is a quoted string or a `${...}` expression, the shapes `DefaultFormHandler.parseProperties` reads as text.
-
-The import boundary follows ADR-0014.
-Three shapes are refused as `UnsupportedFormFieldConstraintError`.
-A constraint name outside the seven, since `FormValidators.createValidator` fails the deployment on any name it has not registered.
-A `validator` or a bound with no `config`, since the first fails the deployment and the second every submission.
-A name repeated on one field, since the parens hold each key once and dropping one would change what runs.
-Dropped with a warning naming the method that never reads it: `datePattern` off a `date` field, `operaton:value` children off an `enum` field, and `config` on `required` or `readonly`.
-Three shapes the engine deploys are carried as written, with a warning that the printed script draws an error at the field.
-A bound on a type its validator refuses, a bound whose `config` is not an integer, and a literal enum default naming no value.
-Rewritten with a warning: a repeated value id, kept at its first position with its last label, as `FormTypes.parseFormPropertyType` keeps it.
-
-A type outside the five stays refused, and a constraint name outside the seven joins it.
-`customFormTypes` and `customFormFieldValidators` are engine configuration this surface cannot see, so a name registered there is indistinguishable from a typo.
-`validator` with a class name reaches a custom validator anyway.
-
-### Consequences
-
-- Good, because no word is reserved: `enum`, `property`, and the eight setting keys stay ordinary identifiers everywhere else.
-- Good, because a field the engine would deploy and then fail on every submission or render is a diagnostic naming the fix.
-- Good, because a Modeler-drawn form carrying any of the four imports, prints, and recompiles to the same IR.
-- Bad, because a field's braces hold two member shapes under two grammar rules, told apart at the third token rather than by a head word.
-- Bad, because a constraint the engine deploys on the wrong type is carried on import with a warning, so the printed script does not compile until the author moves it.
-
-### Confirmation
-
-The `forms` golden pair under `tests/golden` compiles to the exact `operaton:formField` shape, imports back with no warning, and prints to source that re-desugars to the same IR.
-Its suite asserts the complete `FormField[]` of both forms at every hop.
-The validator table in `packages/language/test/validating.test.ts` pins each diagnostic, and the import tables in `packages/transform/test/xml-to-ir.test.ts` pin each refusal, drop, carry, and rewrite.
-
-## Pros and Cons of the Options
-
-### Constraints in the parens, members in the braces
-
-- Good, because it is the ADR-0029 shape, so a reader derives a field's spelling from any other element.
-- Bad, because the braces hold two member shapes under one block.
-
-### Constraints as member lines
-
-- Good, because a field then has one member list and no parens.
-- Bad, because `required` on its own line reads as a part the field is built from rather than a rule it is checked under.
-
-### A nested `validation { }` block
-
-- Good, because it mirrors the XML.
-- Bad, because it nests a second block shape inside a field, the cost ADR-0032 refused to pay for a `fields { }` block.
-
-### `enum` and `property` as soft words rather than keywords
-
-- Good, because `var enum: string`, a step named `property`, and every existing file stay legal.
-- Bad, because an enum value cannot be named by a reserved word, the limit a field id and a parameter name already have.
-
-### Constraints as an ordered list rather than flat named fields
-
-- Good, because the engine's validation order survives the round trip with one map per direction.
-- Bad for flat fields, because the XML order is lost and every name needs a plumbing site per direction.
-
-### A fixed `validator:` key rather than an open key set
-
-- Good, because an unknown name is a diagnostic here instead of a failed deployment there.
-- Bad, because a validator registered under its own engine name is written `validator: ""` instead.
-- Bad for the open set, because every unregistered name fails the deployment, so the openness buys nothing but a late error.
-
-## More Information
-
-Amends ADR-0014 (the refusal list gains `UnsupportedFormFieldConstraintError`, the warned list the form field drops, carries, and rewrite), ADR-0029 (a form field takes the parens-and-braces shape), and ADR-0032 (three parameter directions become four).
-
-Related decisions: ADR-0007 (the moddle fork, which gains the `Properties`, `Property`, `Validation`, and `Constraint` types).
-ADR-0022 (engine attributes as named IR fields, the typing rule the constraint name union follows).
-
-Amended by ADR-0038, under which a `property` line is shared with an external task.
-There it reaches the wire as `operaton:property name=`, on a form field as `operaton:property id=`, since the engine reads the two by different attributes.
diff --git a/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md b/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md
deleted file mode 100644
index f20809f..0000000
--- a/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md
+++ /dev/null
@@ -1,129 +0,0 @@
----
-status: accepted
-date: 2026-09-13
-decision-makers: Marlon Kranz
----
-
-# External task extras ride the topic binding
-
-## Context and Problem Statement
-
-A `service`, `send`, or `decide` step bound with `topic:` hands its work to an external worker, and the engine reads three more things beside the topic there and nowhere else.
-`BpmnParse.parseExternalServiceTask` reads `operaton:taskPriority` through `parsePriority`, an `operaton:properties` block through `BpmnParseUtil.parseOperatonExtensionProperties`, and each `operaton:errorEventDefinition` under `extensionElements` through `parseOperatonErrorEventDefinitions`.
-The priority orders the task in a worker's fetch, the properties travel with it, and a definition raises a BPMN error when its expression holds on a reported failure.
-This surface spelled none of the three, so an import dropped the priority and the properties with a warning and reported the definition as an extension element the moddle descriptor did not declare.
-Where do the three go on a step, and what does the import do with each shape the engine accepts?
-
-## Decision Drivers
-
-- No new reserved word, the driver ADR-0029 lists and ADR-0032 and ADR-0037 held to.
-- The one bracket shape of ADR-0029: scalar settings in the parens, structured members in the braces.
-- A mapping names an error code, and ADR-0030 made every code a declared name the editor resolves.
-- The import contract of ADR-0014: refuse what the engine refuses, warn on what changes, drop nothing in silence.
-- Typed IR fields as ADR-0022 requires, so an extra under a binding the engine never reads it for is a type error.
-- The spellings were fixed before the design: `taskPriority:`, `property key = "value"`, and `error when `.
-
-## Considered Options
-
-- `when` as a soft word told apart by lookahead, or as a keyword
-- A mapping as a member line, as a settings key, or as a nested block
-- The extras on the external binding, or on the service task node beside it
-- The extras on a thrown message binding a topic too, or on the three task kinds alone
-
-## Decision Outcome
-
-Chosen: both words soft, a mapping as a member line, the extras on the external binding, and the three task kinds alone.
-
-`taskPriority` is a settings key on `service`, `send`, and `decide`, legal beside `topic` alone.
-Its value is an integer, a quoted integer, a bare variable name, or a `"${...}"` expression, since `parsePriority` parses a constant as an integer and fails the deployment on anything else.
-`jobPriority` goes through the same method and the same check.
-
-`property key = "value"` is the `IoParameter` direction ADR-0037 added for a form field, admitted in a task's braces the same way.
-It reaches the wire as `operaton:property name=`, where a form field's is keyed by `id`, because `BpmnParseUtil.parseOperatonExtensionProperties` and `DefaultFormHandler.parseProperties` read the same tag by different attributes.
-
-`error when ` is a fourth member shape of the shared block.
-It parses as `ID ID ID Expr`, and the only other member opening with two identifiers is a parameter, `ID ID '='`, so the parser tells them apart at the third token.
-Neither `error` nor `when` is reserved.
-`error` is the trigger word every code site writes, and a mistyped `when` is a validator message saying what to write.
-The code slot is a cross-reference in the scope a `throw error(X)` resolves in, so an undeclared code draws the linker's message and jump-to-definition works.
-The condition is evaluated on the task's execution by `ExternalTaskEntity.evaluateThrowBpmnError`, where `VariableScopeElResolver` resolves `externalTask` to the task entity beside every process variable.
-The validator admits that name inside a mapping's condition and nowhere else, so in an `if` it stays an undeclared variable.
-The engine runs the mappings on a failure and again on a completion, in document order, and the first true one raises its code with the reported message.
-
-In the IR the three sit on the external variant of `ServiceTaskBinding`, as `taskPriority`, `properties`, and `errorMappings`.
-The type then cannot carry them under a class, expression, delegate, or decision binding, which keeps every direction's guard a type check (ADR-0022).
-A mapping counts as a use of its code, so a `bpmn:error` root is derived for it as for a throw (ADR-0016).
-A task prints its injected fields, then its properties, then its mappings, then its parameters and listeners.
-
-The import boundary follows ADR-0014.
-Two shapes refuse as `UnsupportedErrorMappingError`.
-A definition with `errorRef` and no `expression`, which `parseOperatonErrorEventDefinitions` fails the deployment on.
-One whose `errorRef` names no root, or a root without a code, the refusal a throw's dangling reference already draws.
-The moddle deletes an unresolved reference, so a definition written without `errorRef`, which the engine skips, arrives identical to a dangling one, and both refuse.
-Warned, one per item: the three on a class, expression, delegate, or decision binding, naming `parseExternalServiceTask` as the only reader, since the step runs without them either way.
-`errorCodeVariable` and `errorMessageVariable` on a mapping are stored and never read on the throw side, since `BpmnExceptionHandler.propagateBpmnError` writes them off the catching definition, so they warn as on a thrown error.
-A property missing its name or its value is skipped with a warning.
-A repeated name is kept once with its last value, as the engine's `HashMap` keeps it, at its first position, and the warning names the rewrite.
-A second `operaton:properties` block is reported, since the engine reads one.
-
-A message throw bound with a topic reaches `parseExternalServiceTask` too.
-The surface carries the three on the task kinds alone, so on a thrown message the generic sweeps report each as not imported.
-`send` and `decide` take them because `parseSendTask` and `parseBusinessRuleTask` without a `decisionRef` dispatch through the same `parseServiceTaskLike`.
-
-### Consequences
-
-- Good, because no word is reserved: `when` and `property` stay ordinary identifiers, and `error` already headed every code site.
-- Good, because a mapping's code has a declaration to jump to and a rename that reaches it.
-- Good, because a worker's priority, its properties, and its failure mappings round-trip without a warning.
-- Bad, because the shared block holds a fourth member shape, told from a parameter at the third token rather than by a head word.
-- Bad, because `externalTask` is a name the validator admits in one position and warns on in every other.
-- Bad, because a definition missing its `errorRef`, which the engine would skip, refuses on import, since the moddle leaves nothing to tell it from a dangling one.
-
-### Confirmation
-
-The `external-task` golden pair under `tests/golden` compiles to the shape `parseExternalServiceTask` reads, imports back with no warning, and prints to source that re-desugars to the same IR.
-Its suite asserts the complete binding of all three tasks at every hop and the frozen `extensionElements` text.
-The validator table in `packages/language/test/validating.test.ts` pins each diagnostic, and the import tables in `packages/transform/test/xml-to-ir.test.ts` pin each refusal and warning.
-`tests/e2e/forms-and-external-tasks.test.ts` fetches the charge from a real engine and fails it with a matching and a non-matching message.
-
-## Pros and Cons of the Options
-
-### `when` as a soft word
-
-- Good, because ADR-0017's rule holds: a word is reserved only where lookahead cannot do the job, and here it can.
-- Bad, because the validator checks the word, and the editor highlights it through semantic tokens alone.
-
-### `when` as a keyword
-
-- Good, because a mapping is told from a parameter by its second token.
-- Bad, because it reserves a word for a distinction the third token already makes.
-
-### A mapping as a member line
-
-- Good, because several mappings repeat the way parameters do.
-- Bad, because the block's member shapes are four.
-
-### A mapping as a settings key or a nested block
-
-- Bad, because a settings key holds one value where a task carries several mappings, and a nested block is the cost ADR-0032 refused for `fields { }`.
-
-### The extras on the binding, or on the node beside it
-
-- Good for the binding, because the type forbids them wherever the engine never reads them.
-- Bad for the node, because a rule then has to say they are empty unless the binding is external.
-
-### The extras on a thrown message too
-
-- Good, because the engine reads them there.
-- Bad, because the engine reads the priority and the mappings off the message definition and the properties off the event element, a split shape of its own for a fourth site.
-
-## More Information
-
-Amends ADR-0014 (the refusal list gains `UnsupportedErrorMappingError`, the warned list the extras on a non-external binding) and ADR-0021 (the `topic` binding carries the three extras).
-Amends ADR-0030 (a mapping is a third site naming a declared code) and ADR-0032 (the `property` direction rides a `topic` binding too).
-Amends ADR-0037 (`property` is shared with an external task, keyed differently on the wire) and ADR-0029 (an error mapping joins the members the braces hold).
-
-Amended by ADR-0042, under which a built-in mail or shell `type` binding joins the bindings the extras are refused on in a script and warned about on import, since `parseExternalServiceTask` alone reads them.
-
-Related decisions: ADR-0007 (the moddle fork, which gains `taskPriority` on `ServiceTaskLike` and a concrete `ErrorEventDefinition` type beside the trait carrying the catch-side variables).
-ADR-0016 (derived root elements, which a mapping's code now feeds).
diff --git a/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md b/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md
deleted file mode 100644
index aac8b90..0000000
--- a/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md
+++ /dev/null
@@ -1,80 +0,0 @@
----
-status: accepted
-date: 2026-09-13
-decision-makers: Marlon Kranz
----
-
-# Import BPMN resource assignment, and report the quantity attributes
-
-## Context and Problem Statement
-
-A user task drawn in the Modeler can say who does it in BPMN's own words: one `bpmn:humanPerformer` and any number of `bpmn:potentialOwner` elements, each carrying a formal expression.
-`BpmnParse.parseTaskDefinition` reads both into the same assignee and candidate lists that `operaton:assignee`, `operaton:candidateUsers`, and `operaton:candidateGroups` fill, so the deployed task is the same whichever spelling the document used.
-This surface writes the Operaton spelling alone, and the import dropped every resource role with a warning.
-ADR-0014 admitted that drop as the one warned item that changes what runs, since it changes who may claim the task.
-
-Separately, BPMN declares `startQuantity` and `completionQuantity` on every activity with a default of `1`, and ADR-0014 named `startQuantity` as its example of an attribute neither read nor reported.
-`BpmnParse` contains no line reading either, so a document setting one away from `1` runs as if it read `1`, and the import said nothing.
-
-## Considered Options
-
-- Refuse the BPMN spelling
-- Carry both spellings in the IR and the script
-- Import the two roles onto the Operaton attributes, with a warning naming the rewrite
-
-## Decision Outcome
-
-Chosen: import onto the Operaton attributes with a warning, and report a quantity away from `1`.
-
-The merge follows the engine's order.
-`parseTaskDefinition` runs `parseHumanPerformer` and `parsePotentialOwner` before `parseUserTaskCustomExtensions`, so role-derived entries come first and the attribute's entries after, joined with a comma.
-The one `bpmn:humanPerformer` gives the assignee.
-Each `bpmn:potentialOwner` is split as `parseCommaSeparatedList` splits, where a comma inside a `${...}` or `#{...}` body does not split.
-An entry `user(x)` is a candidate user, and `group(x)` or a bare entry is a candidate group.
-The exported document then carries the Operaton attributes alone, and the engine builds the same identity links from them.
-One warning per role element names the rewrite, so a reader knows the script says `assignee: "demo"` where the document said `bpmn:humanPerformer`.
-
-Two shapes refuse as `UnsupportedAssignmentError`, because the engine refuses to deploy them.
-A `bpmn:humanPerformer` beside `operaton:assignee`, which `parseUserTaskCustomExtensions` rejects as a duplicate assignee, and more than one `bpmn:humanPerformer`, which `parseHumanPerformer` rejects.
-The rest drops with a warning naming the method that reads nothing off it.
-A role with no `bpmn:formalExpression` child, a plain `bpmn:performer` or bare `bpmn:resourceRole`, and a role's `resourceRef` or parameter binding are each read by no line of `BpmnParse`.
-A `bpmn:expression` child typed `tFormalExpression` through `xsi:type` drops the same way, since the engine fetches the child by its tag name and finds none.
-On every activity other than a user task the roles stay a generic drop, since the engine reads none of them there.
-
-A `startQuantity` or `completionQuantity` other than `1` warns as an `unmappedConstruct`, naming the attribute and that `BpmnParse` never reads it.
-The moddle default is `1`, so a written `="1"` is silent, as ADR-0014 already holds for a BPMN attribute set to the value it reads back as when nothing is written.
-
-### Consequences
-
-- Good, because a Modeler-drawn user task deploys with the same assignee and candidates whichever spelling the document used.
-- Good, because the one warned drop that changed who may claim a task is gone from the import contract.
-- Bad, because the round trip rewrites the document: a `bpmn:potentialOwner` comes back as `operaton:candidateGroups`.
-- Bad, because a `bpmn:expression` typed `tFormalExpression` is a legal BPMN spelling the engine reads nothing of, so it drops under a warning that reads as a lost assignment.
-
-### Confirmation
-
-The import tables in `packages/transform/test/xml-to-ir.test.ts` pin the merge, the split, the two refusals, each drop, and the quantity warning.
-`tests/e2e/forms-and-external-tasks.test.ts` deploys the re-export of a task assigned in BPMN's own words to a real engine and reads the identity links back.
-
-## Pros and Cons of the Options
-
-### Refuse the BPMN spelling
-
-- Good, because the import never rewrites a document.
-- Bad, because it rejects a file the engine deploys and runs, which ADR-0014 does not license.
-
-### Carry both spellings
-
-- Good, because the round trip is byte-stable on the roles.
-- Bad, because the language then has two spellings for one assignment, and the IR two fields the engine merges into one.
-
-### Import onto the Operaton attributes with a warning
-
-- Good, because the deployed task is the same, and the warning names the rewrite as the `bpmn:manualTask` warning does.
-- Bad, because the document written back differs from the one read.
-
-## More Information
-
-Amends ADR-0014 (the refusal list gains `UnsupportedAssignmentError`; the warned list narrows the resource-assignment drop to the roles the engine never reads and gains the quantity attributes; `startQuantity` leaves the unreported list).
-
-Related decisions: ADR-0022 (engine attributes as named IR fields, where `assignee`, `candidateUsers`, and `candidateGroups` live).
diff --git a/docs/decisions/0040-engine-settings-on-synthesized-gateways.md b/docs/decisions/0040-engine-settings-on-synthesized-gateways.md
deleted file mode 100644
index 75c5730..0000000
--- a/docs/decisions/0040-engine-settings-on-synthesized-gateways.md
+++ /dev/null
@@ -1,108 +0,0 @@
----
-status: accepted
-date: 2026-09-13
-decision-makers: Marlon Kranz
----
-
-# Engine settings on synthesized gateways
-
-## Context and Problem Statement
-
-A gateway is the one flow node in this surface with no keyword and no name of its own.
-`if`, `while`, `do...while`, `parallel`, and a multi-branch `await` each synthesize one or two gateways from block structure, and the restructurer elides every one it matches back to a statement (ADR-0009, ADR-0010).
-Operaton still reads five execution settings off every gateway it deploys.
-`BpmnParse.parseExclusiveGateway`, `parseInclusiveGateway`, `parseParallelGateway`, and `parseEventBasedGateway` each call `parseAsynchronousContinuationForActivity` for `asyncBefore`, `asyncAfter`, and `exclusive`, and `createActivityOnScope` for `jobPriority` through `parsePriority`.
-`DefaultFailedJobParseListener.parseActivity` reads the retry cycle the same way it does on an activity.
-ADR-0022 gave every other flow node these five settings as named IR fields and left the four gateway kinds out, on the ground that a gateway with no textual identity has nowhere to author one.
-A file a real deployment produced can set any of the five on any of its gateways, and this surface imported every one of them as a dropped extension attribute.
-Where should a modeller put a setting for a construct they never write, and what should the import do with one it finds?
-
-## Decision Drivers
-
-- ADR-0013's rule that the compiler infers what it can rather than asking the author to spell a construct out.
-- ADR-0009's elision: every gateway still has to be synthesized and matched back, so a setting cannot cost a gateway its textual anonymity.
-- ADR-0014's import contract: what changes execution is refused, what does not is carried or warned, and nothing drops in silence.
-- ADR-0029's one bracket shape, scalar settings in a parens, reused rather than a second shape invented for this.
-- A join gateway sits beside its split under one statement, so the two need told-apart spellings inside one parens rather than a second parens.
-
-## Considered Options
-
-- A settings parens on the statement head, the join's settings inside it under a `join`-prefixed spelling
-- A second parens for the join, separate from the head's
-- A `gateway` keyword introducing an authored gateway with its own settings
-
-## Decision Outcome
-
-Chosen option: a settings parens on the statement head, carrying the split's five settings under their plain spelling and the join's under `join` plus the spelling, `joinAsyncBefore`, `joinAsyncAfter`, `joinExclusive`, `joinJobPriority`, `joinRetryCycle`.
-One parens rather than two, because a statement already reads as one construct, and a second parens would ask an author to place a setting on a gateway they cannot see or name.
-
-The parens sits where every other statement's settings sit: after the condition on `if` and `while`, and after the keyword on `parallel` and a multi-branch `await`, immediately before the block that follows.
-On `do...while` it follows the condition as well, and there it closes the statement, since no block follows.
-`if`, `parallel`, and a multi-branch `await` synthesize both a split and a join, so their head takes all ten keys, five plain and five `join`-prefixed.
-`while` and `do...while` are one gateway with a back-edge, so their head takes the five plain keys alone.
-A `join`-prefixed key there is an error naming the plain spelling to write instead, since there is no second gateway for it to belong to.
-An `if` chain lowers to one exclusive-gateway split no matter how many `else if` branches it carries.
-`lowerIf` mints a single split with one conditioned flow per branch, so the head parens govern the whole chain.
-An `else if` head takes none of its own.
-
-`parseEventBasedGateway` refuses `asyncAfter` on the gateway it deploys, so a multi-branch `await` head refuses the same setting.
-The message names the branch triggers as where to put it instead, since the catch events behind the gateway are activities and take it.
-The refusal repeats on import: a wait with several branches carrying `operaton:asyncAfter` throws `UnsupportedEventFeatureError` rather than importing a document that never deployed.
-`joinAsyncAfter` is unaffected, since the join a race falls through to is an ordinary exclusive gateway.
-
-A `join`-prefixed setting on a statement whose branches all terminate is a warning rather than an error.
-`statementTerminates` already decides that shape for the validator's unreachable-statement check, and the compiler's `pruneUnreachableJoin` drops the join on the same condition.
-The same predicate says a join setting there has nothing to act on: the join is pruned, so the setting is honest but inert.
-
-On the way out, `irToXml` writes the four `operaton:` attributes and the `operaton:FailedJobRetryTimeCycle` child on a gateway exactly as it does on an activity.
-Both go through the same `jobSettingAttrs` and `retryCycleElement` helpers, so a gateway's serialized shape is byte-identical to an activity's.
-`xmlToIr` reads the same five off all four gateway tags instead of reporting each as a dropped extension attribute.
-On the way to source, `irToDsl` prints the split's settings on the statement head it recovers and the join's as `join`-prefixed keys.
-That happens only when the join is the one-route pass-through the restructurer elides back to the statement, exactly the shape the compiler produces and re-parses to.
-A join with a second route out keeps its settings for its own head.
-A gateway degraded to a jump, or otherwise left with no statement to carry them, draws a new print warning, `droppedSetting`, naming the gateway whose settings the script leaves out.
-Execution listeners stay off every gateway kind: `JobSettings` carries the five settings alone, and `EngineAttributes` is the wider mixin that adds listeners for everything that keeps a textual identity to attach one to.
-
-### Consequences
-
-- Good, because a file a real deployment produced imports its gateway settings instead of losing them, closing the gap ADR-0022 left open.
-- Good, because the settings sit where an author already looks, the statement head, rather than inventing a second parens or a keyword only for this.
-- Good, because the two refusals, `asyncAfter` on a wait's head and on import, agree with each other and with what Operaton itself refuses to deploy.
-- Bad, because a gateway that never reaches a statement still loses its settings on print; `droppedSetting` reports the loss rather than preventing it.
- That is an unstructured jump target, a second-route join, or a join entered from one branch alone, which prints as a guard clause.
-- Bad, because ten keys now sit in one parens on three statement kinds, so a reader has to know which five are the join's before knowing which gateway a value tunes.
-
-### Confirmation
-
-The `gateway-settings` golden pair under `tests/golden` carries all five plain and all five join-prefixed keys, both `parallel` element kinds included, and its suite asserts the settings survive DSL to XML to DSL and back.
-The validator table in `packages/language/test/validating.test.ts` pins the loop and `await` refusals and the pruned-join warning, and the import and print tables in `packages/transform/test/xml-to-ir.test.ts` and `ir-to-dsl.test.ts` pin the carry, the `asyncAfter` refusal, and `droppedSetting`.
-
-## Pros and Cons of the Options
-
-### A settings parens on the statement head
-
-- Good, because it reuses the one bracket shape ADR-0029 already gives every element, at no grammar cost beyond a `SettingsParens?` slot on each of the five statement rules.
-- Good, because the join's setting sits next to the split's, matching how close the two gateways sit in the model.
-- Bad, because ten keys in one parens ask a reader to tell the join spellings from the split's by prefix alone.
-
-### A second parens for the join
-
-- Good, because the split and the join would each read as their own scalar list, with no prefix to track.
-- Bad, because a statement's head would then carry two adjacent parens, a shape nothing else in the grammar does, and `while`'s single gateway would leave the second one empty for no reason.
-
-### A `gateway` keyword
-
-- Good, because an authored gateway could carry a setting the ordinary way any other named element does.
-- Bad, because it reserves a word for an element ADR-0008 and ADR-0025 deliberately keep unauthored, and an authored gateway gains an id and stops eliding, breaking the restructurer's round trip.
-
-## More Information
-
-The five settings are documented beside `JobSettings` in `packages/transform/src/ir/types.ts`.
-The join spellings are derived at `JOIN_KEY_BY_ENGINE_KEY` and the statements that take them are listed at `GATEWAY_STATEMENT_RULES`, both in `packages/language/src/vocabulary.ts`.
-
-Amends ADR-0022 by dropping its rule that no engine attribute can be authored on a gateway, the rule this decision replaces.
-Amends ADR-0025 by dropping its note that a multi-branch `await`'s `asyncAfter` needs no refusal, since an author can now write one.
-Amends ADR-0014, whose refusal list gains the `asyncAfter` bullet for a wait with several branches.
-
-Related decisions: ADR-0008 and ADR-0010, the synthesis and id scheme every gateway here still follows.
-ADR-0029, the bracket shape reused.
diff --git a/docs/decisions/0041-per-run-job-settings-on-a-repetition.md b/docs/decisions/0041-per-run-job-settings-on-a-repetition.md
deleted file mode 100644
index d36da91..0000000
--- a/docs/decisions/0041-per-run-job-settings-on-a-repetition.md
+++ /dev/null
@@ -1,112 +0,0 @@
----
-status: accepted
-date: 2026-09-13
-decision-makers: Marlon Kranz
----
-
-# Per-run job settings on a repetition
-
-## Context and Problem Statement
-
-ADR-0027 gave a repeated statement the same five engine settings any other statement carries: `asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, and `retryCycle`.
-Written there, the five govern one job around the whole repetition, because Operaton reads them off the activity element itself.
-`BpmnParse.parseAsynchronousContinuationForActivity` also hands a multi-instance activity's `bpmn:multiInstanceLoopCharacteristics` element to `parseAsynchronousContinuation`, so the same three async settings written there mean one job per run instead.
-`DefaultFailedJobParseListener.parseActivity` reads a retry cycle off that element the same way.
-`createActivityOnScope` reads a job priority off the activity element alone, and nothing in the engine reads one off the loop element.
-A document a real deployment produced could carry any of the four on its loop element.
-This surface refused every one of them on import, since ADR-0027 gave the clause nowhere to write what the document already ran with.
-Where does a per-run job setting sit on this surface, and what should happen to a per-run job priority the engine reads nowhere?
-
-## Decision Drivers
-
-- The language README's Repetition section already named the gap, as it stood: "one job per run is a setting on the multi-instance element itself, which this surface has nowhere to write."
- Closing it is this decision's whole purpose.
-- ADR-0029's one bracket shape holds every scalar setting in a parens on the statement head; a second construct for four more settings needs its own justification.
-- ADR-0040 already solved the same shape of problem for a join gateway's settings, deriving a second spelling from the plain one with `prefixedSettingKey`.
-- ADR-0014's import contract: a document a real deployment produced should import back rather than refuse, whenever this surface can honestly carry what it wrote.
-
-## Considered Options
-
-- Four keys in the statement's own parens, prefixed `run`
-- A second parens attached to the `for` clause itself
-- The four settings spelled inside the clause's own grammar
-
-## Decision Outcome
-
-Chosen option: four keys in the statement's own parens, prefixed `run`.
-
-`runAsyncBefore`, `runAsyncAfter`, `runExclusive`, and `runRetryCycle` sit in the same parens a repeated statement's own five settings already occupy.
-Each is spelled by `runSettingKey(key) = prefixedSettingKey('run', key)`, the exact function ADR-0040's join spellings already use with a different prefix.
-No `runJobPriority` exists, because `createActivityOnScope` reads a job priority off the activity element alone.
-Nothing in the engine ever looks at the loop element for one, so a value written there would be honored by nothing.
-The validator refuses that specific spelling by name rather than folding it into the generic unknown-key message.
-A generic "not valid on a service task" would send an author hunting for a spelling that does not exist.
-None of the nine repeatable statement kinds also synthesizes a gateway, and none of the three statements that synthesize a join repeats.
-A `run`-prefixed key and a `join`-prefixed key therefore never both sit in one parens.
-A `run*` key on a statement with no `for` clause is an error naming the fix: one job per run says nothing coherent when there is no run.
-The message also names the plain spelling to write instead, for one job around the step.
-
-The four fields land flat on `LoopCharacteristics`, typed `Omit`, so the loop object is `JobSettings`-shaped everywhere but that one field.
-ADR-0027's `readLoop` in `ast-to-ir.ts` reads them with `readJobSettings(settingsOf(stmt.items), runSettingKey)`, the same reader every other job-setting owner already uses, just keyed by the prefixed function instead of the identity one.
-`jobSettingAttrs(loop)` and `retryCycleElement(moddle, loop)` in `ir-to-xml.ts` write the loop element's four attributes and its `operaton:FailedJobRetryTimeCycle` child exactly as they write an activity's.
-Both take a plain `JobSettings`-shaped argument, and neither reads the DSL spelling at all.
-On the XML side the loop element carries the plain, unprefixed attribute names, `operaton:asyncBefore` and not a `run`-prefixed one.
-The loop element is already its own element, distinct from the activity's, so nothing there needs disambiguating the way one shared parens does.
-Only the DSL surface needs the `run` prefix, since the split's settings and the loop's share one parens on the same statement.
-The moddle declaration makes the plain names legal and typed on that element in the first place: `AsyncCapable.extends` in `operaton-moddle.json` gains `bpmn:MultiInstanceLoopCharacteristics` beside `bpmn:FlowNode`.
-Upstream camunda-bpmn-moddle already declares the same properties there through `Collectable`'s own superclass.
-Before this, the loop element's `asyncBefore` and friends parsed into `$attrs` as untyped strings, invisible to every typed reader ADR-0040 built.
-
-On import, `readRunSettings` in `xml-to-ir.ts` reads the same four plain attribute names and the retry-cycle child off the loop element, the same shape `readJobSettings` reads off any other owner.
-`sweepRepetition` keeps its existing warning naming `operaton:jobPriority` on the loop element, since the engine still never reads one there.
-On the way to source, `jobSettingItems(el.loop, runSettingKey)` in `irToDsl`'s `engineSettings` prints the four beside the statement's own settings, guarded by `repeats(el.loop)`.
-A loop with nothing to repeat over carries none by construction, and prints none.
-
-### Consequences
-
-- Good, because a document a real deployment produced, carrying async or retry settings on its multi-instance element, now imports rather than refusing, closing the gap the language README named.
-- Good, because no new IR shape was needed: `LoopCharacteristics` already existed, and every read, write, import, and print helper ADR-0040 built for `JobSettings` applies to it unchanged, keyed by a different function.
-- Bad, because a repeated statement's parens can now hold nine keys, five plain and four `run`-prefixed.
- A reader has to track which five govern the whole repetition and which four govern each run, the same trade ADR-0040 already accepted for a gateway's ten.
-- Neutral, because `jobPriority` in that same parens keeps exactly one meaning, the whole repetition, since `createActivityOnScope` reads it off the step regardless of what repeats around it.
- There is no ambiguity, only a spelling an author might expect and not find.
-
-### Confirmation
-
-The `repetition` golden pair under `tests/golden` carries the four `run` keys on at least one repeated statement, and its round-trip suite asserts the settings survive DSL to XML to DSL unchanged.
-The validator table in `packages/language/test/validating.test.ts` pins the without-a-clause refusal, the `runJobPriority` refusal, and the nine repeatable kinds that admit the four.
-The transform suites pin the loop element's written attributes, the import back into `LoopCharacteristics`, and the printed keys.
-The end-to-end suite deploys a repeated step with `runAsyncBefore: true` and asserts the job executor creates one job per run rather than one job around the whole loop.
-
-## Pros and Cons of the Options
-
-### Four keys in the statement's own parens, prefixed `run`
-
-- Good, because it reuses the one bracket shape ADR-0029 already gives every element, at no grammar cost.
-- Good, because it reuses the exact derivation ADR-0040's join spellings already use, so the validator, the printer, and the importer read one function for both prefixes.
-- Bad, because it grows the parens on a repeated statement to nine keys at once, five plain and four prefixed.
-
-### A second parens attached to the `for` clause
-
-- Good, because a run's settings would sit visually next to the clause that creates the runs, rather than mixed in with the whole step's own settings.
-- Bad, because it is a second bracket shape ADR-0029 exists specifically to avoid, one grammar rule and one printer branch for four settings that already have a home elsewhere.
-
-### The four settings spelled inside the clause's own grammar
-
-- Good, because visual adjacency to the clause is the same as the option above.
-- Bad, because the `for` clause is a phrase grammar, not a keyed settings list.
- Folding four settings into it needs new syntax for each one, where the settings-key list already reuses machinery built for every other element.
-
-## More Information
-
-The four fields are declared beside `LoopCharacteristics` in `packages/transform/src/ir/types.ts`.
-`runSettingKey` and `RUN_ENGINE_KEYS` are declared beside `JOIN_KEY_BY_ENGINE_KEY` in `packages/language/src/vocabulary.ts`.
-The moddle declaration is in `packages/transform/src/operaton-moddle.json`.
-
-Amends ADR-0027, closing the gap the language README's Repetition section named.
-Amends ADR-0014, whose `UnsupportedLoopCharacteristicsError` bullet no longer refuses an async or retry setting on the repetition element itself.
-
-Related decisions: ADR-0010 (the deterministic ids a repeated activity keeps).
-ADR-0022 (engine attributes as named IR fields, the convention this decision extends onto a second element).
-ADR-0029 (the one bracket shape reused here).
-ADR-0040 (the same per-carrier prefixing convention, reused with a different prefix).
diff --git a/docs/decisions/0042-mail-and-shell-as-a-type-binding.md b/docs/decisions/0042-mail-and-shell-as-a-type-binding.md
deleted file mode 100644
index f6b7468..0000000
--- a/docs/decisions/0042-mail-and-shell-as-a-type-binding.md
+++ /dev/null
@@ -1,120 +0,0 @@
----
-status: accepted
-date: 2026-09-13
-decision-makers: Marlon Kranz
----
-
-# Mail and shell tasks as a `type` binding
-
-## Context and Problem Statement
-
-Operaton runs two built-in behaviours off a service-task-like element that carry no code of their own: a mail task and a shell task.
-Each is dispatched by `operaton:type` instead of a class, an expression, or a delegate.
-`BpmnParse.parseServiceTaskLike` reads that attribute before any code attribute: `mail` builds `parseEmailServiceTask`, `shell` builds `parseShellServiceTask`, `external` builds `parseExternalServiceTask`, and any other value fails the deployment.
-This surface refused every other `operaton:type` on import, with no way to write either behaviour.
-Each behaviour also runs its own field checks before the engine deploys it.
-A mail task needs `to` and one of `text`/`html`; a shell task needs only `command`.
-Both refuse a field name their behaviour class does not declare, and a shell task alone also requires a fixed-value shape and three flags that must read `true` or `false`.
-Where does this surface let an author write a mail or a shell task, and which of these engine checks should the validator mirror before a document ever reaches the engine?
-
-## Decision Drivers
-
-- ADR-0021's precedent: an external task folded into `service` as a fourth binding rather than a fifth statement keyword, because the IR already treats every execution form as one tagged union.
-- ADR-0013's minimal keyword surface: a dedicated keyword per behaviour costs an English word an author permanently loses, for a distinction the engine already reads off one attribute.
-- ADR-0032's rule that a field's legal placement follows the engine's own parser, not its schema, and both new behaviours reach the identical `instantiateDelegate` call a class binding already reaches.
-- ADR-0014's honest import contract: a shape the engine refuses to deploy should be refused on import too, rather than carried into a process that cannot start.
-
-## Considered Options
-
-- A fifth binding key, `type`, carrying the engine's own discriminator value
-- Two dedicated keywords, `mail` and `shell`
-- Dedicated keys, `to:` for a mail task and `command:` for a shell task, with no `type` key at all
-
-## Decision Outcome
-
-Chosen option: a fifth binding key, `type`.
-
-`type: "mail"` or `type: "shell"` sits beside `class`, `expression`, `delegate`, and `topic` in a `service`, `send`, or `decide` task's parens.
-It is one more member of `SERVICE_TASK_BINDING_KEYS` and of the exactly-one-binding check the other four already share.
-The IR gains one variant on `ServiceTaskBinding`, `{ kind: 'builtin'; type: BuiltinTaskType; fields?: FieldInjection[] }`.
-It is tagged by the engine's own `type` value rather than by two separate IR kinds, so every exhaustive switch grows one case instead of two.
-
-It is legal on `service`, `send`, and `decide`, the three tags a code binding already reaches, since a `decide` task with no `decision` key falls back to the same `parseServiceTaskLike` path a service task takes.
-It is not legal on `throw` or `emit`.
-The same engine method also builds a thrown message's implementation when bound with a topic.
-A thrown message opens no member block here, so the fields a mail or shell task's required-field check demands could never be written on one.
-`THROW_BINDING_KEYS` excludes `type` for that reason.
-
-`field` lines ride a `type:` binding exactly as they ride `class:` or `delegate:`.
-`parseEmailServiceTask` and `parseShellServiceTask` both build their behaviour through `instantiateDelegate`, the identical call a `class` binding's `ClassDelegateActivityBehavior` reaches, so `applyFieldDeclaration` runs the same check regardless of which behaviour declared the names.
-`FIELD_BINDING_KEYS` grows from `class`/`delegate` to `class`/`delegate`/`type`, one list every reader already reads.
-
-The validator mirrors the three checks the engine's parse runs before it builds a mail or shell behaviour.
-It asks only once `type` is the binding actually written; beside another binding, that conflict is what needs fixing first.
-`BUILTIN_REQUIRED_FIELDS` names each required group, keyed by type; `BUILTIN_FIELD_NAMES` names every field the behaviour class declares, in its own declaration order, and refuses any other with the same fact `applyFieldDeclaration` would throw on deployment.
-On a shell task alone, the validator also mirrors the value shapes `validateFieldDeclarationsForShell` casts before it looks at any of them.
-Every field must be a quoted literal rather than a `"${...}"` expression, and the three flag fields must read `true` or `false`.
-All three tables live once in `vocabulary.ts`, read by both the validator and the importer, so a shape refused on one side and accepted on the other cannot happen by construction.
-
-`resultVariable` beside `type:` is accepted and ignored, the same as beside `topic:` today, since `parseResultVariable` is read inside `parseServiceTaskLike` but neither the mail nor the shell branch consults it.
-Shell output has one spelling here, `field outputVariable = "..."`, the same field `ShellActivityBehavior.execute` writes the process's stdout to.
-Mapping a second name onto it would spell one runtime fact two ways for no reader's benefit.
-
-`validateFieldDeclarationsForShell` checks a shell flag case-insensitively, so `wait: "True"` deploys clean.
-`ShellActivityBehavior.readFields` later compares it with `"true".equals(...)`, case-sensitively, and reads it as `false`.
-The validator refuses that spelling in a script; the importer carries it as written and warns of the false read.
-
-Import reads `mail` and `shell` back off `operaton:type` on the same three tags.
-It refuses the same shapes the engine refuses: a missing required-field group, an undeclared field name, and, on a shell task, an expression-valued field or a flag outside `true`/`false`.
-A `type` outside `external`, `mail`, and `shell` keeps refusing exactly as before, the same `addError` branch `parseServiceTaskLike` itself takes.
-A `type` on a thrown message refuses too, for the same reason.
-Whatever a `type` binding shadows, a code attribute Operaton would never read past it, is dropped with the same shadowing warning an `external` binding's shadowed attributes already draw.
-
-### Consequences
-
-- Good, because a document a real deployment produced, carrying a mail or shell task, now imports and recompiles rather than refusing, the same gap ADR-0021 already closed for an external topic.
-- Good, because no keyword is reserved: `mail` and `shell` stay ordinary identifiers, free for a variable or a step name.
-- Good, because the required-field, field-name, and shell-value-shape facts each live once, read by the validator and the importer alike, so the two cannot drift apart.
-- Bad, because the binding surface now spans five keys instead of four, and the exactly-one-binding message has to name a fifth.
-- Bad, because the shell flag's case-sensitivity gap, a written `"True"` deploying and reading as `false`, is a fact this surface documents rather than one it closes.
-
-### Confirmation
-
-The `mail-and-shell` golden pair under `tests/golden` carries a mail `service` task and a shell `send` task, and its suite asserts both round-trip byte for byte.
-The validator table in `packages/language/test/validating.test.ts` pins the required-field, undeclared-field, and shell-value-shape refusals, and the fifth row of the exactly-one-binding table.
-The transform suites pin the import back, the shadowing warning, and the printed source.
-The end-to-end suite deploys a mail task and asserts the engine refuses one with no body, citing the same message `validateFieldDeclarationsForEmail` throws.
-
-## Pros and Cons of the Options
-
-### A fifth binding key, `type`
-
-- Good, because it matches ADR-0021's own precedent: one more member of a binding list the IR, the validator, and the printer already treat as one tagged union.
-- Good, because `field` rides this binding for free, the same machinery `class` and `delegate` already extend.
-- Bad, because the binding's own value, `"mail"` or `"shell"`, names the engine's attribute rather than a word this surface invented.
-
-### Two dedicated keywords, `mail` and `shell`
-
-- Good, because a reader would see both behaviours named directly, rather than as a value under a shared `type` key.
-- Bad, because it reserves two English words no earlier binding needed, for a distinction the IR already collapses into one binding kind.
-
-### Dedicated keys, `to:` and `command:`, with no `type` key
-
-- Good, because a mail task's recipient would sit directly in the parens rather than inside a member block.
-- Bad, because a mail task takes up to eight fields and a shell task twelve, more than a settings key per field can hold without duplicating the member-block machinery `field` already provides.
-
-## More Information
-
-The IR variant is declared beside `ServiceTaskBinding` in `packages/transform/src/ir/types.ts`.
-`TYPE_BINDING_KEY`, `TYPE_BINDING_VALUES`, `BUILTIN_FIELD_NAMES`, `BUILTIN_REQUIRED_FIELDS`, and `SHELL_FLAG_FIELDS` are declared beside `SERVICE_TASK_BINDING_KEYS` in `packages/language/src/vocabulary.ts`.
-
-Amends ADR-0021, whose "exactly one of the four" binding count grows to five.
-Amends ADR-0032, whose field-binding rule grows from `class`/`delegate` to `class`/`delegate`/`type`.
-Amends ADR-0014, which narrows the `type` refusal to a value outside `external`, `mail`, and `shell`, and whose warned list gains a mail or shell task's shadowed attributes.
-
-Related decisions: ADR-0006 (the engine-agnostic IR whose tagged union this decision extends).
-ADR-0013 (the minimal keyword surface a fifth key, rather than two more keywords, follows).
-ADR-0014 (the honest import contract behind every refusal above).
-ADR-0021 (folding an external topic into `service` as a fourth binding, the precedent this decision repeats for a fifth).
-ADR-0026 (task kinds on the authoring surface, the three tags `parseServiceTaskLike` dispatches from).
-ADR-0032 (field injection, whose placement rule this decision extends to a third binding).
diff --git a/docs/decisions/0043-result-variable-beside-an-expression-binding.md b/docs/decisions/0043-result-variable-beside-an-expression-binding.md
deleted file mode 100644
index 1f662ef..0000000
--- a/docs/decisions/0043-result-variable-beside-an-expression-binding.md
+++ /dev/null
@@ -1,87 +0,0 @@
----
-status: accepted
-date: 2026-09-13
-decision-makers: Marlon Kranz
----
-
-# A result variable rides an expression binding, never a class or delegate one
-
-## Context and Problem Statement
-
-`resultVariable` names the process variable a service task's return value is stored in.
-This surface accepted it on a `service`, `send`, or `decide` task beside any of the five bindings, and the importer carried `operaton:resultVariable` beside any binding into the IR.
-Operaton does not.
-`BpmnParse.parseServiceTaskLike` reads the attribute once, through `parseResultVariable`, which also accepts the older `resultVariableName` spelling.
-The `class` branch and the `delegateExpression` branch each fail the deployment when it is set, with the message `'resultVariableName' not supported for elements using ''`, where the element is `serviceTask`, `sendTask`, or `businessRuleTask`.
-Only the `expression` branch hands it to `ServiceTaskExpressionActivityBehavior`.
-The `type` branches for `mail`, `shell`, and `external` never read it, so beside `topic:` and `type:` it stays accepted and ignored, as ADR-0042 already records.
-A `decide` task with a `decision` key takes a separate path, `parseDmnBusinessRuleTask`, which reads the attribute into `DmnBusinessRuleTaskActivityBehavior`, so beside `decision:` it is read.
-The golden `engine-attributes` fixture carried the exact shape the engine refuses, a class binding beside a result variable, and every suite was green.
-What should the validator and the importer do with a result variable beside a class or delegate binding?
-
-## Decision Drivers
-
-- ADR-0014's contract: a shape the engine refuses to deploy is refused on import, never carried into a process that cannot start.
-- The validator's standing rule, applied in ADR-0037 and ADR-0042, that a deployment refusal the engine's parser raises is reported in the editor first, with the fix named.
-- One fact, one home: which binding a result variable rides is a fact about `parseServiceTaskLike`, and both sides should read it the same way.
-
-## Considered Options
-
-- Refuse it in the validator and on import
-- Warn and drop it on import, and keep accepting it in a script
-- Keep accepting it on both sides
-
-## Decision Outcome
-
-Chosen option: refuse it in the validator and on import, because the engine fails the deployment and ADR-0014 refuses what fails deployment.
-
-The validator reports an error on the `resultVariable` key of a `service`, `send`, or `decide` task whose one binding is `class` or `delegate`.
-The message quotes the engine's own refusal text under the element name the engine uses, and names the fix: bind with `expression` to store the return value, or drop the setting.
-It runs beside the `type` checks, only once exactly one binding is written; a task with two bindings has that conflict to fix first.
-Beside `expression:` and `decision:` the setting stays clean, and beside `topic:` and `type:` it stays accepted and ignored.
-
-The importer throws `UnsupportedServiceTaskFormError` for a service, send, or business rule task without a `decisionRef` that carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`.
-The refusal quotes the same engine text.
-Beside `operaton:expression` the attribute imports and prints as before.
-
-The `engine-attributes` golden pair rebinds its `AssessBodywork` task with an expression, keeps its `resultVariable`, and is refrozen; its `(node id, attribute, value)` contract is unchanged.
-
-### Consequences
-
-- Good, because a script that would fail deployment draws an error at the setting, with the engine's own reason and the binding that does store a return value.
-- Good, because a document the engine refuses no longer imports, prints, and recompiles into a document the engine still refuses.
-- Bad, because a script carrying the combination draws an error rather than a dropped setting, though it never deployed either way.
-- Bad, because the engine's refusal text is quoted by the validator and by the importer separately, so a change in the engine's wording is two edits.
-
-### Confirmation
-
-The binding table in `packages/language/test/validating.test.ts` pins the refusal on `service`, `send`, and `decide` under both bindings, and the clean row for `expression`.
-The refusal table in `packages/transform/test/xml-to-ir.test.ts` pins the import refusal for both attribute spellings.
-The `engine-attributes` round-trip suite pins the refrozen artifact byte for byte and imports it without a warning.
-
-## Pros and Cons of the Options
-
-### Refuse it in the validator and on import
-
-- Good, because it is the only option under which every document this surface accepts deploys.
-- Bad, because an author who wrote the combination gets an error rather than a quiet fix.
-
-### Warn and drop it on import, and keep accepting it in a script
-
-- Good, because an imported document keeps its binding and loses only the attribute the engine would have refused.
-- Bad, because the printed script would then differ from the document in a way the author has to notice in a warning, and the same script written by hand would still fail to deploy.
-
-### Keep accepting it on both sides
-
-- Good, because nothing changes.
-- Bad, because the surface would keep producing documents the engine refuses, against ADR-0014, and the golden fixture pinning that shape would keep proving nothing.
-
-## More Information
-
-The validator's refused-binding table sits beside the `type` binding tables in `packages/language/src/bpmn-script-validator.ts`; the importer's check sits in `readServiceTaskBinding` in `packages/transform/src/xml-to-ir.ts`.
-
-Amends ADR-0014, whose `UnsupportedServiceTaskFormError` bullet gains this case.
-
-Related decisions: ADR-0014 (the honest import contract behind the refusal).
-ADR-0021 (the binding list this rule reads).
-ADR-0042 (the `type` binding, whose parse-mirroring checks this one sits beside).
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
new file mode 100644
index 0000000..88f587e
--- /dev/null
+++ b/docs/decisions/README.md
@@ -0,0 +1,41 @@
+# Architecture decision records
+
+Each file in this directory records one design decision behind BPMNscript: the problem, the options weighed, the choice, and what it costs.
+Every record follows the MADR shape in [adr-template.md](adr-template.md), and states the decision as it stands today.
+Where a record names what the engine does, that behaviour was read from Operaton 2.1.0.
+
+| Number | Title | Status |
+| ---------------------------------------------------------------------- | ------------------------------------------------------------------- | -------- |
+| [0000](0000-use-markdown-architectural-decision-records.md) | Use Markdown Architectural Decision Records | accepted |
+| [0001](0001-use-apache-2-license.md) | Use Apache 2.0 License | accepted |
+| [0002](0002-use-mono-repo-structure.md) | Use Mono-repo Structure | accepted |
+| [0003](0003-use-langium-as-language-workbench.md) | Use Langium as Language Workbench | accepted |
+| [0004](0004-use-vscode-as-primary-ide-target.md) | Use VS Code as Primary IDE Target | accepted |
+| [0005](0005-in-editor-conversion-webview-sidebar.md) | In-Editor Conversion: Webview Sidebar and Two Layers | accepted |
+| [0006](0006-target-users-without-bpmn-knowledge.md) | Target users without BPMN knowledge, and minimize boilerplate | accepted |
+| [0007](0007-intermediate-representation-ast-bpmn.md) | An intermediate representation between the AST and BPMN XML | accepted |
+| [0008](0008-fork-camunda-moddle-extension.md) | Fork the Camunda moddle extension as a local Operaton one | accepted |
+| [0009](0009-auto-layout-and-expansion-hint.md) | Diagram interchange: auto-layout and the sub-process expansion hint | accepted |
+| [0010](0010-deterministic-synthesized-ids.md) | Deterministic ids for synthesized elements, reserved and respelled | accepted |
+| [0011](0011-structured-grammar-one-bracket-shape.md) | A structured, code-like grammar with one bracket shape | accepted |
+| [0012](0012-honest-bpmn-import.md) | Honest BPMN import: refusals, warnings, and the parse tiers | accepted |
+| [0013](0013-validator-mirrors-parse-time-rules.md) | The validator mirrors the engine's parse-time rules | accepted |
+| [0014](0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md) | Restructure the IR into a DSL with dominator analysis | accepted |
+| [0015](0015-the-print-declares-every-variable-it-reads.md) | The print declares every variable it reads | accepted |
+| [0016](0016-task-kinds-on-the-authoring-surface.md) | Task kinds on the authoring surface | accepted |
+| [0017](0017-service-task-bindings.md) | Service task bindings, including external, mail and shell | accepted |
+| [0018](0018-forms-and-field-injection.md) | Forms and field injection | accepted |
+| [0019](0019-a-call-activitys-variable-mapping.md) | A call activity's variable mapping | accepted |
+| [0020](0020-listeners-reuse-on-inside-the-attribute-block.md) | Listeners reuse `on` inside the attribute block | accepted |
+| [0021](0021-operaton-engine-attributes-as-named-ir-fields.md) | Operaton engine attributes as named IR fields | accepted |
+| [0022](0022-repetition-the-for-clause-and-its-per-run-job-settings.md) | Repetition: the `for` clause and its per-run job settings | accepted |
+| [0023](0023-event-roots-from-usage-and-declared-codes.md) | Event root elements derived from usage, and declared codes | accepted |
+| [0024](0024-event-trigger-payloads-paren-slot.md) | Event trigger payloads in one paren slot | accepted |
+| [0025](0025-events-at-the-process-boundary.md) | Events at the process boundary: starts, ends and throws | accepted |
+| [0026](0026-intermediate-catch-events.md) | Intermediate catch events | accepted |
+| [0027](0027-boundary-events-attached-to-an-activity.md) | Boundary events attached to an activity | accepted |
+| [0028](0028-compensation-as-subprocess-undo-block.md) | Compensation as a subprocess undo block | accepted |
+| [0029](0029-work-that-can-be-given-up.md) | Work that can be given up: `attempt` and `cancel` | accepted |
+| [0030](0030-link-events-for-import-round-trip-symmetry.md) | Link events for import round-trip symmetry | accepted |
+| [0031](0031-inclusive-and-event-based-gateways.md) | Inclusive and event-based gateways | accepted |
+| [0032](0032-expressions-raw-templates-and-escapes.md) | Expressions: raw templates, escapes, and what is lowered as EL | accepted |
diff --git a/docs/glossary.md b/docs/glossary.md
index 590003b..d75f13c 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -11,12 +11,20 @@ Terms that show up across the READMEs, the ADRs, and the code.
A `decide` step names a deployed table by its key and reads back what it returns; the `.dmn` artifact itself is authored and deployed outside this tool.
- **Operaton**: the process engine this project targets.
It loads a BPMN file and executes the workflow it describes.
+- **Token**: BPMN's representation of a thread of execution inside a running process instance.
+ It sits on one flow node at a time and moves along sequence flows, splitting into several at a parallel gateway fork and merging back at its join.
+- **Job**: what Operaton schedules for anything that runs asynchronously, such as an async continuation or a timer.
+ A job carries a priority and, on failure, works through a retry cycle before the engine gives up on it.
+- **Java delegate**: a Java class implementing Operaton's `JavaDelegate` interface.
+ A `service X(class: "...")` step binds to one, and Operaton calls it synchronously in the engine's JVM.
+- **External task and worker**: an external task is a service task the engine does not run itself; a `service X(topic: "...")` step publishes work under a topic instead.
+ A worker is the separate process that polls Operaton's REST API, fetches work under that topic, and reports it complete or failed.
- **DSL**: domain-specific language.
A small language built for one narrow purpose.
BPMNscript is a DSL for writing BPMN processes as text.
- **DI**: diagram interchange.
The `` part of a BPMN file that stores diagram coordinates, meaning where each box sits.
- BPMNscript regenerates it automatically on export; see [ADR-0003](decisions/0003-auto-layout-for-diagram-interchange.md).
+ BPMNscript regenerates it automatically on export; see [ADR-0009](decisions/0009-auto-layout-and-expansion-hint.md).
- **Multi-instance**: the BPMN marker that makes one activity run several times, once per element of a collection or a fixed number of times.
BPMNscript writes it from a `for` clause on the step; Operaton keeps the run counters on the repetition and a `loopCounter` on each run.
- **MIWG**: the Model Interchange Working Group.
@@ -26,7 +34,7 @@ Terms that show up across the READMEs, the ADRs, and the code.
- **Langium**: the TypeScript toolkit used to build the language.
From one grammar file it generates the parser, the AST types, and a language server.
- See [ADR-0002](decisions/0002-use-langium-as-language-workbench.md).
+ See [ADR-0003](decisions/0003-use-langium-as-language-workbench.md).
- **Grammar**: the file (`bpmn-script.langium`) that defines the DSL's syntax, meaning which keywords and shapes are valid.
Langium turns it into a parser.
- **Parser**: the code that reads `.bpmnscript` text and builds the AST.
@@ -35,8 +43,8 @@ Terms that show up across the READMEs, the ADRs, and the code.
The parser's structured, in-memory representation of a source file, with nodes for each `process`, `user`, `service`, `if`, `while`, `parallel`, and so on.
- **IR**: intermediate representation.
A small set of plain objects (`packages/transform/src/ir/types.ts`) that every transform reads from or writes to, in both directions.
- See [ADR-0006](decisions/0006-engine-agnostic-intermediate-representation.md).
- [ADR-0022](decisions/0022-engine-attributes-as-named-ir-fields.md) and [ADR-0023](decisions/0023-listeners-on-the-attribute-block.md) extend that shape to the engine's execution settings, input/output parameters, and lifecycle listeners.
+ See [ADR-0007](decisions/0007-intermediate-representation-ast-bpmn.md).
+ [ADR-0021](decisions/0021-operaton-engine-attributes-as-named-ir-fields.md) and [ADR-0020](decisions/0020-listeners-reuse-on-inside-the-attribute-block.md) extend that shape to the engine's execution settings, input/output parameters, and lifecycle listeners.
- **Validator**: checks that a parsed process is structurally sound and reports errors in the editor.
The full list of checks is in [packages/language/README.md](../packages/language/README.md#diagnostics).
- **LSP**: Language Server Protocol.
@@ -44,11 +52,13 @@ Terms that show up across the READMEs, the ADRs, and the code.
Langium implements it for us; the VS Code extension is the host.
- **moddle / bpmn-moddle**: the library that reads and writes BPMN XML as objects.
A _moddle extension_ (here, `operaton-moddle.json`) teaches it about the extra `operaton:` attributes, and about the nested types they can carry, such as an input/output value or a listener binding.
- Declaring those nested shapes is what lets an import warning attribute a dropped element to the specific step that carried it, instead of reporting an anonymous loss ([ADR-0022](decisions/0022-engine-attributes-as-named-ir-fields.md)).
+ Declaring those nested shapes is what lets an import warning attribute a dropped element to the specific step that carried it, instead of reporting an anonymous loss ([ADR-0021](decisions/0021-operaton-engine-attributes-as-named-ir-fields.md)).
+- **Round trip**: compiling a `.bpmnscript` file to BPMN and decompiling the result back to `.bpmnscript`, or the reverse.
+ Used to check what each hop keeps and what it warns about or drops.
## The repository
- **Golden file**: a known-good reference output checked into the repo so a test can diff against it instead of recomputing the expected result.
See [tests/golden/README.md](../tests/golden/README.md).
- **Monorepo / workspace**: this repo holds several npm packages (`packages/*`) in one place; npm "workspaces" link them so they can depend on each other without publishing.
- See [ADR-0005](decisions/0005-use-mono-repo-structure.md).
+ See [ADR-0002](decisions/0002-use-mono-repo-structure.md).
diff --git a/examples/README.md b/examples/README.md
index f56a776..98ce191 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -2,16 +2,14 @@
Deployment fixtures for running a compiled `.bpmn` file on Operaton.
-One mode exists today: `spring-boot/`, with Operaton embedded in a Spring Boot application.
-Two more are planned and have no fixture yet: an Operaton REST engine with external-task workers, and a standalone Operaton engine without Spring Boot.
+One mode exists: `spring-boot/`, with Operaton embedded in a Spring Boot application.
## `spring-boot/`
-The fixture runs Operaton 2.1.0 embedded in a Spring Boot 4.0.6 application on Java 17, exposing the Operaton REST API on port 8080.
-It's packaged as a Docker image so the integration test harness can start and stop it programmatically.
+Operaton 2.1.0 embedded in a Spring Boot 4.0.6 application on Java 17, exposing the Operaton REST API on port 8080.
+It's packaged as a Docker image so the E2E harness can start and stop it.
-Thirty DSL sources live under `spring-boot/processes/`, one per construct or construct combination.
-Running `bpmns build` on any of them produces the deployable `.bpmn`.
+The DSL sources under `spring-boot/processes/` cover one construct or construct combination each, and `bpmns build` on any of them produces the deployable `.bpmn`.
| Source | Covers |
| ----------------------- | -------------------------------------------------------------------------- |
@@ -45,31 +43,20 @@ Running `bpmns build` on any of them produces the deployable `.bpmn`.
| `plan-selection` | A task form with a required enum and a length-bounded text field |
| `nightly-report` | An async join, per-run jobs on a repetition, and a shell task |
| `outage-notice` | A mail task, deployed and never started |
+| `expense-claim` | A `while` loop, an `if`/`else`, and a `parallel` block in one process |
[Running processes on Operaton](spring-boot/README.md#running-processes-on-operaton-demo) is a hands-on tour of the two loan-approval processes.
### Testcontainers harness
-Fifteen E2E test files in `tests/e2e/` use [testcontainers-node](https://testcontainers.com/) to start the Docker image, deploy compiled BPMN over the Operaton REST API, start instances, and assert engine behavior: `invoice-approval`, `parallel-approval`, `loan-approval`, `loan-approval-kopp`, `boundary-events` (over `order-handling`), `awaiting-confirmation`, `engine-extensions`, `service-boundary-and-compensation` (over `charge-with-recovery` and `compensating-saga` in one container boot), `event-positions` (over `order-intake`, `stock-alert`, `scheduled-audit`, `support-ticket`, and `order-rework` in one container boot), `task-kinds`, `repetition` (over `batch-approval` and `empty-batch` in one container boot), `booking-attempt`, `branch-and-race` (over `order-dispatch`), `forms-and-external-tasks` (over `card-charge` and `plan-selection` in one container boot), and `job-settings-and-mail` (over `nightly-report` and `outage-notice` in one container boot).
-The remaining fixtures are demo-only.
+The E2E tests in `tests/e2e/` use [testcontainers-node](https://testcontainers.com/) to start the Docker image, deploy compiled BPMN over the Operaton REST API, start instances, and assert what the engine does with what the compiled document only declares.
+The suite builds the image and starts one engine once, and every file runs against it; each file names the example it drives or the examples it deploys.
+`deploy-sweep` deploys every golden under `tests/golden/`, every fixture under `tests/fixtures/`, and every example here, once as written and once rebuilt from the tool's own print of it.
-The compensation half of `service-boundary-and-compensation` asserts that the `emit compensation` its `on error` handler raises reaches the undo block of the subprocess that completed before the charge failed.
-`booking-attempt` asserts the same machinery on the other route into it: when the block is given up, the engine must have run the undo block of the step that had already finished before the cancel handler opens.
-`branch-and-race` asserts the two engine behaviors the compiled document cannot show on its own.
-An inclusive join waits for exactly the branches the conditions opened, so completing one of two open tasks leaves the token at the join.
-The first trigger of a race to fire cancels the wait the other branch was holding: the losing timer job disappears, and the instance's history holds the message catch and the `Handover` task behind it, with no row for the timer catch or `ChaseCarrier`.
-`forms-and-external-tasks` asserts what the engine does with what a compiled document only declares: a worker fetching the charge sees its priority and its properties, a failure it reports ends through the declined handler when its message matches the mapping and stays on the topic for a retry when it does not, and the form service refuses a submission that leaves the required enum empty or names a value outside its list.
-Its last case runs a user task assigned with `bpmn:humanPerformer` and `bpmn:potentialOwner` through the importer, deploys the re-exported document, and asserts the engine builds the assignee, candidate users, and candidate groups the source declared.
-`job-settings-and-mail` asserts what a job setting makes the engine do, which the compiled document only declares.
-With the join's job definition suspended, an async parallel join parks one job per arriving branch, and the instance completes once the job executor is let at them.
-A repetition of three with `runAsyncBefore` has a job definition on the run and none on the repetition itself, so three jobs park, one per run.
-A shell task running `echo` writes its output, newline included, and its exit code into the variables its fields name.
-The mail task deploys because the fixture's `pom.xml` supplies the mail library the engine declares `provided`, and a mail task built without a body is refused at deployment with the engine's own message, which is the check the validator mirrors.
-
-Docker tests run by default and are skipped only when `SKIP_DOCKER_TESTS=true`, which is what CI sets.
+Docker tests run by default, in CI too, and are skipped only when `SKIP_DOCKER_TESTS=true`.
## Adding a new deployment mode
1. Create a subdirectory with a `README.md` and whatever runtime files it needs, such as a `pom.xml`, `Dockerfile`, or `docker-compose.yml`.
2. Implement the `FixtureAdapter` interface from `tests/fixtures/types.ts` in a new file under `tests/fixtures/adapters/`.
-3. Register the mode in `tests/fixtures/index.ts` by extending the `startFixture` switch.
+3. Have `startFixture` in `tests/fixtures/index.ts` pick the adapter, for example by a mode argument, since it starts the one adapter today.
diff --git a/examples/spring-boot/README.md b/examples/spring-boot/README.md
index 7b40774..3137773 100644
--- a/examples/spring-boot/README.md
+++ b/examples/spring-boot/README.md
@@ -1,6 +1,6 @@
# examples/spring-boot
-Operaton 2.1.0 embedded in Spring Boot 4.0.6, the deployment fixture for the BPMNscript E2E test harness.
+Operaton 2.1.0 embedded in Spring Boot 4.0.6, the deployment fixture for the BPMNscript end-to-end (E2E) test harness.
The engine starts with nothing pre-deployed; the testcontainers harness deploys definitions at test time over REST.
It doubles as a hands-on demo.
@@ -49,7 +49,7 @@ The deployment and lifecycle calls the fixture adapter makes:
| `GET` | `/engine-rest/task?processInstanceId={id}` | List active user tasks for a process instance |
| `POST` | `/engine-rest/task/{id}/complete` | Complete a user task, optionally setting variables |
-The assertions reach further into the same API through `tests/helpers/engine-rest.ts`: message correlation and signal broadcast, event subscriptions, a process instance's liveness, jobs, and the two history endpoints.
+The assertions reach further into the same API through `tests/helpers/engine-rest.ts`.
## Admin credentials
@@ -87,6 +87,7 @@ Re-run this whenever you edit or add a `.bpmnscript`.
### 3. Start the engine (demo profile)
The `demo` profile turns on classpath auto-deployment and serves the web apps.
+It needs a JDK 17 and Maven on `PATH`; the Docker variant below needs neither.
From `examples/spring-boot/`, either run it with Maven:
```bash
@@ -110,7 +111,7 @@ Every compiled process is deployed, so pick one in Cockpit to watch instances an
### The loan approval walkthrough
-The classic WS-BPEL loan approval.
+The loan approval example from the [WS-BPEL 2.0 specification](https://docs.oasis-open.org/wsbpel/2.0/wsbpel-v2.0.html).
A request arrives with an `amount` and a `creditScore`.
Loans under 10,000 are screened by an automated risk assessment, and a low-risk small loan is approved automatically.
Everything else, meaning large loans and small loans that aren't low-risk, goes to a human "Approve loan" task.
@@ -118,10 +119,7 @@ The outcome is recorded as `decision = ACCEPTED` or `REJECTED`.
Its four service tasks are `JavaDelegate`s in [`src/main/java/com/example/loan/`](src/main/java/com/example/loan).
1. Start a request.
- In Tasklist, choose Start process, then Loan Approval.
- There's no start form, so use "Add a variable" to set two variables and then Start:
- - `amount`, type `Long`, for example `5000`
- - `creditScore`, type `Long`, for example `750`
+ In Tasklist, choose Start process, then Loan Approval, and fill in the start form: Loan amount (`amount`), for example `5000`, and Credit score (`creditScore`), for example `750`.
2. Watch the automatic path.
With `amount = 5000` and `creditScore = 750` the risk assessment returns low risk, so the loan is approved with no human step and ends with `decision = ACCEPTED`.
@@ -130,24 +128,21 @@ Its four service tasks are `JavaDelegate`s in [`src/main/java/com/example/loan/`
3. Watch the human path.
Start another request with `amount = 25000`, or a small loan that fails the risk screen (`amount = 5000`, `creditScore = 400`, since the assessment marks scores below 600 as high risk).
The instance stops at the "Approve loan" task, which appears under the `demo` user in Tasklist.
- Open it and, on Complete, add one variable:
- - `approved`, type `Boolean`, `true` to accept and `false` to reject
-
+ Open it, tick "Approve the loan?" to accept or leave it unticked to reject, and Complete.
The instance then records `ACCEPTED` or `REJECTED`.
- Complete the task without setting `approved` and it defaults to rejected.
In Cockpit, open the live diagram of a running instance to watch the token sit on "Approve loan" and move once you complete it.
### Variant: Kopp 2009
-`loan-approval-kopp` is the parallel-rating variant of that process, following Kopp et al. (2009).
+`loan-approval-kopp` is the parallel-rating variant of that process, following Kopp, Martin, Wutke, and Leymann's ["The Difference Between Graph-Based and Block-Structured Business Process Modelling Languages"](https://doi.org/10.18417/emisa.4.1.1) (EMISA 4(1), 2009).
The request is rated in parallel by two external bureaus and one internal service, and a low internal rating additionally routes through a human "Manual risk assessment".
It accepts when both external bureaus rate low, or when the internal rating is low and the assessor agrees (`assessorRes = low`).
Its delegates live in `src/main/java/com/example/loan/kopp/`.
-Start it the same way, through Start process and Loan Approval (Kopp 2009), with `amount` and `creditScore` as `Long`.
-The manual-assessment task edits the pre-seeded `assessorRes` string: set it to `low` to approve that path, leave it `high` to decline.
-Some telling inputs:
+Start it the same way, through Start process and Loan Approval (Kopp 2009), with `amount` and `creditScore` in the start form.
+The manual-assessment task's form shows the pre-seeded `assessorRes`: set it to `low` to approve that path, leave it `high` to decline.
+Inputs worth trying:
- `creditScore = 750`, `amount = 5000`: all three rate low.
Both externals rating low already grants acceptance, but the assessor task still appears because the internal rating is low, so complete it to let the instance finish.
diff --git a/examples/spring-boot/compile-processes.sh b/examples/spring-boot/compile-processes.sh
index 6abd8c3..8f426d8 100755
--- a/examples/spring-boot/compile-processes.sh
+++ b/examples/spring-boot/compile-processes.sh
@@ -29,7 +29,7 @@ shopt -s nullglob
count=0
for src in "$SRC_DIR"/*.bpmnscript; do
base="$(basename "$src" .bpmnscript)"
- node "$CLI" build "$src" -o "$OUT_DIR/$base.bpmn"
+ node "$CLI" build "$src" -o "$OUT_DIR/$base.bpmn" --force
count=$((count + 1))
done
diff --git a/examples/spring-boot/processes/awaiting-confirmation.bpmnscript b/examples/spring-boot/processes/awaiting-confirmation.bpmnscript
index 7230b68..9a82ce4 100644
--- a/examples/spring-boot/processes/awaiting-confirmation.bpmnscript
+++ b/examples/spring-boot/processes/awaiting-confirmation.bpmnscript
@@ -1,12 +1,6 @@
-// Awaiting Confirmation: a request goes out to an external system and the
-// process blocks at an intermediate message catch until that system
-// correlates a confirmation back. There is no user task in between: the
-// token simply sits at the `await`, which is the property the deployment
-// test drives directly over the REST API.
-//
-// The service tasks point at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this
-// process deploys and runs on the demo profile without additional Java classes.
+// The token parks at the `await` until an outside system correlates the
+// confirmation back. There is no user task in between, so only the message
+// releases the instance.
process awaiting-confirmation {
start RequestSubmitted
diff --git a/examples/spring-boot/processes/batch-approval.bpmnscript b/examples/spring-boot/processes/batch-approval.bpmnscript
index 6cbef71..1e8d0f4 100644
--- a/examples/spring-boot/processes/batch-approval.bpmnscript
+++ b/examples/spring-boot/processes/batch-approval.bpmnscript
@@ -1,12 +1,5 @@
-// Batch approval: one process whose steps repeat, laid out so each form of
-// repetition is observable over the REST API. Three approvals are offered at
-// once, three sign-offs come one after another, a spot check stops as soon as
-// two of its three runs are in, the log step runs its count out, and a review
-// sub-process runs its task once per line of the list it maps in.
-//
-// The logging step points at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this
-// process deploys and runs on the demo profile without additional Java classes.
+// Every repetition form in one process, each on a step whose runs are
+// observable over the REST API.
process batch-approval(label: "Batch approval") {
start BatchReceived
diff --git a/examples/spring-boot/processes/booking-attempt.bpmnscript b/examples/spring-boot/processes/booking-attempt.bpmnscript
index 6b836b9..b2cb5ae 100644
--- a/examples/spring-boot/processes/booking-attempt.bpmnscript
+++ b/examples/spring-boot/processes/booking-attempt.bpmnscript
@@ -1,21 +1,10 @@
-// Seat booking as a block of work that can be given up. `attempt BookAndPay`
-// holds the seat, charges the travel card and issues the ticket; a guard on
-// the declined charge ends that path with
-// `end BookingAbandoned cancel(label: "Give up the booking")`, and
-// `on BookAndPay: cancel` catches it. Started with `chargeDeclined` set, the
-// charge is declined and the escape task opens; started without it, the
-// block completes and the booking goes to confirmation.
+// A block of work that can be given up: started with `chargeDeclined` set,
+// the cancel end inside the block fires and `on BookAndPay: cancel` catches it.
//
-// Giving up the block undoes the steps inside it that had already finished
-// and carry an `on compensation` block, and it undoes them before the cancel
-// handler opens. `HoldSeat` has one, so the seat goes back on sale.
-// `ChargeCard` has none, so it stays completed and nothing reverses it.
-// Nothing outside the block is undone either.
-//
-// Every service task points at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so
-// this process deploys and runs on the demo profile without additional Java
-// classes.
+// Giving up the block undoes every finished step inside it that carries an
+// `on compensation` block, before the cancel handler opens. `HoldSeat` has
+// one, so the seat goes back on sale; `ChargeCard` has none, so it stays
+// completed. Nothing outside the block is undone.
process booking-attempt {
var chargeDeclined: boolean
diff --git a/examples/spring-boot/processes/card-charge.bpmnscript b/examples/spring-boot/processes/card-charge.bpmnscript
index c988adf..44d5db3 100644
--- a/examples/spring-boot/processes/card-charge.bpmnscript
+++ b/examples/spring-boot/processes/card-charge.bpmnscript
@@ -1,12 +1,9 @@
-// Card charge handed to an external worker that polls the "charge-card"
-// topic. The worker fetching it sees the task's priority and its two
-// properties beside the topic, and a failure it reports with the message
-// "declined" is raised as the declared error, which the boundary handler
-// catches and hands to a review task. A failure with any other message
-// leaves the task on the topic for a retry.
+// A failure the worker reports with the message "declined" is raised as the
+// declared error and caught by the boundary handler; a failure with any other
+// message leaves the task on the topic for a retry.
//
-// The fixture's worker polls "print-label" alone, so an instance of this
-// process waits until something fetches "charge-card" over the REST API.
+// The fixture's worker polls "print-label" alone, so an instance waits until
+// something fetches "charge-card" over the REST API.
process card-charge {
error PAYMENT_DECLINED(message: "The card issuer declined the charge")
diff --git a/examples/spring-boot/processes/charge-with-recovery.bpmnscript b/examples/spring-boot/processes/charge-with-recovery.bpmnscript
index 21b085b..9fae58b 100644
--- a/examples/spring-boot/processes/charge-with-recovery.bpmnscript
+++ b/examples/spring-boot/processes/charge-with-recovery.bpmnscript
@@ -1,17 +1,6 @@
-// Card charge with an error-boundary recovery path. The charge is a service
-// task bound to a delegate that conditionally fails; an interrupting error
-// boundary on that task catches the failure and routes to a review task
-// instead of letting the process fail outright. Started with the
-// `failCharge` process variable set, the charge fails and the review task
-// opens; started without it, the charge succeeds and the process confirms
-// normally.
-//
-// The confirmation step points at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java; the
-// charge step points at
-// examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java,
-// so this process deploys and runs on the demo profile without additional
-// Java classes.
+// Started with `failCharge` set, the charge delegate throws the declared
+// error and the boundary handler opens the review task; started without it,
+// the charge goes through.
process charge-with-recovery {
error CHARGE_FAILED(message: "The payment charge could not be completed")
diff --git a/examples/spring-boot/processes/compensating-saga.bpmnscript b/examples/spring-boot/processes/compensating-saga.bpmnscript
index 110126c..79a69d6 100644
--- a/examples/spring-boot/processes/compensating-saga.bpmnscript
+++ b/examples/spring-boot/processes/compensating-saga.bpmnscript
@@ -1,17 +1,6 @@
-// Seat-booking saga with a runnable compensation trigger. A subprocess
-// reserves a seat and owns an `on compensation` undo block that releases it;
-// once the reservation completes, a service task charges the customer's card
-// through the same conditional delegate `charge-with-recovery` uses. Started
-// with `failCharge` set, the charge raises the process's declared error, the
-// process-level `on error` handler runs as an event-subprocess, and its
-// `emit compensation` should unwind the completed reservation.
-//
-// The service tasks point at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java and
-// the conditional
-// examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java,
-// so this process deploys and runs on the demo profile without additional
-// Java classes.
+// Started with `failCharge` set, the charge delegate throws the declared
+// error, and the `emit compensation` in the process-level handler unwinds the
+// reservation subprocess that had already completed.
process compensating-saga {
error CHARGE_FAILED(message: "The payment charge could not be completed")
diff --git a/examples/spring-boot/processes/empty-batch.bpmnscript b/examples/spring-boot/processes/empty-batch.bpmnscript
index e446728..9a57502 100644
--- a/examples/spring-boot/processes/empty-batch.bpmnscript
+++ b/examples/spring-boot/processes/empty-batch.bpmnscript
@@ -1,11 +1,5 @@
-// An empty batch: the one repetition count that is not a count of runs. The
-// engine resolves the cardinality on entering the loop, so a count of zero
-// leaves the loop without ever running the step inside it and the token walks
-// straight to the end. Nothing is logged and no run of the step is recorded.
-//
-// The logging step points at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this
-// process deploys and runs on the demo profile without additional Java classes.
+// The engine resolves the cardinality on entering the loop, so a count of
+// zero walks the token past the step without running it once.
process empty-batch(label: "Empty batch") {
start BatchOpened
diff --git a/examples/spring-boot/processes/expense-claim.bpmnscript b/examples/spring-boot/processes/expense-claim.bpmnscript
new file mode 100644
index 0000000..2f59d95
--- /dev/null
+++ b/examples/spring-boot/processes/expense-claim.bpmnscript
@@ -0,0 +1,38 @@
+process expense-claim {
+ var amount: number
+ var compliant: boolean
+
+ start ClaimSubmitted {
+ form {
+ amount: number "Claimed amount"
+ }
+ }
+
+ service CheckPolicy(label: "Check the expense policy", class: "com.example.expense.CheckPolicyDelegate")
+
+ while (!compliant) {
+ user ReviseClaim(label: "Revise the claim", assignee: "demo") {
+ form {
+ amount: number "Corrected amount"
+ compliant: boolean "Claim now complies"
+ }
+ }
+ }
+
+ if (amount > 500) {
+ user ApproveClaim(label: "Approve the claim", assignee: "manager")
+ } else {
+ service AutoApprove(label: "Approve automatically", class: "com.example.expense.AutoApproveDelegate")
+ }
+
+ parallel {
+ {
+ service BookPayment(label: "Book the payment", class: "com.example.expense.BookPaymentDelegate")
+ }
+ {
+ service NotifyClaimant(label: "Notify the claimant", class: "com.example.expense.NotifyDelegate")
+ }
+ }
+
+ end ClaimSettled
+}
diff --git a/examples/spring-boot/processes/external-task.bpmnscript b/examples/spring-boot/processes/external-task.bpmnscript
index da31917..6f87322 100644
--- a/examples/spring-boot/processes/external-task.bpmnscript
+++ b/examples/spring-boot/processes/external-task.bpmnscript
@@ -1,5 +1,5 @@
-// Hand the shipment label print-out off to an external worker that polls the
-// "print-label" topic, instead of running the work in the Operaton process.
+// The fixture's own worker polls "print-label", so the instance completes
+// without an outside process.
process shipment-label {
start OrderPlaced
diff --git a/examples/spring-boot/processes/invoice-approval.bpmnscript b/examples/spring-boot/processes/invoice-approval.bpmnscript
index 5492af3..50b09cb 100644
--- a/examples/spring-boot/processes/invoice-approval.bpmnscript
+++ b/examples/spring-boot/processes/invoice-approval.bpmnscript
@@ -1,10 +1,4 @@
-// Invoice Approval: the canonical BPMNscript example, deployed by the
-// spring-boot end-to-end test. This is also the file the README and the docs
-// use to show what the language looks like.
-//
-// The service task's class name below must stay in sync with the Java delegate at
-// examples/spring-boot/src/main/java/com/example/invoice/AutoApproveDelegate.java.
-// That is the class Operaton loads and runs when the auto-approve branch fires.
+// The canonical example: the README shows the language with this file.
process invoice-approval {
start ReviewStart {
diff --git a/examples/spring-boot/processes/loan-approval-kopp.bpmnscript b/examples/spring-boot/processes/loan-approval-kopp.bpmnscript
index 8a24a5f..6ce74ce 100644
--- a/examples/spring-boot/processes/loan-approval-kopp.bpmnscript
+++ b/examples/spring-boot/processes/loan-approval-kopp.bpmnscript
@@ -13,13 +13,10 @@ process loan-approval-kopp(label: "Loan Approval (Kopp 2009)") {
}
}
- // Conservative default: the manual assessment counts as high risk unless the
- // assessor explicitly lowers it (and so an undefined assessorRes never reaches
- // the final gateway).
+ // Seeds `assessorRes = high`, so the assessment counts as high risk unless
+ // the assessor lowers it.
service InitRequest(label: "Initialize request", class: "com.example.loan.kopp.InitDelegate")
- // The request is rated in parallel by two external bureaus and one internal
- // service; the internal low-risk path additionally needs a human assessor.
parallel {
{
service CheckExternalS1(label: "Check external rating S1", class: "com.example.loan.kopp.ExternalRatingS1Delegate")
@@ -39,8 +36,6 @@ process loan-approval-kopp(label: "Loan Approval (Kopp 2009)") {
}
}
- // Conservative accept: both external bureaus low, or internal low AND the
- // human assessor agrees.
if ((extRes1 == "low" && extRes2 == "low") || (intRes == "low" && assessorRes == "low")) {
service AcceptLoan(label: "Accept loan", class: "com.example.loan.kopp.AcceptLoanDelegate")
} else {
diff --git a/examples/spring-boot/processes/loan-approval.bpmnscript b/examples/spring-boot/processes/loan-approval.bpmnscript
index c41318b..0f58453 100644
--- a/examples/spring-boot/processes/loan-approval.bpmnscript
+++ b/examples/spring-boot/processes/loan-approval.bpmnscript
@@ -1,5 +1,4 @@
process loan-approval(
- label: "Loan Approval",
documentation: "Decides a consumer loan request. Small loans are screened automatically and only the ones the screening does not clear reach a loan officer.",
historyTimeToLive: "P180D",
candidateStarterUsers: "demo",
@@ -21,13 +20,10 @@ process loan-approval(
}
}
- // Small loans are screened by an automated risk assessment.
if (amount < 10000) {
service AssessRisk(label: "Assess risk", class: "com.example.loan.RiskAssessmentDelegate")
}
- // A low-risk small loan is approved automatically; everything else
- // (large loans, or small loans that are not low-risk) needs a human decision.
if (amount < 10000 && risk == "low") {
service AutoApprove(label: "Auto-approve", class: "com.example.loan.AutoApproveDelegate")
} else {
diff --git a/examples/spring-boot/processes/nightly-report.bpmnscript b/examples/spring-boot/processes/nightly-report.bpmnscript
index 76f017e..3113cad 100644
--- a/examples/spring-boot/processes/nightly-report.bpmnscript
+++ b/examples/spring-boot/processes/nightly-report.bpmnscript
@@ -1,14 +1,8 @@
-// Nightly report: two job settings and a shell task, three things an XML
-// comparison cannot show the engine doing. The two gather steps run on parallel branches
-// and their join carries `joinAsyncBefore`, so each arriving token parks a job
-// at the join until the job executor runs it. The count step repeats three
-// times with `runAsyncBefore`, so each run parks a job of its own rather than
-// one job around the repetition. The last step is a shell task the engine runs
-// itself: `echo report-host`, whose output and exit code land in the two
-// variables the fields name.
-//
-// The shell command is `echo`, which every image the fixture builds on ships,
-// so the process runs on the demo profile without additional Java classes.
+// Two job settings and a shell task, which compile to XML that shows nothing
+// of what the engine does. `joinAsyncBefore` parks one job per token arriving
+// at the join; `runAsyncBefore` on a repetition parks one job per run, none
+// around the repetition; the shell task writes `echo`'s output and exit code
+// into the variables its fields name.
process nightly-report(label: "Nightly report") {
start NightlyRun
diff --git a/examples/spring-boot/processes/order-dispatch.bpmnscript b/examples/spring-boot/processes/order-dispatch.bpmnscript
index 002bdc8..4ccb7e2 100644
--- a/examples/spring-boot/processes/order-dispatch.bpmnscript
+++ b/examples/spring-boot/processes/order-dispatch.bpmnscript
@@ -1,20 +1,9 @@
-// Order dispatch: an inclusive fork whose branches are picked by the order
-// amount, then a race between an outside confirmation and a chase timer.
-//
-// The fork carries three branches. A large order is audited, any order with a
-// positive amount is recorded, and the `else` branch catches the orders no
-// condition claims. Both conditioned branches can hold at once, so the number
-// of tasks the fork opens, and the join's willingness to wait for exactly
-// those branches, follow from the amount the instance starts with. Both
-// branches are conditioned on purpose: an unconditioned branch of an inclusive
-// fork is always taken, which would leave the fallback nothing to pick up.
-//
-// The `await` block is a race: the engine subscribes to the message and parks
-// the timer job at the same moment, and the first of the two to fire cancels
-// the other. Exactly one of `Handover` and `ChaseCarrier` can ever be offered.
-//
-// Every step is a user task, so the process needs no Java class of its own and
-// every state it reaches is observable over the REST API.
+// An inclusive fork and a race. The join waits for exactly the branches the
+// conditions opened, and both branches carry a condition on purpose: an
+// unconditioned branch of an inclusive fork is always taken, which would leave
+// the `else` nothing to pick up. In the `await`, the first trigger to fire
+// cancels the other, so only one of `Handover` and `ChaseCarrier` is ever
+// offered.
process order-dispatch(label: "Order dispatch") {
var amount: number
diff --git a/examples/spring-boot/processes/order-handling.bpmnscript b/examples/spring-boot/processes/order-handling.bpmnscript
index 2bffcb0..6363f6f 100644
--- a/examples/spring-boot/processes/order-handling.bpmnscript
+++ b/examples/spring-boot/processes/order-handling.bpmnscript
@@ -1,21 +1,3 @@
-// Order handling with an embedded sub-process. The two payment steps are
-// grouped into a `subprocess` that Operaton Cockpit renders as an expanded
-// box with its children inside.
-//
-// Two activities also carry attached boundary events. The review task has
-// three: a non-interrupting timer that sends a nudge when review is taking
-// too long without blocking it, an interrupting message that lets an external
-// system confirm the order was already approved and skip straight to payment,
-// and a non-interrupting message that a supervisor sends to ask for a status
-// note while the review stays open. The payment subprocess carries an
-// interrupting error boundary that hands a declined card to a
-// customer-service agent, and a non-interrupting escalation boundary that
-// pulls a manager into checking a large payment while capture keeps going.
-//
-// The service tasks point at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this
-// process deploys and runs on the demo profile without additional Java classes.
-
process order-handling {
error PAYMENT_DECLINED(message: "The card could not be charged")
escalation LARGE_PAYMENT_FLAGGED
diff --git a/examples/spring-boot/processes/order-intake.bpmnscript b/examples/spring-boot/processes/order-intake.bpmnscript
index 358b836..db77d7b 100644
--- a/examples/spring-boot/processes/order-intake.bpmnscript
+++ b/examples/spring-boot/processes/order-intake.bpmnscript
@@ -1,7 +1,5 @@
-// The order arrives as a correlated message rather than from a caller, the
-// warehouse is told mid-flow, and the acknowledgment ends the path. Neither
-// thrown message carries an implementation, so the engine passes the token
-// straight through both.
+// Neither thrown message carries an implementation, so the engine passes the
+// token straight through both.
process order-intake {
start OrderReceived message("OrderReceived", label: "An order arrives")
diff --git a/examples/spring-boot/processes/order-recovery.bpmnscript b/examples/spring-boot/processes/order-recovery.bpmnscript
index 5388755..00d8b2b 100644
--- a/examples/spring-boot/processes/order-recovery.bpmnscript
+++ b/examples/spring-boot/processes/order-recovery.bpmnscript
@@ -1,14 +1,3 @@
-// Order handling with an error handler and an escalation. The payment and
-// shipping steps are grouped into a `subprocess`; if a step fails it raises the
-// PAYMENT_FAILED error, which the interrupting `on error` handler catches to
-// cancel the order. A slow warehouse emits the SLOW_FULFILLMENT escalation,
-// which the non-interrupting `alongside` handler picks up; a supervisor is
-// notified while the main flow keeps running.
-//
-// The service tasks point at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this
-// process deploys and runs on the demo profile without additional Java classes.
-
process order-recovery {
error PAYMENT_FAILED(message: "The card could not be charged")
escalation SLOW_FULFILLMENT
diff --git a/examples/spring-boot/processes/order-reminder.bpmnscript b/examples/spring-boot/processes/order-reminder.bpmnscript
index 56b77e1..b193489 100644
--- a/examples/spring-boot/processes/order-reminder.bpmnscript
+++ b/examples/spring-boot/processes/order-reminder.bpmnscript
@@ -1,14 +1,3 @@
-// Order handling with a timer reminder and a message handler. The fulfillment
-// steps are grouped into a `subprocess`; while they run, a non-interrupting
-// `on timer("PT1H", alongside)` handler fires an hour in to chase the
-// warehouse without stopping the main flow (the reminder pattern). A process-
-// level `on message("OrderCancelled")` handler catches a cancellation the engine
-// correlates from outside and rolls the order back.
-//
-// The service tasks point at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this
-// process deploys and runs on the demo profile without additional Java classes.
-
process order-reminder {
start OrderPlaced
user ReviewOrder(label: "Review order", assignee: "demo")
diff --git a/examples/spring-boot/processes/order-rework.bpmnscript b/examples/spring-boot/processes/order-rework.bpmnscript
index 8199acb..4f71f8c 100644
--- a/examples/spring-boot/processes/order-rework.bpmnscript
+++ b/examples/spring-boot/processes/order-rework.bpmnscript
@@ -1,12 +1,7 @@
-// Order Rework: a rework loop drawn with BPMN's own link events rather than
-// a `goto`. Rejecting the review sends the token off at the throw; it
-// reappears at the catch with no flow drawn between the two, then loops back
-// into Review by name once the fix is done.
-//
-// Both steps are user tasks assigned to "demo", so this process deploys and
-// runs on the demo profile without additional Java classes.
+// The link pair jumps from the throw to the catch with no flow drawn between
+// them; the `goto` closes the loop by name.
-process order-rework(label: "Order Rework") {
+process order-rework {
var approved: boolean
start Received
diff --git a/examples/spring-boot/processes/outage-notice.bpmnscript b/examples/spring-boot/processes/outage-notice.bpmnscript
index c81e6bb..458f29f 100644
--- a/examples/spring-boot/processes/outage-notice.bpmnscript
+++ b/examples/spring-boot/processes/outage-notice.bpmnscript
@@ -1,7 +1,6 @@
-// Outage notice: one mail task the engine sends itself. The harness and the
-// demo profile deploy this process and never start it, since sending needs an
-// SMTP server neither runs. Deploying is the proof: the engine builds the mail
-// behaviour at parse time and refuses a task missing a recipient or a body.
+// Deployed and never started: sending needs an SMTP server the fixture does
+// not run. Deploying alone proves the engine builds the mail behaviour at
+// parse time, where it refuses a task missing a recipient or a body.
process outage-notice(label: "Outage notice") {
start OutageDetected
diff --git a/examples/spring-boot/processes/parallel-approval.bpmnscript b/examples/spring-boot/processes/parallel-approval.bpmnscript
index ee42c53..1b31e3b 100644
--- a/examples/spring-boot/processes/parallel-approval.bpmnscript
+++ b/examples/spring-boot/processes/parallel-approval.bpmnscript
@@ -1,12 +1,3 @@
-// Parallel Approval: a parallel-gateway example deployed by the
-// spring-boot engine end-to-end test. Two user tasks run concurrently on
-// independent parallel branches; the process ends only after both complete.
-//
-// This exercises the parallel-gateway desugaring end-to-end: the `parallel
-// { { } { } }` construct produces a parallelGateway fork/join pair in the
-// BPMN XML, and Operaton's AND-split/AND-join semantics are verified at the
-// engine level by `tests/e2e/parallel-approval.test.ts`.
-
process parallel-approval {
start ParallelStart
parallel {
diff --git a/examples/spring-boot/processes/plan-selection.bpmnscript b/examples/spring-boot/processes/plan-selection.bpmnscript
index 97fb7b6..c55e80c 100644
--- a/examples/spring-boot/processes/plan-selection.bpmnscript
+++ b/examples/spring-boot/processes/plan-selection.bpmnscript
@@ -1,6 +1,5 @@
-// A task form Operaton Tasklist renders from the field list: a required
-// enum the engine refuses to submit without a value or with one outside its
-// list, and a free-text note bounded in length.
+// The engine's form service refuses a submission that leaves the required
+// enum empty or names a value outside its list.
process plan-selection {
start SignedUp
diff --git a/examples/spring-boot/processes/purchasing.bpmnscript b/examples/spring-boot/processes/purchasing.bpmnscript
index c47e987..85420f7 100644
--- a/examples/spring-boot/processes/purchasing.bpmnscript
+++ b/examples/spring-boot/processes/purchasing.bpmnscript
@@ -1,9 +1,5 @@
-// Purchasing: starts a purchase request and delegates the invoice sign-off
-// to the existing `invoice-approval` process (see invoice-approval.bpmnscript)
-// through a `call` activity, passing the order amount in and reading the
-// auto-approval flag (set by that process when the invoice clears without a
-// senior sign-off) back out. Deploy alongside invoice-approval.bpmnscript so
-// the engine can resolve the call by process id.
+// `autoApproved` is set by the called process's auto-approve delegate. Deploy
+// `invoice-approval` alongside, or the engine cannot resolve the call.
process purchasing {
start OrderPlaced {
diff --git a/examples/spring-boot/processes/scheduled-audit.bpmnscript b/examples/spring-boot/processes/scheduled-audit.bpmnscript
index 6c8d9ae..d080555 100644
--- a/examples/spring-boot/processes/scheduled-audit.bpmnscript
+++ b/examples/spring-boot/processes/scheduled-audit.bpmnscript
@@ -1,6 +1,5 @@
-// The timer definition parks a job at deployment. The date is far enough out
-// that the engine's own clock never reaches it, so the test fires the job by
-// hand and the assertion never depends on wall time.
+// The timer start parks a job at deployment. The date is far enough out that
+// the clock never reaches it, so the E2E test fires the job by hand.
process scheduled-audit {
start AuditWindowOpens timer(at: "2099-01-01T00:00:00", label: "The audit window opens")
diff --git a/examples/spring-boot/processes/script-task.bpmnscript b/examples/spring-boot/processes/script-task.bpmnscript
index 705df0b..591585e 100644
--- a/examples/spring-boot/processes/script-task.bpmnscript
+++ b/examples/spring-boot/processes/script-task.bpmnscript
@@ -1,7 +1,3 @@
-// Compute a discount amount inline with a short Groovy snippet: no external
-// delegate needed for such a small calculation. The start form supplies the
-// order amount when the process is launched from Tasklist.
-
process order-discount {
start OrderPlaced {
form {
diff --git a/examples/spring-boot/processes/service-delegate.bpmnscript b/examples/spring-boot/processes/service-delegate.bpmnscript
index 26ad761..95fe61d 100644
--- a/examples/spring-boot/processes/service-delegate.bpmnscript
+++ b/examples/spring-boot/processes/service-delegate.bpmnscript
@@ -1,5 +1,4 @@
-// Charge the customer's payment method through a Spring-managed delegate
-// bean, referenced by bean name rather than by fully-qualified Java class.
+// `chargeService` is the bean name `ChargeService` registers under.
process payment-charge {
start OrderPlaced
diff --git a/examples/spring-boot/processes/service-expression.bpmnscript b/examples/spring-boot/processes/service-expression.bpmnscript
index 3ec9c49..db580cc 100644
--- a/examples/spring-boot/processes/service-expression.bpmnscript
+++ b/examples/spring-boot/processes/service-expression.bpmnscript
@@ -1,6 +1,4 @@
-// Compute a shipping quote through a JUEL expression evaluated by Operaton
-// directly, rather than a Java delegate class. The start form supplies the
-// order reference passed to the bean.
+// `shippingBean` is the bean name `ShippingBean` registers under.
process shipping-quote {
start OrderPlaced {
diff --git a/examples/spring-boot/processes/stock-alert.bpmnscript b/examples/spring-boot/processes/stock-alert.bpmnscript
index 3eaa003..d6513b1 100644
--- a/examples/spring-boot/processes/stock-alert.bpmnscript
+++ b/examples/spring-boot/processes/stock-alert.bpmnscript
@@ -1,6 +1,5 @@
-// A broadcast starts the process and two branches run at once. Finishing the
-// escalation reaches a terminate end, which stops every path, including the
-// reorder task still sitting in the task list.
+// The terminate end stops every path, including the reorder task still
+// sitting in the task list.
process stock-alert {
start StockRunningLow signal("StockRunningLow", label: "Stock is running low")
diff --git a/examples/spring-boot/processes/support-ticket.bpmnscript b/examples/spring-boot/processes/support-ticket.bpmnscript
index bcd5d1d..6e77297 100644
--- a/examples/spring-boot/processes/support-ticket.bpmnscript
+++ b/examples/spring-boot/processes/support-ticket.bpmnscript
@@ -1,7 +1,3 @@
-// A ticket enters two ways: an agent opens it directly, or it arrives by a
-// correlated message before any instance exists. Both starts lead into the
-// same triage step, so the entry chosen never changes what happens next.
-
process support-ticket {
start ByAgent(label: "An agent opens a ticket")
start ByEmail message("TicketEmailed", label: "A ticket arrives by email")
diff --git a/examples/spring-boot/processes/task-kinds.bpmnscript b/examples/spring-boot/processes/task-kinds.bpmnscript
index 9cea48c..9727e02 100644
--- a/examples/spring-boot/processes/task-kinds.bpmnscript
+++ b/examples/spring-boot/processes/task-kinds.bpmnscript
@@ -1,15 +1,7 @@
-// Task kinds: one process carrying the four activity statements that are not a
-// user, service or script task, laid out so each one's engine behavior is
-// observable over the REST API. The `step` is a plain activity the engine
-// automates nothing for and walks straight past; the `send` and the `decide`
-// each invoke their binding and hand the token on; the `receive` is a wait
-// state that only a correlated message releases; the `user` task afterwards
-// makes the released instance visible.
-//
-// The send and decision steps point at the generic delegate
-// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java. The
-// decision step binds to code rather than a decision table so the process
-// deploys and runs on the demo profile with no DMN artifact.
+// The engine walks straight past the `step`, runs the `send` and `decide`
+// bindings, and parks at the `receive` until a message is correlated; the user
+// task after it makes the released instance visible. The `decide` binds to a
+// class rather than a decision table, so no DMN artifact is needed.
process task-kinds(label: "Task kinds") {
start OrderPlaced
diff --git a/examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java b/examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java
index b9b9dec..dd62b27 100644
--- a/examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java
+++ b/examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java
@@ -7,11 +7,8 @@
import org.slf4j.LoggerFactory;
/**
- * Charges a payment, conditionally failing: when the process variable
- * {@code failCharge} is {@code true}, it raises a BPMN error with code
- * {@code CHARGE_FAILED} instead of completing; otherwise it logs and
- * continues like {@link LogDelegate}. The boolean gate lets one deployment
- * drive both a failing run and a clean pass-through run.
+ * The {@code failCharge} process variable lets one deployment drive both a
+ * failing and a clean run.
*/
public class FailingChargeDelegate implements JavaDelegate {
diff --git a/examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java b/examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java
index 85eeadf..2ec0ed5 100644
--- a/examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java
+++ b/examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java
@@ -7,12 +7,10 @@
import org.slf4j.LoggerFactory;
/**
- * Generic delegate for any service task that just needs to log and continue.
- *
- * A task that injects a {@code marker} field also gets
- * {@code :execute:} appended to the {@link MarkerLog}, so
- * what the engine set on the delegate is readable from outside the JVM. A task
- * that injects none writes no variable at all.
+ * The generic delegate: logs and continues. The engine injects {@code marker}
+ * from the task's {@code field}; when set, its value goes into the
+ * {@link MarkerLog} so what the engine set on the delegate is readable from
+ * outside the JVM.
*/
public class LogDelegate implements JavaDelegate {
diff --git a/examples/spring-boot/src/main/java/com/example/demo/MarkerLog.java b/examples/spring-boot/src/main/java/com/example/demo/MarkerLog.java
index 5abad6b..9947caa 100644
--- a/examples/spring-boot/src/main/java/com/example/demo/MarkerLog.java
+++ b/examples/spring-boot/src/main/java/com/example/demo/MarkerLog.java
@@ -4,9 +4,8 @@
import org.operaton.bpm.engine.delegate.VariableScope;
/**
- * The ordered trace of engine callbacks a process instance leaves behind, held
- * in one comma separated process variable so a single read shows what the
- * engine invoked and in which order.
+ * The ordered trace of engine callbacks, one comma separated process variable
+ * so a single read shows what ran and in which order.
*/
final class MarkerLog {
@@ -15,20 +14,16 @@ final class MarkerLog {
private MarkerLog() {
}
- /** Appends one marker, starting the record when nothing has written yet. */
static void append(VariableScope scope, String marker) {
Object recorded = scope.getVariable(VARIABLE);
scope.setVariable(VARIABLE, recorded == null ? marker : recorded + "," + marker);
}
/**
- * The value of an injected field as a trailing marker segment, empty when
- * the binding carried no field.
- *
- * The field is declared as an {@link Expression} rather than as a
- * {@code String} because the engine hands both value forms over the same
- * slot: a literal arrives as a fixed value and a {@code ${...}} body as a
- * JUEL expression, and a field of any other type fails the deployment.
+ * The injected field is an {@link Expression} rather than a {@code String}
+ * because the engine hands a literal and a {@code ${...}} body over the
+ * same slot, and refuses any other field type when it first instantiates
+ * the delegate.
*/
static String injected(Expression field, VariableScope scope) {
return field == null ? "" : ":" + field.getValue(scope);
diff --git a/examples/spring-boot/src/main/java/com/example/demo/RecordingExecutionListener.java b/examples/spring-boot/src/main/java/com/example/demo/RecordingExecutionListener.java
index 0558cd1..eec0d7f 100644
--- a/examples/spring-boot/src/main/java/com/example/demo/RecordingExecutionListener.java
+++ b/examples/spring-boot/src/main/java/com/example/demo/RecordingExecutionListener.java
@@ -5,14 +5,8 @@
import org.operaton.bpm.engine.delegate.Expression;
/**
- * Execution listener that leaves a trace of itself in the process instance.
- *
- * Every invocation appends {@code :} to the
- * {@link MarkerLog}, so registering the same class on both the start and the
- * end of an activity yields two distinct markers. A listener carrying an
- * injected {@code marker} field appends its value as a third segment, so the
- * record shows both that the listener ran and what the engine set on it before
- * it did.
+ * Records {@code :} and the injected {@code marker}, if
+ * any, in the {@link MarkerLog}.
*/
public class RecordingExecutionListener implements ExecutionListener {
diff --git a/examples/spring-boot/src/main/java/com/example/demo/RecordingTaskListener.java b/examples/spring-boot/src/main/java/com/example/demo/RecordingTaskListener.java
index 2ed3aae..b549673 100644
--- a/examples/spring-boot/src/main/java/com/example/demo/RecordingTaskListener.java
+++ b/examples/spring-boot/src/main/java/com/example/demo/RecordingTaskListener.java
@@ -5,12 +5,9 @@
import org.operaton.bpm.engine.delegate.TaskListener;
/**
- * Task listener that leaves a trace of itself in the process instance.
- *
- * It appends {@code :} to the same
- * {@link MarkerLog} {@link RecordingExecutionListener} writes, so execution and
- * task events share one ordered record, and appends the value of an injected
- * {@code marker} field where one is set.
+ * Records {@code :} and the injected
+ * {@code marker}, if any, in the same {@link MarkerLog} the execution listener
+ * writes, so both share one ordered record.
*/
public class RecordingTaskListener implements TaskListener {
diff --git a/examples/spring-boot/src/main/java/com/example/expense/AutoApproveDelegate.java b/examples/spring-boot/src/main/java/com/example/expense/AutoApproveDelegate.java
new file mode 100644
index 0000000..4237cf0
--- /dev/null
+++ b/examples/spring-boot/src/main/java/com/example/expense/AutoApproveDelegate.java
@@ -0,0 +1,17 @@
+package com.example.expense;
+
+import org.operaton.bpm.engine.delegate.DelegateExecution;
+import org.operaton.bpm.engine.delegate.JavaDelegate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AutoApproveDelegate implements JavaDelegate {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AutoApproveDelegate.class);
+
+ @Override
+ public void execute(DelegateExecution execution) {
+ execution.setVariable("autoApproved", true);
+ LOG.info("AutoApprove [{}]: autoApproved=true", execution.getProcessInstanceId());
+ }
+}
diff --git a/examples/spring-boot/src/main/java/com/example/expense/BookPaymentDelegate.java b/examples/spring-boot/src/main/java/com/example/expense/BookPaymentDelegate.java
new file mode 100644
index 0000000..cbd02ba
--- /dev/null
+++ b/examples/spring-boot/src/main/java/com/example/expense/BookPaymentDelegate.java
@@ -0,0 +1,17 @@
+package com.example.expense;
+
+import org.operaton.bpm.engine.delegate.DelegateExecution;
+import org.operaton.bpm.engine.delegate.JavaDelegate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class BookPaymentDelegate implements JavaDelegate {
+
+ private static final Logger LOG = LoggerFactory.getLogger(BookPaymentDelegate.class);
+
+ @Override
+ public void execute(DelegateExecution execution) {
+ LOG.info("BookPayment [{}]: amount={}",
+ execution.getProcessInstanceId(), execution.getVariable("amount"));
+ }
+}
diff --git a/examples/spring-boot/src/main/java/com/example/expense/CheckPolicyDelegate.java b/examples/spring-boot/src/main/java/com/example/expense/CheckPolicyDelegate.java
new file mode 100644
index 0000000..2521f8f
--- /dev/null
+++ b/examples/spring-boot/src/main/java/com/example/expense/CheckPolicyDelegate.java
@@ -0,0 +1,25 @@
+package com.example.expense;
+
+import org.operaton.bpm.engine.delegate.DelegateExecution;
+import org.operaton.bpm.engine.delegate.JavaDelegate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class CheckPolicyDelegate implements JavaDelegate {
+
+ private static final Logger LOG = LoggerFactory.getLogger(CheckPolicyDelegate.class);
+
+ private static final double POLICY_LIMIT = 2000;
+
+ @Override
+ public void execute(DelegateExecution execution) {
+ Number claimed = (Number) execution.getVariable("amount");
+ double amount = claimed == null ? 0 : claimed.doubleValue();
+
+ boolean compliant = amount > 0 && amount <= POLICY_LIMIT;
+ execution.setVariable("compliant", compliant);
+
+ LOG.info("CheckPolicy [{}]: amount={} -> compliant={}",
+ execution.getProcessInstanceId(), amount, compliant);
+ }
+}
diff --git a/examples/spring-boot/src/main/java/com/example/expense/NotifyDelegate.java b/examples/spring-boot/src/main/java/com/example/expense/NotifyDelegate.java
new file mode 100644
index 0000000..beb1936
--- /dev/null
+++ b/examples/spring-boot/src/main/java/com/example/expense/NotifyDelegate.java
@@ -0,0 +1,17 @@
+package com.example.expense;
+
+import org.operaton.bpm.engine.delegate.DelegateExecution;
+import org.operaton.bpm.engine.delegate.JavaDelegate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class NotifyDelegate implements JavaDelegate {
+
+ private static final Logger LOG = LoggerFactory.getLogger(NotifyDelegate.class);
+
+ @Override
+ public void execute(DelegateExecution execution) {
+ LOG.info("Notify [{}]: the claimant is told the claim is settled",
+ execution.getProcessInstanceId());
+ }
+}
diff --git a/examples/spring-boot/src/main/java/com/example/invoice/ChargeService.java b/examples/spring-boot/src/main/java/com/example/invoice/ChargeService.java
index 56c294e..0a4a3df 100644
--- a/examples/spring-boot/src/main/java/com/example/invoice/ChargeService.java
+++ b/examples/spring-boot/src/main/java/com/example/invoice/ChargeService.java
@@ -7,9 +7,8 @@
import org.springframework.stereotype.Component;
/**
- * Bean referenced by the payment-charge process via
- * {@code delegate = "${chargeService}"} (operaton:delegateExpression). The bean
- * name "chargeService" is what the delegate expression resolves against.
+ * The {@code ${chargeService}} delegate expression in
+ * {@code service-delegate.bpmnscript} resolves to this bean by name.
*/
@Component("chargeService")
public class ChargeService implements JavaDelegate {
diff --git a/examples/spring-boot/src/main/java/com/example/invoice/PrintLabelWorker.java b/examples/spring-boot/src/main/java/com/example/invoice/PrintLabelWorker.java
index 7ed7f09..41c9d36 100644
--- a/examples/spring-boot/src/main/java/com/example/invoice/PrintLabelWorker.java
+++ b/examples/spring-boot/src/main/java/com/example/invoice/PrintLabelWorker.java
@@ -10,10 +10,8 @@
import org.springframework.stereotype.Component;
/**
- * Completes "print-label" external tasks from inside the same JVM as the
- * embedded engine, so the shipment-label process reaches its end event without a
- * separate worker deployment. It polls the engine's external-task service on a
- * fixed delay and completes any task locked on the topic.
+ * Completes "print-label" external tasks from inside the engine's JVM, so the
+ * fixture needs no separate worker deployment.
*/
@Component
public class PrintLabelWorker {
diff --git a/examples/spring-boot/src/main/java/com/example/invoice/ShippingBean.java b/examples/spring-boot/src/main/java/com/example/invoice/ShippingBean.java
index 9126332..678e192 100644
--- a/examples/spring-boot/src/main/java/com/example/invoice/ShippingBean.java
+++ b/examples/spring-boot/src/main/java/com/example/invoice/ShippingBean.java
@@ -5,10 +5,8 @@
import org.springframework.stereotype.Component;
/**
- * Bean referenced by the shipping-quote process via
- * {@code expression = "${shippingBean.quote(order)}"} (operaton:expression). The
- * service task evaluates the method call; the returned quote is logged (the DSL
- * does not yet bind an expression result to a process variable).
+ * The {@code ${shippingBean.quote(order)}} expression in
+ * {@code service-expression.bpmnscript} resolves to this bean by name.
*/
@Component("shippingBean")
public class ShippingBean {
diff --git a/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS1Delegate.java b/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS1Delegate.java
index a4f586e..87e08db 100644
--- a/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS1Delegate.java
+++ b/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS1Delegate.java
@@ -5,7 +5,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-/** External credit bureau S1: rates by credit score. */
public class ExternalRatingS1Delegate implements JavaDelegate {
private static final Logger LOG = LoggerFactory.getLogger(ExternalRatingS1Delegate.class);
diff --git a/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS2Delegate.java b/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS2Delegate.java
index 72a3902..3d479b4 100644
--- a/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS2Delegate.java
+++ b/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS2Delegate.java
@@ -5,7 +5,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-/** External credit bureau S2: rates by loan size. */
public class ExternalRatingS2Delegate implements JavaDelegate {
private static final Logger LOG = LoggerFactory.getLogger(ExternalRatingS2Delegate.class);
diff --git a/examples/spring-boot/src/main/java/com/example/loan/kopp/InternalRatingDelegate.java b/examples/spring-boot/src/main/java/com/example/loan/kopp/InternalRatingDelegate.java
index 68a186d..218b613 100644
--- a/examples/spring-boot/src/main/java/com/example/loan/kopp/InternalRatingDelegate.java
+++ b/examples/spring-boot/src/main/java/com/example/loan/kopp/InternalRatingDelegate.java
@@ -5,7 +5,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-/** Internal rating service: stricter credit-score cutoff than the external bureaus. */
+/** A stricter credit-score cutoff than the external bureau S1 applies. */
public class InternalRatingDelegate implements JavaDelegate {
private static final Logger LOG = LoggerFactory.getLogger(InternalRatingDelegate.class);
diff --git a/examples/spring-boot/src/main/resources/application-demo.yml b/examples/spring-boot/src/main/resources/application-demo.yml
index 8fd133c..b8d5661 100644
--- a/examples/spring-boot/src/main/resources/application-demo.yml
+++ b/examples/spring-boot/src/main/resources/application-demo.yml
@@ -1,3 +1,2 @@
-# "demo" profile: turns classpath auto-deployment on so the compiled processes deploy at startup.
operaton.bpm:
auto-deployment-enabled: true
diff --git a/examples/spring-boot/src/main/resources/application.yml b/examples/spring-boot/src/main/resources/application.yml
index 6798d2f..6f9f3f4 100644
--- a/examples/spring-boot/src/main/resources/application.yml
+++ b/examples/spring-boot/src/main/resources/application.yml
@@ -2,16 +2,13 @@ server:
port: 8080
operaton.bpm:
- # Admin user credentials used by the Operaton REST API and web apps.
admin-user:
id: demo
password: demo
firstName: Demo
- # History level: "audit" (= Operaton's HISTORY_DEFAULT constant) records task and
- # variable events needed by the E2E harness. Valid values: none | activity | audit | full | auto.
+ # The E2E assertions read task and variable history, which "audit" records.
history-level: audit
- # The testcontainers harness deploys process definitions via REST (POST /engine-rest/deployment/create).
- # Disabling classpath auto-deployment prevents duplicate deployments on startup.
+ # The harness deploys over REST; the demo profile turns this on.
auto-deployment-enabled: false
diff --git a/package-lock.json b/package-lock.json
index 3ceb2c3..bcdea8d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,7 @@
"": {
"name": "bpmn-script-workspace",
"version": "0.0.1",
+ "license": "Apache-2.0",
"workspaces": [
"packages/language",
"packages/cli",
@@ -17,12 +18,245 @@
"devDependencies": {
"@types/node": "^26.4.0",
"@typescript-eslint/parser": "^8.69.0",
+ "@vitest/coverage-v8": "^4.1.11",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-bpmn-io": "^2.3.1",
"prettier": "^3.9.6",
"shx": "~0.4.0",
"typescript": "~6.0.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/abort-controller": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz",
+ "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/core-auth": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz",
+ "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@azure/abort-controller": "^2.1.2",
+ "@azure/core-util": "^1.13.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/core-client": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz",
+ "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@azure/abort-controller": "^2.1.2",
+ "@azure/core-auth": "^1.10.0",
+ "@azure/core-rest-pipeline": "^1.22.0",
+ "@azure/core-tracing": "^1.3.0",
+ "@azure/core-util": "^1.13.0",
+ "@azure/logger": "^1.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/core-process": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz",
+ "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/core-rest-pipeline": {
+ "version": "1.25.0",
+ "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz",
+ "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@azure/abort-controller": "^2.1.2",
+ "@azure/core-auth": "^1.10.0",
+ "@azure/core-tracing": "^1.3.0",
+ "@azure/core-util": "^1.13.0",
+ "@azure/logger": "^1.3.0",
+ "@typespec/ts-http-runtime": "^0.3.4",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/core-tracing": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz",
+ "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/core-util": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz",
+ "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@azure/abort-controller": "^2.1.2",
+ "@typespec/ts-http-runtime": "^0.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/identity": {
+ "version": "4.13.3",
+ "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.3.tgz",
+ "integrity": "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-auth": "^1.9.0",
+ "@azure/core-client": "^1.9.2",
+ "@azure/core-process": "^1.0.0",
+ "@azure/core-rest-pipeline": "^1.17.0",
+ "@azure/core-tracing": "^1.0.0",
+ "@azure/core-util": "^1.11.0",
+ "@azure/logger": "^1.0.0",
+ "@azure/msal-browser": "^5.5.0",
+ "@azure/msal-node": "^6.0.0",
+ "open": "^10.1.0",
+ "tslib": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/logger": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz",
+ "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typespec/ts-http-runtime": "^0.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@azure/msal-browser": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.22.0.tgz",
+ "integrity": "sha512-5kgu9xeEKgGc2JeidxAtU15NJTqiH/CMCRRQAJ4Rac56kB7KVg91vbNmn+z3RO1vNomPN69UvjKG9h1Pghx6dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@azure/msal-common": "16.14.1"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/@azure/msal-common": {
+ "version": "16.14.1",
+ "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz",
+ "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/@azure/msal-node": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz",
+ "integrity": "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@azure/msal-common": "16.14.1",
+ "jsonwebtoken": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
}
},
"node_modules/@balena/dockerignore": {
@@ -32,6 +266,16 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@bpmn-script/cli": {
"resolved": "packages/cli",
"link": true
@@ -625,9 +869,9 @@
"license": "MIT"
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -706,9 +950,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -902,6 +1146,16 @@
"node": ">=12"
}
},
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
@@ -909,6 +1163,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
@@ -930,6 +1195,238 @@
"debug": "^4.1.1"
}
},
+ "node_modules/@napi-rs/keyring": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz",
+ "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "optionalDependencies": {
+ "@napi-rs/keyring-darwin-arm64": "1.3.0",
+ "@napi-rs/keyring-darwin-x64": "1.3.0",
+ "@napi-rs/keyring-freebsd-x64": "1.3.0",
+ "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0",
+ "@napi-rs/keyring-linux-arm64-gnu": "1.3.0",
+ "@napi-rs/keyring-linux-arm64-musl": "1.3.0",
+ "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0",
+ "@napi-rs/keyring-linux-x64-gnu": "1.3.0",
+ "@napi-rs/keyring-linux-x64-musl": "1.3.0",
+ "@napi-rs/keyring-win32-arm64-msvc": "1.3.0",
+ "@napi-rs/keyring-win32-ia32-msvc": "1.3.0",
+ "@napi-rs/keyring-win32-x64-msvc": "1.3.0"
+ }
+ },
+ "node_modules/@napi-rs/keyring-darwin-arm64": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz",
+ "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-darwin-x64": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz",
+ "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-freebsd-x64": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz",
+ "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz",
+ "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-arm64-gnu": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz",
+ "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-arm64-musl": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz",
+ "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz",
+ "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-x64-gnu": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz",
+ "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-x64-musl": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz",
+ "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-win32-arm64-msvc": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz",
+ "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-win32-ia32-msvc": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz",
+ "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-win32-x64-msvc": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz",
+ "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1184,9 +1681,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1204,9 +1698,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1224,9 +1715,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1244,9 +1732,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1264,9 +1749,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1284,9 +1766,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1354,6 +1833,76 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@secretlint/core": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz",
+ "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@secretlint/profiler": "^10.2.2",
+ "@secretlint/types": "^10.2.2",
+ "debug": "^4.4.1",
+ "structured-source": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@secretlint/profiler": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz",
+ "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@secretlint/secretlint-rule-no-dotenv": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz",
+ "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@secretlint/types": "^10.2.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@secretlint/secretlint-rule-preset-recommend": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz",
+ "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@secretlint/source-creator": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz",
+ "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@secretlint/types": "^10.2.2",
+ "istextorbinary": "^9.5.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@secretlint/types": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz",
+ "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1623,6 +2172,21 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typespec/ts-http-runtime": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
+ "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz",
@@ -1970,6 +2534,37 @@
"win32"
]
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
+ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.11",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.11",
+ "vitest": "4.1.11"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
@@ -2058,29 +2653,225 @@
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/@vitest/spy": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
- "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vscode/vsce": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-4.0.0.tgz",
+ "integrity": "sha512-NImwuLaenMmb5D5Jer9/lzi/F9ZQUBOp8Azhj/BVYcTFgixv8KehFXqEUDjQlD2tAiw2E6dDGyjTuAB//di60A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@azure/identity": "^4.13.2",
+ "@napi-rs/keyring": "^1.3.0",
+ "@secretlint/core": "^10.2.2",
+ "@secretlint/secretlint-rule-no-dotenv": "^10.2.2",
+ "@secretlint/secretlint-rule-preset-recommend": "^10.2.2",
+ "@secretlint/source-creator": "^10.2.2",
+ "@secretlint/types": "^10.2.2",
+ "@vscode/vsce-sign": "^2.1.0",
+ "azure-devops-node-api": "^12.5.0",
+ "cockatiel": "^3.2.1",
+ "commander": "^12.1.0",
+ "hosted-git-info": "^4.1.0",
+ "jsonc-parser": "^3.3.1",
+ "marked": "^18.0.11",
+ "mime": "^1.6.0",
+ "minimatch": "^10.2.6",
+ "parse5": "^8.0.1",
+ "proper-lockfile": "^4.1.2",
+ "read": "^1.0.7",
+ "semver": "^7.8.5",
+ "tinyglobby": "^0.2.17",
+ "typed-rest-client": "^1.8.11",
+ "url-join": "^4.0.1",
+ "xml2js": "^0.5.0",
+ "yauzl": "^3.4.0",
+ "yazl": "^2.5.1"
+ },
+ "bin": {
+ "vsce": "vsce"
+ },
+ "engines": {
+ "node": ">= 22"
+ }
+ },
+ "node_modules/@vscode/vsce-sign": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.1.0.tgz",
+ "integrity": "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optionalDependencies": {
+ "@vscode/vsce-sign-alpine-arm64": "2.0.6",
+ "@vscode/vsce-sign-alpine-x64": "2.0.6",
+ "@vscode/vsce-sign-darwin-arm64": "2.0.6",
+ "@vscode/vsce-sign-darwin-x64": "2.0.6",
+ "@vscode/vsce-sign-linux-arm": "2.0.6",
+ "@vscode/vsce-sign-linux-arm64": "2.0.6",
+ "@vscode/vsce-sign-linux-x64": "2.0.6",
+ "@vscode/vsce-sign-win32-arm64": "2.0.6",
+ "@vscode/vsce-sign-win32-x64": "2.0.6"
+ }
+ },
+ "node_modules/@vscode/vsce-sign-alpine-arm64": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz",
+ "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optional": true,
+ "os": [
+ "alpine"
+ ]
+ },
+ "node_modules/@vscode/vsce-sign-alpine-x64": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz",
+ "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optional": true,
+ "os": [
+ "alpine"
+ ]
+ },
+ "node_modules/@vscode/vsce-sign-darwin-arm64": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz",
+ "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@vscode/vsce-sign-darwin-x64": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz",
+ "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@vscode/vsce-sign-linux-arm": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz",
+ "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/vsce-sign-linux-arm64": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz",
+ "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/vsce-sign-linux-x64": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz",
+ "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/vsce-sign-win32-arm64": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz",
+ "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@vscode/vsce-sign-win32-x64": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz",
+ "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
},
- "node_modules/@vitest/utils": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
- "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "node_modules/@vscode/vsce/node_modules/commander": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "4.1.11",
- "convert-source-map": "^2.0.0",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/abort-controller": {
@@ -2119,6 +2910,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2381,6 +3182,25 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz",
+ "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -2421,6 +3241,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/azure-devops-node-api": {
+ "version": "12.5.0",
+ "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz",
+ "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tunnel": "0.0.6",
+ "typed-rest-client": "^1.8.4"
+ }
+ },
"node_modules/b4a": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
@@ -2574,6 +3405,22 @@
"tweetnacl": "^0.14.3"
}
},
+ "node_modules/binaryextensions": {
+ "version": "6.11.0",
+ "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz",
+ "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==",
+ "dev": true,
+ "license": "Artistic-2.0",
+ "dependencies": {
+ "editions": "^6.21.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "funding": {
+ "url": "https://bevry.me/fund"
+ }
+ },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -2626,6 +3473,13 @@
"node": ">= 6"
}
},
+ "node_modules/boundary": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz",
+ "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
"node_modules/bpmn-auto-layout": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/bpmn-auto-layout/-/bpmn-auto-layout-1.3.0.tgz",
@@ -2716,6 +3570,13 @@
"node": ">=8.0.0"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
@@ -2726,6 +3587,22 @@
"node": ">=10.0.0"
}
},
+ "node_modules/bundle-name": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+ "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "run-applescript": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/byline": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz",
@@ -2936,6 +3813,16 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/cockatiel": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz",
+ "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3312,6 +4199,36 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/default-browser": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz",
+ "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bundle-name": "^4.1.0",
+ "default-browser-id": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+ "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -3330,6 +4247,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/define-lazy-prop": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
@@ -3500,6 +4430,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/editions": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz",
+ "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==",
+ "dev": true,
+ "license": "Artistic-2.0",
+ "dependencies": {
+ "version-range": "^4.15.0"
+ },
+ "engines": {
+ "ecmascript": ">= es5",
+ "node": ">=4"
+ },
+ "funding": {
+ "url": "https://bevry.me/fund"
+ }
+ },
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -3517,6 +4474,19 @@
"once": "^1.4.0"
}
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4005,9 +4975,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4076,9 +5046,9 @@
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4759,9 +5729,9 @@
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
- "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
+ "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4928,6 +5898,67 @@
"node": ">= 0.4"
}
},
+ "node_modules/hosted-git-info": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
+ "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/hosted-git-info/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5153,6 +6184,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -5222,6 +6269,25 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -5430,6 +6496,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-wsl": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@@ -5444,6 +6526,63 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istextorbinary": {
+ "version": "9.5.0",
+ "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz",
+ "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==",
+ "dev": true,
+ "license": "Artistic-2.0",
+ "dependencies": {
+ "binaryextensions": "^6.11.0",
+ "editions": "^6.21.0",
+ "textextensions": "^6.11.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "funding": {
+ "url": "https://bevry.me/fund"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -5529,6 +6668,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
@@ -5552,6 +6698,29 @@
"node": "*"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -5568,6 +6737,29 @@
"node": ">=4.0"
}
},
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5855,9 +7047,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5879,9 +7068,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5903,9 +7089,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5927,9 +7110,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6021,6 +7201,48 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6028,6 +7250,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -6065,6 +7294,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz",
+ "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/marked": {
+ "version": "18.0.13",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz",
+ "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6099,6 +7369,19 @@
"node": ">=8.6"
}
},
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/min-dash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/min-dash/-/min-dash-5.1.0.tgz",
@@ -6195,6 +7478,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/mute-stream": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
+ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/nan": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz",
@@ -6443,6 +7733,25 @@
"wrappy": "1"
}
},
+ "node_modules/open": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
+ "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "default-browser": "^5.2.1",
+ "define-lazy-prop": "^3.0.0",
+ "is-inside-container": "^1.0.0",
+ "wsl-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6541,6 +7850,19 @@
"node": ">=6"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6592,6 +7914,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6781,6 +8110,23 @@
"node": ">=6"
}
},
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6816,6 +8162,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/read": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
+ "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "mute-stream": "~0.0.4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
@@ -6851,9 +8210,9 @@
"license": "MIT"
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
- "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
+ "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7060,6 +8419,19 @@
"@rolldown/binding-win32-x64-msvc": "1.2.6"
}
},
+ "node_modules/run-applescript": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+ "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7177,6 +8549,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/sax": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
+ "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
"node_modules/saxen": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/saxen/-/saxen-11.1.1.tgz",
@@ -7187,9 +8569,9 @@
}
},
"node_modules/semver": {
- "version": "7.8.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
- "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -7320,15 +8702,15 @@
}
},
"node_modules/side-channel": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
- "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
- "object-inspect": "^1.13.3",
- "side-channel-list": "^1.0.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -7751,6 +9133,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/structured-source": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz",
+ "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boundary": "^2.0.0"
+ }
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -7856,6 +9248,22 @@
"b4a": "^1.6.4"
}
},
+ "node_modules/textextensions": {
+ "version": "6.11.0",
+ "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz",
+ "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==",
+ "dev": true,
+ "license": "Artistic-2.0",
+ "dependencies": {
+ "editions": "^6.21.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "funding": {
+ "url": "https://bevry.me/fund"
+ }
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -7984,6 +9392,16 @@
"dev": true,
"license": "0BSD"
},
+ "node_modules/tunnel": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
+ "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.11 <=0.7.0 || >=0.7.3"
+ }
+ },
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
@@ -8082,6 +9500,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/typed-rest-client": {
+ "version": "1.8.11",
+ "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz",
+ "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "qs": "^6.9.1",
+ "tunnel": "0.0.6",
+ "underscore": "^1.12.1"
+ }
+ },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -8115,6 +9545,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/underscore": {
+ "version": "1.13.8",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
+ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/undici": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz",
@@ -8190,6 +9627,13 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/url-join": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
+ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -8197,6 +9641,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/version-range": {
+ "version": "4.15.0",
+ "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz",
+ "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==",
+ "dev": true,
+ "license": "Artistic-2.0",
+ "engines": {
+ "node": ">=4"
+ },
+ "funding": {
+ "url": "https://bevry.me/fund"
+ }
+ },
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
@@ -8718,6 +10175,46 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/wsl-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
+ "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-wsl": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/xml2js": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
+ "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -8728,6 +10225,13 @@
"node": ">=10"
}
},
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
@@ -8818,6 +10322,39 @@
"node": ">=8"
}
},
+ "node_modules/yauzl": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
+ "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yazl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz",
+ "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3"
+ }
+ },
+ "node_modules/yazl/node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -8849,6 +10386,7 @@
"packages/cli": {
"name": "@bpmn-script/cli",
"version": "0.0.1",
+ "license": "Apache-2.0",
"dependencies": {
"@bpmn-script/language": "0.0.1",
"@bpmn-script/transform": "*",
@@ -8882,6 +10420,7 @@
"packages/extension": {
"name": "vscode-bpmnscript",
"version": "0.0.1",
+ "license": "Apache-2.0",
"dependencies": {
"@bpmn-script/language": "0.0.1",
"@bpmn-script/transform": "0.0.1",
@@ -8889,17 +10428,19 @@
},
"devDependencies": {
"@types/vscode": "~1.134.0",
+ "@vscode/vsce": "^4.0.0",
"concurrently": "^10.0.5",
"esbuild": "~0.28.2",
"vitest": "^4.1.11"
},
"engines": {
- "vscode": "^1.100.0"
+ "vscode": "^1.134.0"
}
},
"packages/language": {
"name": "@bpmn-script/language",
"version": "0.0.1",
+ "license": "Apache-2.0",
"dependencies": {
"langium": "^4.3.1",
"vscode-languageserver-types": "^3.18.3"
@@ -8922,6 +10463,7 @@
"packages/transform": {
"name": "@bpmn-script/transform",
"version": "0.0.1",
+ "license": "Apache-2.0",
"dependencies": {
"@bpmn-script/language": "*",
"bpmn-auto-layout": "^1.2.0",
@@ -8938,6 +10480,7 @@
},
"tests": {
"version": "0.0.1",
+ "license": "Apache-2.0",
"devDependencies": {
"@bpmn-script/language": "0.0.1",
"@bpmn-script/transform": "0.0.1",
@@ -8947,7 +10490,8 @@
"vscode-languageserver-types": "^3.18.0"
},
"engines": {
- "node": ">=22.0.0"
+ "node": ">=22.0.0",
+ "npm": ">=10.2.3"
}
}
}
diff --git a/package.json b/package.json
index 396b912..0ea6c49 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,17 @@
{
"name": "bpmn-script-workspace",
- "description": "Base workspace package",
+ "description": "Write executable business processes as text, compile to BPMN 2.0 XML, and read existing BPMN files back as text.",
"version": "0.0.1",
+ "license": "Apache-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/BPMNscript/bpmnscript.git"
+ },
"type": "module",
"private": true,
+ "engines": {
+ "node": ">=22.0.0"
+ },
"scripts": {
"clean": "npm run clean --workspaces --if-present",
"watch": "tsc -b tsconfig.build.json --watch",
@@ -19,6 +27,7 @@
"devDependencies": {
"@types/node": "^26.4.0",
"@typescript-eslint/parser": "^8.69.0",
+ "@vitest/coverage-v8": "^4.1.11",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-bpmn-io": "^2.3.1",
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 546f99a..8721bb7 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -25,14 +25,22 @@ bpmns parse invoice-approval.bpmn -o invoice-approval.bpmnscript
```
`build` writes BPMN 2.0 XML with generated diagram interchange data.
+When the layout library cannot place the graph, `build` writes the document without diagram data instead, prints `Warning: no diagram could be drawn for this process (...); the file deploys but opens without shapes in a modeler` to stderr, and still exits `0`, since Operaton deploys such a file unchanged (`BpmnParse.parseDiagramInterchangeElements` reads a diagram only when one is present).
`parse` writes `.bpmnscript` source.
-Exit codes are `0` for success, `1` for validation or parse errors, `2` for I/O errors.
+Exit codes are `0` for success, `1` for validation errors on `build` or a refused BPMN construct on `parse`, and `2` for an I/O problem or input that cannot be read as BPMN XML.
Both directions report non-fatal warnings on stderr without changing the exit code, so an undeclared variable reference on compile, or a dropped Operaton extension attribute or lane on import, is warned about but still produces a file.
`parse` reports what the print hop could not carry into the script as well: some of those warnings bear on what a recompiled document runs, and some on whether the script builds back at all, so read them before running `build` on the output.
+After those warnings, `parse` also re-validates the script it just wrote and reports every error `build` would draw from it, still exiting `0` and still writing the file.
The roundtripping section of the root [README](../../README.md#roundtripping-bpmn-xml---bpmnscript) walks through both hops.
A BPMN construct the DSL cannot express is the exception: `parse` exits `1` with a message naming the construct and writes nothing.
+Both subcommands read a file from disk; neither one reads stdin.
+An `-o` equal to the input path always exits `2` before anything is touched, `--force` included: there is no output left to write to once it's the same file as the source.
+An existing output file at a different path is left alone unless you pass `--force`; without it, the command exits `2` instead of overwriting it.
+An `-o` that names an existing directory writes into it under the input's own basename.
+`build` refuses a file that is not `.bpmnscript` with exit `2` and a source with no process with exit `1`; `parse` refuses a file it cannot read as BPMN XML, an empty or non-XML one included, with exit `2`.
+
The package is not meant to be imported programmatically.
Import `@bpmn-script/transform` for the transforms and `@bpmn-script/language` for the Langium services.
@@ -55,8 +63,8 @@ npm test
| `src/main.ts` | Entry point; registers `build` and `parse` through `commander` |
| `src/build.ts` | `buildAction`: parse, validate, `astToIr`, `irToXml`, write `.bpmn` |
| `src/parse.ts` | `parseAction`: read `.bpmn`, `xmlToIr`, `irToDsl`, write `.bpmnscript` |
-| `src/util.ts` | `resolveOutputPath` (output-path derivation) and `CLI_VERSION` |
-| `bin/cli.js` | Shell script that calls the compiled entry point |
+| `src/util.ts` | Shared by both actions: path guards, `buildDocument`, `CLI_VERSION` |
+| `bin/cli.js` | Node entry script; imports the compiled `out/main.js` and runs it |
## Dependencies on other packages
diff --git a/packages/cli/package.json b/packages/cli/package.json
index bed9500..6f49527 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,13 @@
{
"name": "@bpmn-script/cli",
- "description": "The cli specific package",
+ "description": "Command line interface compiling BPMNscript to BPMN 2.0 XML and back",
"version": "0.0.1",
+ "license": "Apache-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/BPMNscript/bpmnscript.git",
+ "directory": "packages/cli"
+ },
"type": "module",
"engines": {
"node": ">=22.0.0",
diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts
index 225ad7a..0cb26be 100644
--- a/packages/cli/src/build.ts
+++ b/packages/cli/src/build.ts
@@ -1,136 +1,103 @@
import {
- createBpmnScriptServices,
BpmnScriptLanguageMetaData,
+ Diagnostic,
+ DiagnosticSeverity,
} from '@bpmn-script/language';
import type { Model } from '@bpmn-script/language';
-import { NodeFileSystem } from 'langium/node';
-import { URI } from 'langium';
import chalk from 'chalk';
-import * as fs from 'node:fs/promises';
-import * as fsSync from 'node:fs';
import * as path from 'node:path';
-import { astToIr, irToXml } from '@bpmn-script/transform';
-import { CLI_VERSION, diagnosticMessage, resolveOutputPath } from './util.js';
-
-export type BuildOptions = {
- output?: string;
-};
-
-const SEVERITY_ERROR = 1;
-const SEVERITY_WARNING = 2;
+import {
+ astToIr,
+ irToXml,
+ LayoutError,
+ NO_PROCESS_MESSAGE,
+} from '@bpmn-script/transform';
+import {
+ CLI_VERSION,
+ type CommandOptions,
+ buildDocument,
+ fail,
+ formatDiagnostic,
+ guardOutputPath,
+ resolveInputPath,
+ resolveOutputPath,
+ warn,
+ writeOutput,
+} from './util.js';
export async function buildAction(
fileName: string,
- opts: BuildOptions,
+ opts: CommandOptions,
): Promise {
- const resolvedInput = path.resolve(fileName);
-
- if (!fsSync.existsSync(resolvedInput)) {
- console.error(chalk.red(`Error: file not found: ${fileName}`));
- process.exit(2);
- }
+ const resolvedInput = resolveInputPath(fileName);
+ // A wrong extension fails later with an internal Langium "service registry contains no services".
const extensions: readonly string[] =
BpmnScriptLanguageMetaData.fileExtensions;
if (!extensions.includes(path.extname(resolvedInput))) {
- console.error(
- chalk.yellow(
- `Warning: expected a file with one of these extensions: ${extensions.join(', ')}`,
- ),
+ fail(
+ 2,
+ `Error: expected a file with one of these extensions: ${extensions.join(', ')}; ` +
+ 'a .bpmn file is decompiled with `bpmns parse`',
);
}
const outPath = resolveOutputPath(resolvedInput, '.bpmn', opts.output);
-
- const services = createBpmnScriptServices(NodeFileSystem).BpmnScript;
+ guardOutputPath(resolvedInput, outPath, opts);
let document;
try {
- document =
- await services.shared.workspace.LangiumDocuments.getOrCreateDocument(
- URI.file(resolvedInput),
- );
- await services.shared.workspace.DocumentBuilder.build([document], {
- validation: true,
- });
+ document = await buildDocument(resolvedInput);
} catch (err) {
- console.error(
- chalk.red(
- `Error: failed to parse ${fileName}: ${(err as Error).message}`,
- ),
- );
- process.exit(2);
+ fail(2, `Error: failed to parse ${fileName}: ${(err as Error).message}`);
}
const errors = (document.diagnostics ?? []).filter(
- (d) => d.severity === SEVERITY_ERROR,
+ (d) => d.severity === DiagnosticSeverity.Error,
);
if (errors.length > 0) {
console.error(chalk.red('Validation errors:'));
for (const diag of errors) {
- console.error(
- chalk.red(
- ` line ${diag.range.start.line + 1}: ${diagnosticMessage(diag)}` +
- ` [${document.textDocument.getText(diag.range)}]`,
- ),
- );
+ console.error(chalk.red(formatDiagnostic(document, diag)));
}
process.exit(1);
}
+ // After the error gate: a keyword typo also parses into a model with no processes.
+ const ast = document.parseResult.value as Model;
+ if (ast.processes.length === 0) fail(1, `Error: ${NO_PROCESS_MESSAGE}`);
+
const warnings = (document.diagnostics ?? []).filter(
- (d) => d.severity === SEVERITY_WARNING,
+ (d) => d.severity === DiagnosticSeverity.Warning,
);
for (const diag of warnings) {
- console.error(
- chalk.yellow(
- `Warning: line ${diag.range.start.line + 1}: ${diagnosticMessage(diag)}`,
- ),
+ warn(
+ `Warning: line ${diag.range.start.line + 1}: ${Diagnostic.getMessageString(diag)}`,
);
}
- const ast = document.parseResult?.value as Model;
-
let ir;
try {
ir = astToIr(ast);
} catch (err) {
- console.error(
- chalk.red(
- `Error: AST to IR conversion failed: ${(err as Error).message}`,
- ),
- );
- process.exit(1);
+ fail(1, `Error: ${(err as Error).message}`);
}
- let xml;
+ let xml: string;
try {
- xml = await irToXml(ir, {
- sourceFileName: path.basename(resolvedInput),
- exporterVersion: CLI_VERSION,
- });
+ xml = await irToXml(ir, { exporterVersion: CLI_VERSION });
} catch (err) {
- console.error(
- chalk.red(
- `Error: IR to XML conversion failed: ${(err as Error).message}`,
- ),
- );
- process.exit(1);
- }
-
- try {
- const outDir = path.dirname(outPath);
- await fs.mkdir(outDir, { recursive: true });
- await fs.writeFile(outPath, xml, 'utf-8');
- } catch (err) {
- console.error(
- chalk.red(
- `Error: could not write output to ${outPath}: ${(err as Error).message}`,
- ),
+ if (!(err instanceof LayoutError)) {
+ fail(1, `Error: ${(err as Error).message}`);
+ }
+ // Operaton deploys the document without a diagram.
+ xml = err.xml;
+ warn(
+ `Warning: no diagram could be drawn for this process (${err.message}); the file deploys but opens without shapes in a modeler`,
);
- process.exit(2);
}
+ await writeOutput(outPath, xml);
console.log(chalk.green(`Built: ${outPath}`));
}
diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts
index 7c760a5..7466675 100644
--- a/packages/cli/src/main.ts
+++ b/packages/cli/src/main.ts
@@ -19,6 +19,7 @@ export default function (): void {
'-o, --output ',
'output .bpmn file path (default: same dir, same basename)',
)
+ .option('--force', 'overwrite an existing output file')
.description('Compile a .bpmnscript source file to BPMN 2.0 XML')
.action(buildAction);
@@ -29,6 +30,7 @@ export default function (): void {
'-o, --output ',
'output .bpmnscript file path (default: same dir, same basename)',
)
+ .option('--force', 'overwrite an existing output file')
.description('Decompile a BPMN 2.0 XML file to .bpmnscript DSL')
.action(parseAction);
diff --git a/packages/cli/src/parse.ts b/packages/cli/src/parse.ts
index dac9b65..b01a83f 100644
--- a/packages/cli/src/parse.ts
+++ b/packages/cli/src/parse.ts
@@ -1,43 +1,53 @@
+import { DiagnosticSeverity } from '@bpmn-script/language';
import chalk from 'chalk';
import * as fs from 'node:fs/promises';
-import * as fsSync from 'node:fs';
-import * as path from 'node:path';
import {
xmlToIr,
irToDsl,
+ EMPTY_INPUT_MESSAGE,
+ readableParseError,
+ xmlInputProblem,
+ SERVICE_TASK_FORM_ATTRIBUTES,
UnsupportedConstructError,
UnsupportedServiceTaskFormError,
UnsupportedElementError,
} from '@bpmn-script/transform';
import type { ImportWarning, PrintWarning } from '@bpmn-script/transform';
-import { resolveOutputPath } from './util.js';
-
-export type ParseOptions = {
- output?: string;
-};
+import {
+ type CommandOptions,
+ buildDocument,
+ fail,
+ formatDiagnostic,
+ guardOutputPath,
+ resolveInputPath,
+ resolveOutputPath,
+ warn,
+ writeOutput,
+} from './util.js';
export async function parseAction(
fileName: string,
- opts: ParseOptions,
+ opts: CommandOptions,
): Promise {
- const resolvedInput = path.resolve(fileName);
-
- if (!fsSync.existsSync(resolvedInput)) {
- console.error(chalk.red(`Error: file not found: ${fileName}`));
- process.exit(2);
- }
-
+ const resolvedInput = resolveInputPath(fileName);
const outPath = resolveOutputPath(resolvedInput, '.bpmnscript', opts.output);
+ guardOutputPath(resolvedInput, outPath, opts);
let xml: string;
try {
xml = await fs.readFile(resolvedInput, 'utf-8');
} catch (err) {
- console.error(
- chalk.red(`Error: could not read ${fileName}: ${(err as Error).message}`),
+ fail(2, `Error: could not read ${fileName}: ${(err as Error).message}`);
+ }
+
+ const problem = xmlInputProblem(xml);
+ if (problem === EMPTY_INPUT_MESSAGE) fail(2, `Error: ${problem}`);
+ if (problem !== undefined) {
+ fail(
+ 2,
+ `Error: ${problem}; a .bpmnscript file is built with \`bpmns build\``,
);
- process.exit(2);
}
let ir;
@@ -47,43 +57,30 @@ export async function parseAction(
} catch (err) {
// Subclasses first: they all extend UnsupportedConstructError.
if (err instanceof UnsupportedServiceTaskFormError) {
- console.error(
- chalk.red(
- `Error: unsupported ${err.subject.toLowerCase()} form in ${fileName}:\n` +
- ` ${err.message}\n` +
- ' The attributes are operaton:class (or the deprecated camunda:class ' +
- 'alias), operaton:expression, operaton:delegateExpression, ' +
- 'operaton:type="external" with operaton:topic, ' +
- 'operaton:type="mail" or "shell" with their operaton:field ' +
- 'children, and operaton:decisionRef.',
- ),
+ fail(
+ 1,
+ `Error: unsupported ${err.subject.toLowerCase()} form in ${fileName}:\n` +
+ ` ${err.message}\n` +
+ ` The attributes are ${SERVICE_TASK_FORM_ATTRIBUTES}.`,
);
- process.exit(1);
}
if (err instanceof UnsupportedElementError) {
- console.error(
- chalk.red(
- `Error: unsupported BPMN element in ${fileName}:\n` +
- ` ${err.message}`,
- ),
+ fail(
+ 1,
+ `Error: unsupported BPMN element in ${fileName}:\n ${err.message}`,
);
- process.exit(1);
}
if (err instanceof UnsupportedConstructError) {
- console.error(
- chalk.red(
- `Error: unsupported BPMN construct in ${fileName}:\n` +
- ` ${err.message}`,
- ),
+ fail(
+ 1,
+ `Error: unsupported BPMN construct in ${fileName}:\n ${err.message}`,
);
- process.exit(1);
}
- console.error(
- chalk.red(
- `Error: failed to parse ${fileName}: ${(err as Error).message}`,
- ),
+ fail(
+ 2,
+ `Error: failed to parse ${fileName}: ` +
+ readableParseError((err as Error).message, xml),
);
- process.exit(2);
}
let dsl: string;
@@ -91,33 +88,29 @@ export async function parseAction(
try {
({ source: dsl, warnings: printWarnings } = irToDsl(ir));
} catch (err) {
- console.error(
- chalk.red(
- `Error: IR to DSL conversion failed: ${(err as Error).message}`,
- ),
- );
- process.exit(2);
- }
-
- try {
- const outDir = path.dirname(outPath);
- await fs.mkdir(outDir, { recursive: true });
- await fs.writeFile(outPath, dsl, 'utf-8');
- } catch (err) {
- console.error(
- chalk.red(
- `Error: could not write output to ${outPath}: ${(err as Error).message}`,
- ),
- );
- process.exit(2);
+ fail(2, `Error: ${(err as Error).message}`);
}
+ await writeOutput(outPath, dsl);
console.log(chalk.green(`Parsed: ${outPath}`));
- // The id leads: several messages describe the route on from a step without
- // naming it, and two such warnings are otherwise the same line twice. The id
- // is also a token the reader can search for in the script just written.
+ // Several messages do not name their step, so the id tells them apart.
for (const w of [...warnings, ...printWarnings]) {
- console.error(chalk.yellow(`Warning: ${w.elementId}: ${w.message}`));
+ warn(`Warning: ${w.elementId}: ${w.message}`);
+ }
+
+ // Nothing above runs the validator, so rebuilding the written script is what catches an error.
+ const rebuilt = await buildDocument(outPath);
+ const buildErrors = (rebuilt.diagnostics ?? []).filter(
+ (d) => d.severity === DiagnosticSeverity.Error,
+ );
+ if (buildErrors.length > 0) {
+ warn(
+ `Warning: the printed script draws ${buildErrors.length} error(s) ` +
+ 'when built; hand-repair is needed:',
+ );
+ for (const diag of buildErrors) {
+ warn(formatDiagnostic(rebuilt, diag));
+ }
}
}
diff --git a/packages/cli/src/util.ts b/packages/cli/src/util.ts
index 2388ad7..3d67449 100644
--- a/packages/cli/src/util.ts
+++ b/packages/cli/src/util.ts
@@ -1,41 +1,107 @@
-import { readFileSync } from 'node:fs';
+import { existsSync, readFileSync, statSync } from 'node:fs';
+import * as fs from 'node:fs/promises';
import * as path from 'node:path';
+import chalk from 'chalk';
import type { LangiumDocument } from 'langium';
+import { URI } from 'langium';
+import { NodeFileSystem } from 'langium/node';
+import { createBpmnScriptServices, Diagnostic } from '@bpmn-script/language';
-/** Read off the document so the shape is Langium's own, not a restatement. */
-type Diagnostic = NonNullable[number];
-
-/**
- * A diagnostic carries its message as plain text or as LSP markup. Everything
- * the language server raises is text, but the union has to be read either way:
- * printed straight into a template literal, a markup message reaches the author
- * as `[object Object]` instead of the diagnostic.
- */
-export function diagnosticMessage(diagnostic: Diagnostic): string {
- return typeof diagnostic.message === 'string'
- ? diagnostic.message
- : diagnostic.message.value;
-}
-
-/**
- * Resolved relative to this module, so it works from both `out/` (compiled) and
- * `src/` (vitest); both sit one level below the package root.
- */
+export function fail(code: number, message: string): never {
+ console.error(chalk.red(message));
+ process.exit(code);
+}
+
+export function warn(message: string): void {
+ console.error(chalk.yellow(message));
+}
+
+// One level below the package root in both `out/` and `src/`.
export const CLI_VERSION: string = (
JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf-8'),
) as { version: string }
).version;
+/** Without these checks the loader fails with ENOENT or a bare EISDIR. */
+export function resolveInputPath(fileName: string): string {
+ const resolved = path.resolve(fileName);
+ if (!existsSync(resolved)) fail(2, `Error: file not found: ${fileName}`);
+ if (statSync(resolved).isDirectory()) {
+ fail(2, `Error: ${fileName} is a directory`);
+ }
+ return resolved;
+}
+
export function resolveOutputPath(
resolvedInput: string,
defaultExt: string,
outputOverride?: string,
): string {
- if (outputOverride !== undefined) {
- return path.resolve(outputOverride);
- }
- const dir = path.dirname(resolvedInput);
const base = path.basename(resolvedInput, path.extname(resolvedInput));
- return path.join(dir, `${base}${defaultExt}`);
+ if (outputOverride === undefined) {
+ return path.join(path.dirname(resolvedInput), `${base}${defaultExt}`);
+ }
+ const resolved = path.resolve(outputOverride);
+ // `-o some/dir` names a place for the file.
+ return existsSync(resolved) && statSync(resolved).isDirectory()
+ ? path.join(resolved, `${base}${defaultExt}`)
+ : resolved;
+}
+
+export type CommandOptions = { output?: string; force?: boolean };
+
+export function guardOutputPath(
+ resolvedInput: string,
+ outPath: string,
+ opts: CommandOptions,
+): void {
+ if (outPath === resolvedInput) {
+ fail(2, 'Error: the input and the output are the same file');
+ }
+ if (!opts.force && existsSync(outPath)) {
+ fail(
+ 2,
+ `Error: ${outPath} exists; pass --force to overwrite it or -o for another path`,
+ );
+ }
+}
+
+export async function writeOutput(
+ outPath: string,
+ content: string,
+): Promise {
+ try {
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
+ await fs.writeFile(outPath, content, 'utf-8');
+ } catch (err) {
+ fail(
+ 2,
+ `Error: could not write output to ${outPath}: ${(err as Error).message}`,
+ );
+ }
+}
+
+export async function buildDocument(
+ filePath: string,
+): Promise {
+ const services = createBpmnScriptServices(NodeFileSystem).BpmnScript;
+ const document =
+ await services.shared.workspace.LangiumDocuments.getOrCreateDocument(
+ URI.file(filePath),
+ );
+ await services.shared.workspace.DocumentBuilder.build([document], {
+ validation: true,
+ });
+ return document;
+}
+
+export function formatDiagnostic(
+ document: LangiumDocument,
+ diagnostic: Diagnostic,
+): string {
+ return (
+ ` line ${diagnostic.range.start.line + 1}: ${Diagnostic.getMessageString(diagnostic)}` +
+ ` [${document.textDocument.getText(diagnostic.range)}]`
+ );
}
diff --git a/packages/cli/test/build-parse.smoke.test.ts b/packages/cli/test/build-parse.smoke.test.ts
index 20456d4..a4b4a95 100644
--- a/packages/cli/test/build-parse.smoke.test.ts
+++ b/packages/cli/test/build-parse.smoke.test.ts
@@ -1,12 +1,9 @@
-// What `bpmns build` and `bpmns parse` make of a file: the exit code, what
-// lands on stderr, and what is written. The decompile contract itself, and the
-// fixtures that carry it, are in `decompile-contract.e2e.test.ts`.
-
import { describe, test, expect, beforeAll } from 'vitest';
import * as fs from 'node:fs';
+import * as fsp from 'node:fs/promises';
+import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { dirname } from 'node:path';
import { EmptyFileSystem } from 'langium';
import { parseHelper } from 'langium/test';
@@ -16,15 +13,24 @@ import { xmlToIr } from '@bpmn-script/transform';
import {
expectMentions,
+ runActionAt,
runBuild,
runParse,
type Input,
} from './helpers/actions.js';
+import type { CommandOptions } from '../src/util.js';
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
+const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
-const REPO_ROOT = path.resolve(__dirname, '../../..');
+// Read here rather than imported: util.ts's export is what this pins.
+const PACKAGE_VERSION = (
+ JSON.parse(
+ fs.readFileSync(
+ path.resolve(REPO_ROOT, 'packages/cli/package.json'),
+ 'utf-8',
+ ),
+ ) as { version: string }
+).version;
const INVOICE_APPROVAL_SRC = path.resolve(
REPO_ROOT,
@@ -53,13 +59,12 @@ beforeAll(() => {
parse = parseHelper(services.BpmnScript);
});
-// Back-edge into a parallel fork (`B -> Fork`). The fork's out-edges are already
-// consumed when the back-arrival is reached, so the decompiler emits the
-// hand-repair marker instead of a `goto`. Only hand-built BPMN gets here.
+// A back-edge into a parallel fork (`B -> Fork`), which the decompiler cannot
+// phrase as a `goto`; only hand-built BPMN gets here.
const UNSTRUCTURED_FORK_BPMN = `
-
+
@@ -73,14 +78,13 @@ const UNSTRUCTURED_FORK_BPMN = `
`;
-// Two steps, each with a single conditioned outgoing flow. Both produce the
-// same dropped-condition text, so the element id is the only thing telling the
-// reader which step lost a condition, and that two did.
+// Two steps with one conditioned outgoing flow each, so both draw the same
+// dropped-condition warning.
const TWO_DROPPED_CONDITIONS_BPMN = `
-
+
@@ -97,15 +101,36 @@ const TWO_DROPPED_CONDITIONS_BPMN = `
`;
+// A number field whose default the engine's `LongFormType` cannot convert:
+// imported as written, refused by the validator.
+const DECIMAL_DEFAULT_BPMN = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
type ParseExpectation = {
/** The id of every `Warning: : ...` line, in the order printed. */
warningIds: string[];
- /** Substrings the warnings must carry; the wording is the transform's own. */
mentions?: string[];
- /** Substrings the written script must carry. */
script?: string[];
/** Set where the warnings differ in nothing but the element they name. */
sameMessage?: boolean;
+ /** The re-validation lines after the warnings, whole; a RegExp where the line number is not pinned. */
+ buildErrorLines?: (string | RegExp)[];
};
type ParseRow = readonly [
@@ -136,34 +161,56 @@ describe('bpmns parse', () => {
{
warningIds: ['CheckStock', 'ReserveGoods'],
sameMessage: true,
- // The id is worth printing because it is a token the reader can find
- // in the script they were just handed.
script: ['CheckStock', 'ReserveGoods'],
},
],
+ [
+ 'a setting the import warning names as an eventual failure is confirmed, after that warning, by re-validating the printed script',
+ { text: DECIMAL_DEFAULT_BPMN },
+ {
+ warningIds: ['Review'],
+ mentions: ["default '1.5'", 'imported as written'],
+ script: ['amount: number = 1.5'],
+ buildErrorLines: [
+ 'Warning: the printed script draws 1 error(s) when built; hand-repair is needed:',
+ /^ {2}line \d+: The default 1\.5 of number field 'amount' is not an integer; the engine converts it with Long\.valueOf every time the form renders \(LongFormType\.convertValue\) and throws on anything else\. \[.+\]$/,
+ ],
+ },
+ ],
])('%s', async (_title, input, expected) => {
const run = await runParse(input);
expect(run.exit).toBeUndefined();
expect(run.output).toBeDefined();
- const prefixes = run.stderr.map(
+ const tailLen = expected.buildErrorLines?.length ?? 0;
+ expect(run.stderr).toHaveLength(expected.warningIds.length + tailLen);
+ const splitAt = run.stderr.length - tailLen;
+ const idLines = run.stderr.slice(0, splitAt);
+ const tail = run.stderr.slice(splitAt);
+
+ const prefixes = idLines.map(
(line) => /^Warning: ([^:]+): /.exec(line)?.[1],
);
expect(prefixes).toEqual(expected.warningIds);
- expectMentions(run.stderr.join('\n'), expected.mentions ?? []);
+ tail.forEach((line, i) => {
+ const want = expected.buildErrorLines![i];
+ if (want instanceof RegExp) expect(line).toMatch(want);
+ else expect(line).toBe(want);
+ });
+
+ expectMentions(idLines.join('\n'), expected.mentions ?? []);
expectMentions(run.output ?? '', expected.script ?? []);
if (expected.sameMessage) {
- const bodies = run.stderr.map((line, i) =>
+ const bodies = idLines.map((line, i) =>
line.slice(`Warning: ${expected.warningIds[i]}: `.length),
);
expect(new Set(bodies).size).toBe(1);
expect(bodies[0]).not.toBe('');
}
- // Whatever it writes has to be a script the language accepts.
const document = await parse(run.output!);
expect(document.parseResult.parserErrors).toHaveLength(0);
});
@@ -181,6 +228,9 @@ const WARNING_ONLY_SOURCE = `process warning-only {
}
`;
+// Yields zero Process nodes, as an empty file does.
+const KEYWORD_TYPO_SOURCE = 'proces p { user A }\n';
+
// Declares `amount` as string, then compares it numerically: severity 1.
const TYPE_MISMATCH_SOURCE = `process type-mismatch {
var amount: string
@@ -194,11 +244,8 @@ const TYPE_MISMATCH_SOURCE = `process type-mismatch {
`;
type BuildExpectation = {
- /** The code it exited with, or undefined where it ran to the end. */
exit?: number;
- /** The process id the written BPMN imports back under, if one was written. */
reimportsAs?: string;
- /** How many lines reach stderr, and what they must say. */
stderrLines: number;
mentions?: string[];
};
@@ -230,6 +277,15 @@ describe('bpmns build', () => {
{ text: TYPE_MISMATCH_SOURCE },
{ exit: 1, stderrLines: 2, mentions: ['Validation errors:'] },
],
+ [
+ 'a keyword typo lists the parser error instead of the no-process message',
+ { text: KEYWORD_TYPO_SOURCE },
+ {
+ exit: 1,
+ stderrLines: 2,
+ mentions: ['Validation errors:', 'proces'],
+ },
+ ],
])('%s', async (_title, input, expected) => {
const run = await runBuild(input);
@@ -243,25 +299,288 @@ describe('bpmns build', () => {
}
expect(run.output).toBeDefined();
expect((await xmlToIr(run.output!)).ir.id).toBe(expected.reimportsAs);
+ expect(run.output!.match(/exporterVersion="[^"]*"/g)).toEqual([
+ `exporterVersion="${PACKAGE_VERSION}"`,
+ ]);
+ });
+});
+
+const VALID_DSL = 'process guard { start S end E }';
+
+const VALID_BPMN = `
+
+
+
+
+
+
+`;
+
+// Over 40 characters, so the preview's cutoff shows, and accented, which the
+// preview keeps.
+const SWAPPED_DSL =
+ 'process rechnung_prüfen { start S user Prüfung(assignee: "demo") end E }';
+
+const NOT_XML = (preview: string) =>
+ `Error: not an XML document (starts with "${preview}"); ` +
+ 'a .bpmnscript file is built with `bpmns build`';
+
+type GuardCase = {
+ inputPath: string;
+ opts: CommandOptions;
+ exit: number | undefined;
+ /** The first stderr line, where the row expects one. */
+ line?: string;
+ check?: () => void;
+};
+
+type Action = 'build' | 'parse';
+
+type GuardRow = readonly [
+ title: string,
+ action: Action,
+ make: (dir: string) => GuardCase,
+];
+
+const COMMAND_FILES = {
+ build: { inExt: '.bpmnscript', outExt: '.bpmn', source: VALID_DSL },
+ parse: { inExt: '.bpmn', outExt: '.bpmnscript', source: VALID_BPMN },
+};
+
+const WRITTEN: Record = {
+ build: '
+ (dir: string): GuardCase => {
+ const { inputPath, outputPath } = writeInput(dir, action, true);
+ return {
+ inputPath,
+ opts: {},
+ exit: 2,
+ line: `Error: ${outputPath} exists; pass --force to overwrite it or -o for another path`,
+ check: () =>
+ expect(fs.readFileSync(outputPath, 'utf-8')).toBe('PRE-EXISTING'),
+ };
+ };
+
+/** One input file written fresh and run with no options. */
+const refusedFile = (
+ title: string,
+ action: Action,
+ name: string,
+ content: string | Buffer,
+ exit: number,
+ line: string | ((inputPath: string) => string),
+): GuardRow => [
+ title,
+ action,
+ (dir) => {
+ const inputPath = path.join(dir, name);
+ fs.writeFileSync(inputPath, content);
+ return {
+ inputPath,
+ opts: {},
+ exit,
+ line: typeof line === 'string' ? line : line(inputPath),
+ };
+ },
+];
+
+const BINARY = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00]);
+
+// `saxen` echoes the whole unparsed remainder, the NUL included, into one
+// line, then appends `\n\tline: ...`, which the cutoff must never reach.
+const UNCLOSED_TAG = ' {
+ test.each([
+ [
+ 'build: an existing output is refused without --force, and is left untouched',
+ 'build',
+ refusesExisting('build'),
+ ],
+ [
+ 'parse: an existing output is refused without --force, and is left untouched',
+ 'parse',
+ refusesExisting('parse'),
+ ],
+ [
+ 'build: --force overwrites an existing output',
+ 'build',
+ (dir) => {
+ const { inputPath, outputPath } = writeInput(dir, 'build', true);
+ return {
+ inputPath,
+ opts: { force: true },
+ exit: undefined,
+ check: () =>
+ expect(fs.readFileSync(outputPath, 'utf-8')).toContain(
+ WRITTEN.build,
+ ),
+ };
+ },
+ ],
+ [
+ 'build: an -o equal to the input path is refused, and the source is left untouched',
+ 'build',
+ (dir) => {
+ const { inputPath, source } = writeInput(dir, 'build');
+ return {
+ inputPath,
+ opts: { output: inputPath },
+ exit: 2,
+ line: 'Error: the input and the output are the same file',
+ check: () => expect(fs.readFileSync(inputPath, 'utf-8')).toBe(source),
+ };
+ },
+ ],
+ [
+ 'build: a directory input is refused',
+ 'build',
+ (dir) => ({
+ inputPath: dir,
+ opts: {},
+ exit: 2,
+ line: `Error: ${dir} is a directory`,
+ }),
+ ],
+ [
+ 'build: an -o naming a directory writes inside it under the default basename',
+ 'build',
+ (dir) => {
+ const { inputPath } = writeInput(dir, 'build');
+ const outDir = path.join(dir, 'out');
+ fs.mkdirSync(outDir);
+ return {
+ inputPath,
+ opts: { output: outDir },
+ exit: undefined,
+ check: () =>
+ expect(
+ fs.readFileSync(path.join(outDir, 'order.bpmn'), 'utf-8'),
+ ).toContain(WRITTEN.build),
+ };
+ },
+ ],
+ refusedFile(
+ 'build: a wrong extension is refused outright, not warned about then crashed on',
+ 'build',
+ 'order.txt',
+ VALID_DSL,
+ 2,
+ 'Error: expected a file with one of these extensions: .bpmnscript; ' +
+ 'a .bpmn file is decompiled with `bpmns parse`',
+ ),
+ refusedFile(
+ 'build: an empty source is reported plainly, without a pipeline stage name',
+ 'build',
+ 'empty.bpmnscript',
+ ' \n \n',
+ 1,
+ 'Error: the file has no process',
+ ),
+ refusedFile(
+ 'build: a comment-only source is reported the same way, not as an astToIr internal error',
+ 'build',
+ 'comment-only.bpmnscript',
+ '// nothing but a comment here\n',
+ 1,
+ 'Error: the file has no process',
+ ),
+ refusedFile(
+ 'parse: an empty file is reported plainly, not as an XML parse failure',
+ 'parse',
+ 'empty.bpmn',
+ '',
+ 2,
+ 'Error: the file is empty',
+ ),
+ refusedFile(
+ 'parse: binary input is reported, not dumped onto stderr',
+ 'parse',
+ 'binary.bpmn',
+ BINARY,
+ 2,
+ // 0xff/0xfe decode to U+FFFD, which is not a control character.
+ NOT_XML(BINARY.toString('utf-8').replace(/\p{C}/gu, '')),
+ ),
+ refusedFile(
+ 'parse: DSL text given by mistake is reported by a preview that keeps its accented characters, not echoed back whole',
+ 'parse',
+ 'swapped.bpmn',
+ SWAPPED_DSL,
+ 2,
+ NOT_XML(SWAPPED_DSL.slice(0, 40)),
+ ),
+ refusedFile(
+ 'parse: a non-BPMN root names the tag and namespace it expected',
+ 'parse',
+ 'not-definitions.bpmn',
+ 'hi',
+ 2,
+ (inputPath) =>
+ `Error: failed to parse ${inputPath}: failed to parse document as ` +
+ '(root element is ; expected in namespace ' +
+ 'http://www.omg.org/spec/BPMN/20100524/MODEL)',
+ ),
+ refusedFile(
+ 'parse: a wrong bpmn namespace names the namespace it found',
+ 'parse',
+ 'wrong-ns.bpmn',
+ '' +
+ '',
+ 2,
+ (inputPath) =>
+ `Error: failed to parse ${inputPath}: failed to parse document as ` +
+ '(root element is ; expected in namespace ' +
+ 'http://www.omg.org/spec/BPMN/20100524/MODEL; found xmlns:bpmn=http://example.com/wrong)',
+ ),
+ refusedFile(
+ 'parse: an unclosed tag with no closing bracket anywhere is cut to its first line, capped at 200 characters, with any control byte stripped',
+ 'parse',
+ 'unclosed.bpmn',
+ UNCLOSED_TAG,
+ 2,
+ (inputPath) =>
+ `Error: failed to parse ${inputPath}: ` +
+ (
+ `unparsable content ${UNCLOSED_TAG} detected`.slice(0, 200) + '...'
+ ).replace(/\p{C}/gu, ''),
+ ),
+ ])('%s', async (_title, action, make) => {
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bpmns-guard-'));
+ try {
+ const { inputPath, opts, exit, line, check } = make(dir);
+ const run = await runActionAt(action, inputPath, opts);
+ expect(run.exit).toBe(exit);
+ if (line !== undefined) expect(run.stderr[0]).toBe(line);
+ check?.();
+ } finally {
+ await fsp.rm(dir, { recursive: true, force: true });
+ }
});
});
describe('tmLanguage extension sync', () => {
- test('the extension ships the current grammar, copied by a build step', () => {
+ test('the extension ships the current grammar', () => {
expect(fs.readFileSync(EXTENSION_TMLANGUAGE, 'utf-8')).toBe(
fs.readFileSync(LANGUAGE_TMLANGUAGE, 'utf-8'),
);
-
- const pkg = JSON.parse(
- fs.readFileSync(
- path.resolve(REPO_ROOT, 'packages/extension/package.json'),
- 'utf-8',
- ),
- ) as { scripts?: Record };
-
- const prepare = pkg.scripts?.['build:prepare'] ?? '';
- expect(prepare).toContain('language/syntaxes');
- expect(prepare).toContain('syntaxes');
- expect(prepare, 'build:prepare must perform a file copy').toMatch(/\bcp\b/);
});
});
diff --git a/packages/cli/test/build.layout-guard.test.ts b/packages/cli/test/build.layout-guard.test.ts
new file mode 100644
index 0000000..37815a2
--- /dev/null
+++ b/packages/cli/test/build.layout-guard.test.ts
@@ -0,0 +1,25 @@
+// Its own file because `vi.mock` applies to the whole file.
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('bpmn-auto-layout', () => ({
+ layoutProcess: vi.fn(() => {
+ throw new TypeError('Cannot set properties of undefined');
+ }),
+}));
+
+import { runBuild } from './helpers/actions.js';
+
+describe('bpmns build: layoutProcess guard', () => {
+ it('falls back to writing the DI-less document with a warning instead of failing the build', async () => {
+ const run = await runBuild({ text: 'process guard { start S end E }' });
+
+ expect(run.exit).toBeUndefined();
+ expect(run.stderr).toEqual([
+ 'Warning: no diagram could be drawn for this process (bpmn-auto-layout ' +
+ 'failed to lay out the process: Cannot set properties of undefined); ' +
+ 'the file deploys but opens without shapes in a modeler',
+ ]);
+ expect(run.output).toBeDefined();
+ expect(run.output).not.toContain('bpmndi:');
+ });
+});
diff --git a/packages/cli/test/decompile-contract.e2e.test.ts b/packages/cli/test/decompile-contract.e2e.test.ts
index 222942e..4581332 100644
--- a/packages/cli/test/decompile-contract.e2e.test.ts
+++ b/packages/cli/test/decompile-contract.e2e.test.ts
@@ -1,32 +1,25 @@
-// The decompile contract on the two fixtures that carry it: what the import
-// keeps, what it warns about, what it refuses, and that the script it hands
-// back compiles again. `build-parse.smoke.test.ts` covers the commands
-// themselves.
-
-import { describe, it, test, expect, beforeAll } from 'vitest';
+import { describe, it, expect, beforeAll } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { dirname } from 'node:path';
import { EmptyFileSystem } from 'langium';
-import { parseHelper, validationHelper } from 'langium/test';
-import { createBpmnScriptServices } from '@bpmn-script/language';
-import type { Model } from '@bpmn-script/language';
+import { validationHelper } from 'langium/test';
+import {
+ createBpmnScriptServices,
+ Diagnostic,
+ intoBranchMessage,
+ type Model,
+} from '@bpmn-script/language';
import {
xmlToIr,
irToDsl,
- UnsupportedConstructError,
UnsupportedEventFeatureError,
} from '@bpmn-script/transform';
-import { diagnosticMessage } from '../src/util.js';
import { runBuild, runParse, expectMentions } from './helpers/actions.js';
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
-const REPO_ROOT = path.resolve(__dirname, '../../..');
+const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
const LANES_AND_ASYNC_BPMN = path.resolve(
REPO_ROOT,
@@ -38,22 +31,22 @@ const ERROR_START_BPMN = path.resolve(
'tests/fixtures/error-start.bpmn',
);
-// `StartEvent_1`/`EndEvent_1` are what a modeler mints for a start and an end
-// drawn without a name of their own, and the shape the desugarer reserves for
-// the ids it generates, so a script cannot spell either one back.
-const GENERATED_ID_LABELS_BPMN = `
+const labeledTerminalsBpmn = (
+ startId: string,
+ endId: string,
+): string => `
-
+
-
-
-
+
+
+
`;
-// BPMN vocabulary the DSL author never sees (ADR-0013).
+// BPMN vocabulary the DSL author never sees.
const FORBIDDEN_JARGON = ['flow node', 'gateway', 'token', 'sequence flow'];
function assertNoForbiddenJargon(text: string): void {
@@ -66,21 +59,18 @@ function assertNoForbiddenJargon(text: string): void {
}
}
-let parse: ReturnType>;
let validate: ReturnType>;
beforeAll(() => {
const services = createBpmnScriptServices(EmptyFileSystem);
- parse = parseHelper(services.BpmnScript);
validate = validationHelper(services.BpmnScript);
});
-describe('decompile contract: what the import makes of a fixture', () => {
- it('the lanes-and-async fixture imports into the supported subset, keeping the engine settings the IR carries and warning once per dropped item', async () => {
- const xml = fs.readFileSync(LANES_AND_ASYNC_BPMN, 'utf-8');
- const { ir, warnings } = await xmlToIr(xml);
-
- expect(ir.id).toBe('lanes-and-async');
+describe('decompile contract on the fixtures', () => {
+ it('the lanes-and-async fixture imports into the supported subset, warns once per dropped item, and its script builds and re-imports cleanly', async () => {
+ const { ir, warnings } = await xmlToIr(
+ fs.readFileSync(LANES_AND_ASYNC_BPMN, 'utf-8'),
+ );
expect(ir.flowElements.map((fe) => fe.kind)).toEqual([
'startEvent',
'userTask',
@@ -89,143 +79,113 @@ describe('decompile contract: what the import makes of a fixture', () => {
const task = ir.flowElements.find((fe) => fe.kind === 'userTask');
expect(task?.kind === 'userTask' && task.assignee).toBe('demo');
expect(task?.kind === 'userTask' && task.asyncBefore).toBe(true);
-
// operaton:properties is a declared type, so moddle ties the drop to the
// task that carries it rather than to the process.
expect(warnings.map((w) => [w.elementId, w.category])).toEqual([
['Lane_Ops', 'lane'],
['ReviewRequest', 'extensionAttribute'],
]);
- expectMentions(warnings.map((w) => w.message).join('\n'), [
- 'operaton:Properties',
- ]);
- });
- it('the error-start fixture is refused with UnsupportedEventFeatureError naming the offending start event, with no BPMN jargon', async () => {
- const xml = fs.readFileSync(ERROR_START_BPMN, 'utf-8');
+ const parsed = await runParse({ file: LANES_AND_ASYNC_BPMN });
+ expect(parsed.exit).toBeUndefined();
+ expect(
+ parsed.stderr.map((line) => /^Warning: ([^:]+): /.exec(line)?.[1]),
+ ).toEqual(['Lane_Ops', 'ReviewRequest']);
+ expectMentions(parsed.stderr.join('\n'), ['operaton:properties']);
+ const dsl = parsed.output!;
+ expectMentions(dsl, [
+ 'process lanes-and-async',
+ 'start ReviewStart',
+ 'user ReviewRequest',
+ 'assignee: "demo"',
+ 'end ReviewDone',
+ ]);
- await expect(xmlToIr(xml)).rejects.toBeInstanceOf(
- UnsupportedEventFeatureError,
- );
+ const { document, diagnostics } = await validate(dsl);
+ expect(document.parseResult.parserErrors).toHaveLength(0);
+ expect(diagnostics).toHaveLength(0);
- try {
- await xmlToIr(xml);
- expect.fail('Should have thrown');
- } catch (err) {
- expect(err).toBeInstanceOf(UnsupportedConstructError);
- const e = err as UnsupportedEventFeatureError;
- expect(e.elementId).toBe('ShipmentFailed');
- expect(e.message).toContain('ShipmentFailed');
- expect(e.message).toContain("Catch it with 'on error'");
- assertNoForbiddenJargon(e.message);
- }
+ const built = await runBuild({ text: dsl });
+ expect(built.exit).toBeUndefined();
+ expect(built.stderr).toEqual([]);
+ expect((await xmlToIr(built.output!)).ir.id).toBe('lanes-and-async');
});
-});
-
-type ParseExpectation =
- | {
- /** Ran to the end: the ids of the warning lines, in the order printed. */
- exit?: undefined;
- warningIds: string[];
- mentions: string[];
- script: string[];
- }
- | { exit: number; mentions: string[] };
-type ParseRow = readonly [
- title: string,
- fixture: string,
- expected: ParseExpectation,
-];
+ it('the error-start fixture is refused, by the import and by `bpmns parse` with exit 1 and nothing written, naming the start event without BPMN jargon', async () => {
+ const err: unknown = await xmlToIr(
+ fs.readFileSync(ERROR_START_BPMN, 'utf-8'),
+ ).catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(UnsupportedEventFeatureError);
+ expect((err as UnsupportedEventFeatureError).elementId).toBe(
+ 'ShipmentFailed',
+ );
-describe('decompile contract: what `bpmns parse` does with the same fixtures', () => {
- test.each([
- [
- 'a dropped lane and a dropped engine attribute are printed as warnings, and the script is written anyway',
- LANES_AND_ASYNC_BPMN,
- {
- warningIds: ['Lane_Ops', 'ReviewRequest'],
- mentions: ['operaton:Properties'],
- script: [
- 'process lanes-and-async',
- 'start ReviewStart',
- 'user ReviewRequest',
- 'assignee: "demo"',
- 'end ReviewDone',
- ],
- },
- ],
- [
- 'a refused construct exits 1, writes nothing, and says which element it was',
- ERROR_START_BPMN,
- // 1 means unsupported construct; 2 would mean I/O or generic failure.
- { exit: 1, mentions: ['ShipmentFailed', 'on error'] },
- ],
- ])('%s', async (_title, fixture, expected) => {
- const run = await runParse({ file: fixture });
+ // 1 means unsupported construct; 2 would mean I/O or generic failure.
+ const run = await runParse({ file: ERROR_START_BPMN });
+ expect(run.exit).toBe(1);
+ expect(run.output).toBeUndefined();
const stderr = run.stderr.join('\n');
-
- expect(run.exit).toBe(expected.exit);
- expectMentions(stderr, expected.mentions);
-
- if (expected.exit !== undefined) {
- // A refusal is the one message the author has to act on, so it stays in
- // the vocabulary the DSL uses.
- assertNoForbiddenJargon(stderr);
- expect(run.output).toBeUndefined();
- return;
- }
-
- expect(
- run.stderr.map((line) => /^Warning: ([^:]+): /.exec(line)?.[1]),
- ).toEqual(expected.warningIds);
- expectMentions(run.output ?? '', expected.script);
+ expectMentions(stderr, ['ShipmentFailed', "Catch it with 'on error'"]);
+ assertNoForbiddenJargon(stderr);
});
});
describe('decompile contract: the script it hands back goes through the pipeline again', () => {
- it('the DSL produced from the lanes-and-async fixture re-parses with zero parser errors and zero validation diagnostics', async () => {
- const xml = fs.readFileSync(LANES_AND_ASYNC_BPMN, 'utf-8');
- const { ir } = await xmlToIr(xml);
- const dsl = irToDsl(ir).source;
-
- const document = await parse(dsl, { validation: true });
- expect(document.parseResult.parserErrors).toHaveLength(0);
-
- const { diagnostics } = await validate(dsl);
- expect(diagnostics).toHaveLength(0);
- });
-
- it('the DSL produced from a diagram whose labeled start and end carry generated-shaped ids re-parses with zero diagnostics, and each dropped label is warned about', async () => {
- const { ir, warnings } = await xmlToIr(GENERATED_ID_LABELS_BPMN);
- const dsl = irToDsl(ir).source;
-
- const document = await parse(dsl, { validation: true });
- expect(document.parseResult.parserErrors).toHaveLength(0);
-
- const { diagnostics } = await validate(dsl);
- expect(diagnostics).toHaveLength(0);
-
- const labelWarnings = warnings.filter((w) => w.category === 'label');
- expect(labelWarnings.map((w) => w.elementId)).toEqual([
+ // Only the ids the compiler generates are left out; a modeller's defaults
+ // are names like any other. A generated-id start drops its label with an
+ // import warning, a generated-id end prints and is refused on the name.
+ it.each([
+ [
+ "a modelling tool's default ids keep their statements and labels",
'StartEvent_1',
'EndEvent_1',
- ]);
- expect(labelWarnings[0]?.message).toContain('Order received');
- expect(labelWarnings[1]?.message).toContain('Order filed');
- for (const w of labelWarnings) assertNoForbiddenJargon(w.message);
- });
-
- it('the DSL produced from the lanes-and-async fixture builds again without validation errors, and the rebuilt BPMN re-imports cleanly', async () => {
- const xml = fs.readFileSync(LANES_AND_ASYNC_BPMN, 'utf-8');
- const { ir } = await xmlToIr(xml);
-
- const run = await runBuild({ text: irToDsl(ir).source });
-
- expect(run.exit).toBeUndefined();
- expect(run.stderr).toEqual([]);
- expect((await xmlToIr(run.output!)).ir.id).toBe('lanes-and-async');
- });
+ [
+ 'start StartEvent_1(label: "Order received")',
+ 'end EndEvent_1(label: "Order filed")',
+ ],
+ [],
+ 0,
+ ],
+ [
+ 'the start id this tool generates for the process drops its statement and label, but a like-named end keeps both and is refused on the name alone',
+ 'StartEvent_generated-id-labels',
+ 'EndEvent_generated-id-labels',
+ ['end EndEvent_generated-id-labels(label: "Order filed")'],
+ ['StartEvent_generated-id-labels'],
+ 1,
+ ],
+ ])(
+ 'the DSL produced from a diagram where %s',
+ async (
+ _title,
+ startId,
+ endId,
+ printedLines,
+ warnedIds,
+ diagnosticCount,
+ ) => {
+ const { ir, warnings } = await xmlToIr(
+ labeledTerminalsBpmn(startId, endId),
+ );
+ const dsl = irToDsl(ir).source;
+
+ const { document, diagnostics } = await validate(dsl);
+ expect(document.parseResult.parserErrors).toHaveLength(0);
+ expect(diagnostics).toHaveLength(diagnosticCount);
+ expect(
+ dsl.split('\n').filter((line) => /^\s+(start|end) /.test(line)),
+ ).toEqual(printedLines.map((line) => ` ${line}`));
+
+ const labelWarnings = warnings.filter((w) => w.category === 'label');
+ expect(labelWarnings.map((w) => w.elementId)).toEqual(warnedIds);
+ for (const w of labelWarnings) {
+ expect(w.message).toContain(
+ w.elementId === startId ? 'Order received' : 'Order filed',
+ );
+ assertNoForbiddenJargon(w.message);
+ }
+ },
+ );
});
describe('decompile contract: language integrity', () => {
@@ -247,22 +207,17 @@ process Second {
const { document, diagnostics } = await validate(source);
expect(document.parseResult.parserErrors).toHaveLength(0);
- expect(diagnostics).toHaveLength(2);
for (const d of diagnostics) {
expect(d.severity).toBe(1);
- assertNoForbiddenJargon(diagnosticMessage(d));
+ assertNoForbiddenJargon(Diagnostic.getMessageString(d));
}
-
- const extraProcess = diagnostics.find((d) =>
- diagnosticMessage(d).includes('Only one process is supported'),
- );
- expect(extraProcess).toBeDefined();
-
- const gotoIntoParallel = diagnostics.find((d) =>
- diagnosticMessage(d).toLowerCase().includes('branch'),
- );
- expect(gotoIntoParallel && diagnosticMessage(gotoIntoParallel)).toContain(
- 'A',
+ expect(
+ diagnostics.map((d) => Diagnostic.getMessageString(d)).sort(),
+ ).toEqual(
+ [
+ 'Only one process is supported per file. Move additional processes into separate files.',
+ intoBranchMessage('goto A', 'goto', 'parallel'),
+ ].sort(),
);
});
});
diff --git a/packages/cli/test/helpers/actions.ts b/packages/cli/test/helpers/actions.ts
index 94a8988..51ef165 100644
--- a/packages/cli/test/helpers/actions.ts
+++ b/packages/cli/test/helpers/actions.ts
@@ -1,7 +1,3 @@
-// Runs a command the way `bpmns` does, minus the shell: a real file in and a
-// real file out, with the exit code, the stderr lines and the written output
-// handed back instead of leaving the process.
-
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as os from 'node:os';
@@ -11,9 +7,9 @@ import { expect, vi } from 'vitest';
import { buildAction } from '../../src/build.js';
import { parseAction } from '../../src/parse.js';
+import type { CommandOptions } from '../../src/util.js';
-// Captured stderr is compared line for line, so the colouring must not vary
-// with whether the runner happens to own a terminal.
+// Stderr is compared line for line, so colour must not depend on a TTY.
chalk.level = 0;
class ExitCalled extends Error {
@@ -26,20 +22,44 @@ class ExitCalled extends Error {
export type ActionRun = {
/** The code the action exited with, or undefined where it ran to the end. */
exit: number | undefined;
- /** Every line the action wrote to stderr, in order. */
stderr: string[];
- /** The written file's content, or undefined where nothing was written. */
output: string | undefined;
};
-/** Either a source written into the temp dir, or a file already on disk. */
export type Input = { text: string } | { file: string };
type Action = (
fileName: string,
- opts: { output?: string },
+ opts: CommandOptions,
) => Promise;
+async function capture(
+ invoke: () => Promise,
+): Promise> {
+ const stderr: string[] = [];
+ const spies = [
+ vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
+ stderr.push(String(args[0]));
+ }),
+ vi.spyOn(console, 'log').mockImplementation(() => {}),
+ // A no-op mock would let the action run on past the exit.
+ vi.spyOn(process, 'exit').mockImplementation((code?: unknown) => {
+ throw new ExitCalled(typeof code === 'number' ? code : 0);
+ }),
+ ];
+
+ let exit: number | undefined;
+ try {
+ await invoke();
+ } catch (err) {
+ if (!(err instanceof ExitCalled)) throw err;
+ exit = err.code;
+ } finally {
+ for (const spy of spies) spy.mockRestore();
+ }
+ return { exit, stderr };
+}
+
async function run(
action: Action,
inputExt: string,
@@ -55,28 +75,9 @@ async function run(
}
const outputPath = path.join(dir, `output${outputExt}`);
- const stderr: string[] = [];
- const spies = [
- vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
- stderr.push(String(args[0]));
- }),
- vi.spyOn(console, 'log').mockImplementation(() => {}),
- // Throws so the action stops where process.exit() would have. A no-op
- // mock would let it fall through and keep running.
- vi.spyOn(process, 'exit').mockImplementation((code?: unknown) => {
- throw new ExitCalled(typeof code === 'number' ? code : 0);
- }),
- ];
-
- let exit: number | undefined;
- try {
- await action(inputPath, { output: outputPath });
- } catch (err) {
- if (!(err instanceof ExitCalled)) throw err;
- exit = err.code;
- } finally {
- for (const spy of spies) spy.mockRestore();
- }
+ const { exit, stderr } = await capture(() =>
+ action(inputPath, { output: outputPath }),
+ );
return {
exit,
@@ -90,15 +91,22 @@ async function run(
}
}
-/** Compiles a `.bpmnscript` source to BPMN, as `bpmns build` does. */
+/** Runs on caller-owned paths: nothing is invented or cleaned up. */
+export const runActionAt = (
+ action: 'build' | 'parse',
+ inputPath: string,
+ opts: CommandOptions,
+): Promise> =>
+ capture(() =>
+ (action === 'build' ? buildAction : parseAction)(inputPath, opts),
+ );
+
export const runBuild = (input: Input): Promise =>
run(buildAction, '.bpmnscript', '.bpmn', input);
-/** Decompiles a `.bpmn` file to a script, as `bpmns parse` does. */
export const runParse = (input: Input): Promise =>
run(parseAction, '.bpmn', '.bpmnscript', input);
-/** Every substring must appear in `text`, so a line that stops saying one fails. */
export function expectMentions(
text: string,
mentions: readonly string[],
diff --git a/packages/cli/test/util.test.ts b/packages/cli/test/util.test.ts
deleted file mode 100644
index 509413d..0000000
--- a/packages/cli/test/util.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as path from 'node:path';
-import {
- diagnosticMessage,
- resolveOutputPath,
- CLI_VERSION,
-} from '../src/util.js';
-
-describe('resolveOutputPath', () => {
- it.each([
- // No override: the default extension replaces the input's own.
- [
- '/work/invoice-approval.bpmnscript',
- undefined,
- '/work/invoice-approval.bpmn',
- ],
- // Only the final extension goes, so a dotted basename survives.
- ['/work/my.invoice.bpmnscript', undefined, '/work/my.invoice.bpmn'],
- // An override is taken verbatim, resolved from cwd.
- ['/work/invoice-approval.bpmnscript', 'out/custom.bpmn', 'out/custom.bpmn'],
- ] as const)('%s + %s -> %s', (input, override, expected) => {
- expect(resolveOutputPath(path.resolve(input), '.bpmn', override)).toBe(
- path.resolve(expected),
- );
- });
-});
-
-describe('diagnosticMessage', () => {
- const RANGE = {
- start: { line: 0, character: 0 },
- end: { line: 0, character: 4 },
- };
-
- it.each([
- ['plain text', 'no such step'],
- ['markup', { kind: 'markdown', value: 'no such step' }],
- ] as const)('reads a %s message as its text', (_kind, message) => {
- expect(diagnosticMessage({ range: RANGE, message })).toBe('no such step');
- });
-});
-
-describe('CLI_VERSION', () => {
- it('is a non-empty string read from package.json', () => {
- expect(typeof CLI_VERSION).toBe('string');
- expect(CLI_VERSION.length).toBeGreaterThan(0);
- });
-});
diff --git a/packages/extension/.vscodeignore b/packages/extension/.vscodeignore
index e4feff1..0b8c790 100644
--- a/packages/extension/.vscodeignore
+++ b/packages/extension/.vscodeignore
@@ -2,6 +2,7 @@
.vscode-test/**
src/**
test/**
+injection/**
**/*.ts
**/*.map
**/*.tsbuildinfo
diff --git a/packages/extension/README.md b/packages/extension/README.md
index 9fd86a3..aba0fe4 100644
--- a/packages/extension/README.md
+++ b/packages/extension/README.md
@@ -1,27 +1,30 @@
# vscode-bpmnscript
The VS Code extension for BPMNscript.
-It runs the Langium language server for highlighting, autocompletion, hover, and inline diagnostics, and it converts the open file between `.bpmnscript` and `.bpmn` from a sidebar panel or the command palette.
+It runs the Langium language server for highlighting, completion, hover, go to definition, references, rename, the outline and inline diagnostics, and it converts the open file between `.bpmnscript` and `.bpmn` from a sidebar panel or the command palette.
The language intelligence itself lives in `@bpmn-script/language`; this package loads it in VS Code.
-That wiring is mostly Langium scaffold, which is the point of choosing Langium in the first place ([ADR-0002](../../docs/decisions/0002-use-langium-as-language-workbench.md)).
+That wiring is mostly Langium scaffold, which is the point of choosing Langium in the first place ([ADR-0003](https://github.com/BPMNscript/bpmnscript/blob/main/docs/decisions/0003-use-langium-as-language-workbench.md)).
The conversion layer on top is project-specific: three commands (`bpmnscript.compile`, `bpmnscript.decompile`, and `bpmnscript.openAndDecompile`, which picks a BPMN file from disk and decompiles it) and a sidebar webview that drives them for the active file.
## How it fits together
-Three parts bundle into `out/extension/main.cjs`.
+Three parts, two bundles: the client and the conversion layer in `out/extension/main.cjs`, the language server in `out/language/main.cjs`.
The extension client (`src/extension/main.ts`) runs inside VS Code.
Opening a `.bpmnscript` file starts the language server and connects to it; the client also registers the conversion commands, wires up the sidebar webview provider, and listens for editor changes to keep the sidebar in sync.
The language server (`src/language/main.ts`) runs in a background process.
It imports `createBpmnScriptServices` from `@bpmn-script/language` and answers the editor's LSP requests.
+Hover shows a step's kind and label, a code's kind and message, a variable's type, and a `/** ... */` comment written before the statement.
+Completion offers only what the validator accepts at that position, never a fixed keyword list.
+Rename edits every site of a step, a code or a variable, or refuses when the caret isn't on one.
The conversion layer splits along the VS Code boundary.
`conversion-core.ts` is a pure, `vscode`-free module driving the `compileDslToBpmn` and `decompileBpmnToDsl` pipelines, and it holds the decisions: severity gating (warnings don't block a compile), error classification, unsupported-element handling.
A successful decompile also returns `warnings`, what `xmlToIr` reported on the way in and `irToDsl` on the way out, in one list.
Because nothing here touches `vscode`, it's testable under vitest with no editor host.
-`conversion.ts` is the adapter around it: it resolves the source URI (an argument, otherwise the active editor), reads the text (preferring an unsaved in-memory document), calls the core, maps validation errors into the Problems panel through a `DiagnosticCollection`, asks before overwriting an existing output file, writes the result next to the source with the extension swapped, and opens it.
+`conversion.ts` is the adapter around it: it resolves the source URI (an argument, otherwise the active editor), refuses a file whose extension is not the one the command takes and names the command that is, reads the text (preferring an unsaved in-memory document), calls the core, opens the Problems panel on a validation failure (the language server already publishes the same diagnostics), asks before overwriting an existing output file, writes the result next to the source with the extension swapped, and opens it.
A BPMN construct the transform refuses surfaces as an error notification and no file is written; any `warnings` surface as one aggregated notification listing what the two hops reported.
`sidebar-view-provider.ts` implements `WebviewViewProvider` for the "Convert" view in the "BPMNscript" activity-bar container.
@@ -32,6 +35,7 @@ Conversions open their output, so moving between the two files is one click.
## Syntax highlighting
The base TextMate grammar is generated from the `language` package's `.langium` grammar at build time and copied in, so it always tracks the real grammar.
+`packages/language/textmate-postprocess.mjs` adjusts it right after generation, so a hyphenated name keeps its color instead of losing it at the first `-`, and so numbers and operators get a scope the generator leaves out.
A second, hand-maintained TextMate injection grammar (`injection/bpmn-script.injection.tmLanguage.json`) colors the body of a `script` task the way a markdown fenced code block works: the opening language tag selects the embedded scope (`source.js`, `source.python`, `source.ruby`, `source.groovy`), and `feel` or any other accepted tag without an installed grammar falls back to a plain uncolored block.
This is highlighting only, with no autocomplete, hover, or diagnostics inside the fence.
@@ -45,12 +49,23 @@ npm run build --workspace packages/extension
```
`esbuild` bundles both entry points into CommonJS under `out/`, which is how VS Code loads extensions.
-To try it live, press F5 in VS Code from the repo root: a second window opens with the extension loaded, where `.bpmnscript` and `.bpmn` files get language support and the sidebar panel.
-See [CONTRIBUTING.md](../../CONTRIBUTING.md#trying-it-out-in-vs-code).
+To try it live, press F5 in VS Code from the repo root: a second window opens with the extension loaded, where `.bpmnscript` files get language support and `.bpmn` files get the decompile command and the sidebar panel.
+See [CONTRIBUTING.md](https://github.com/BPMNscript/bpmnscript/blob/main/CONTRIBUTING.md#trying-it-out-in-vs-code).
Build order matters.
The extension bundles `@bpmn-script/language` and `@bpmn-script/transform` from their compiled `out/` directories, so a source edit in either one is invisible until you rebuild it (or run `npm run build` from the repo root).
+## Installing
+
+```bash
+npm run build
+npm run package -w packages/extension
+code --install-extension packages/extension/vscode-bpmnscript-0.0.1.vsix
+```
+
+VS Code then shows the BPMNscript icon in the activity bar and offers `BPMNscript: Compile to BPMN` in the command palette when a `.bpmnscript` file is open.
+F5 from the repo root stays the way to try a change to the extension itself without packaging it.
+
## Source layout
| Path | Purpose |
@@ -64,8 +79,10 @@ The extension bundles `@bpmn-script/language` and `@bpmn-script/transform` from
| `media/sidebar.css` | Webview styles built on `--vscode-*` theme variables |
| `media/sidebar.js` | Webview script: renders state, posts convert and open messages to the extension host |
| `media/sidebar-icon.svg` | Activity-bar icon for the "BPMNscript" view container |
+| `media/icon.png` | Marketplace/Extensions-view icon, rasterized from `media/sidebar-icon.svg` |
| `package.json` | Registers the language, commands, menus, sidebar, and activation events |
-| `language-configuration.json` | Brackets, comments, and auto-closing pairs |
+| `language-configuration.json` | Brackets, comments, auto-closing pairs, and the word pattern |
| `esbuild.mjs` | Bundles both entry points; adds the `import.meta.url` CJS shim and copies the moddle asset |
| `injection/bpmn-script.injection.tmLanguage.json` | Injection grammar for embedded-language highlighting inside a `script` body |
| `syntaxes/` | TextMate grammars, both copied in at build time: the base grammar from `language` and the injection grammar from `injection/` |
+| `LICENSE`, `CHANGELOG.md` | Copied from the repo root by `build:prepare`, git-ignored, packed into the vsix |
diff --git a/packages/extension/esbuild.mjs b/packages/extension/esbuild.mjs
index 4ccdcdc..3263bb5 100644
--- a/packages/extension/esbuild.mjs
+++ b/packages/extension/esbuild.mjs
@@ -1,7 +1,7 @@
import * as esbuild from 'esbuild';
import { copyFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { dirname, isAbsolute, join, resolve } from 'node:path';
+import { dirname, join, resolve } from 'node:path';
const watch = process.argv.includes('--watch');
const minify = process.argv.includes('--minify');
@@ -12,9 +12,6 @@ const success = watch ? 'Watch build succeeded' : 'Build succeeded';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
-// Source operaton-moddle.json lives in the transform package next to the TS
-// files that read it; its out/ copy is what normal consumers see, but the
-// bundle needs a copy beside itself (see assetCopyPlugin below).
const operatonModdleSrc = resolve(
__dirname,
'../transform/src/operaton-moddle.json',
@@ -29,56 +26,30 @@ function padZeroes(i) {
return i.toString().padStart(2, '0');
}
-/**
- * esbuild plugin: copies operaton-moddle.json beside the bundle after every
- * successful build, including --watch rebuilds.
- *
- * Why this is needed: the transform package locates the JSON at module-init
- * time via dirname(fileURLToPath(import.meta.url)). Under the CJS bundle the
- * import.meta.url shim (below) resolves to the bundle file, so moduleDir ===
- * the bundle's output directory. Copying the asset there satisfies the
- * transform resolver's first candidate:
- * join(moduleDir, 'operaton-moddle.json') → out/extension/operaton-moddle.json
- *
- * Exported so that verify/test bundles can include it and get the same copy
- * behavior without duplicating the path logic.
- */
+// Copies operaton-moddle.json beside the bundle after every build, watch
+// rebuilds included: the transform package locates it at module init through
+// dirname(fileURLToPath(import.meta.url)), which under the shim below is the
+// bundle's own directory.
export const assetCopyPlugin = {
name: 'asset-copy',
setup(build) {
build.onEnd((result) => {
if (result.errors.length > 0) return;
const opts = build.initialOptions;
- let targetDir;
- if (opts.outfile) {
- // Single-file output (e.g. a verify/test bundle): copy beside it.
- // Use an absolute-path-aware resolution so this works regardless of cwd.
- targetDir = isAbsolute(opts.outfile)
- ? dirname(opts.outfile)
- : resolve(process.cwd(), dirname(opts.outfile));
- } else {
- // outdir-based output: extension entry maps to outdir/extension/.
- targetDir = resolve(__dirname, opts.outdir ?? 'out', 'extension');
- }
+ // outfile is a single-file test bundle; outdir is the extension build,
+ // whose entry lands under outdir/extension/.
+ const targetDir = opts.outfile
+ ? resolve(dirname(opts.outfile))
+ : resolve(__dirname, opts.outdir ?? 'out', 'extension');
mkdirSync(targetDir, { recursive: true });
copyFileSync(operatonModdleSrc, join(targetDir, 'operaton-moddle.json'));
});
},
};
-/**
- * Shared esbuild options, exported so that any verify/test bundle uses the
- * identical configuration as the production extension bundle. In particular:
- *
- * define + banner: needed because @bpmn-script/transform reads
- * fileURLToPath(import.meta.url) at module-init time. esbuild's CJS output
- * otherwise leaves import.meta.url as undefined, causing
- * ERR_INVALID_ARG_TYPE on activation. The banner injects a CJS-compatible
- * definition at the top of the bundle before any module-init code runs.
- *
- * Callers should override entryPoints / outfile / outdir and add their own
- * plugins (assetCopyPlugin is exported separately for the same reason).
- */
+// define + banner: @bpmn-script/transform reads fileURLToPath(import.meta.url)
+// at module init, and esbuild's CJS output otherwise leaves import.meta.url
+// undefined, which throws ERR_INVALID_ARG_TYPE on activation.
export const sharedBuildOptions = {
bundle: true,
target: 'ES2022',
@@ -93,9 +64,8 @@ export const sharedBuildOptions = {
},
};
-// ── Production build entry ────────────────────────────────────────────────────
-// Guard with isMain so that importing this module (e.g. from a test to obtain
-// sharedBuildOptions) does not trigger a production build as a side effect.
+// The test bundle imports this module for the options above; only a direct
+// `node esbuild.mjs` builds.
const isMain =
process.argv[1] != null &&
import.meta.url === pathToFileURL(process.argv[1]).href;
diff --git a/packages/extension/injection/bpmn-script.injection.tmLanguage.json b/packages/extension/injection/bpmn-script.injection.tmLanguage.json
index fbc71f4..b1a0eed 100644
--- a/packages/extension/injection/bpmn-script.injection.tmLanguage.json
+++ b/packages/extension/injection/bpmn-script.injection.tmLanguage.json
@@ -11,7 +11,7 @@
],
"repository": {
"javascript-block": {
- "begin": "(`{3})(javascript|js)\\s*$",
+ "begin": "(`{3})(javascript|js|ecmascript)\\s*$",
"beginCaptures": {
"1": { "name": "punctuation.definition.script.begin.bpmn-script" },
"2": { "name": "fenced_code.block.language.bpmn-script" }
diff --git a/packages/extension/language-configuration.json b/packages/extension/language-configuration.json
index 7976a56..c66e306 100644
--- a/packages/extension/language-configuration.json
+++ b/packages/extension/language-configuration.json
@@ -5,6 +5,8 @@
// block comment markers
"blockComment": ["/*", "*/"]
},
+ // the ID terminal in bpmn-script.langium, so a hyphenated name selects whole
+ "wordPattern": "[_a-zA-Z]\\w*(-\\w+)*",
// symbols used as brackets
"brackets": [
["{", "}"],
diff --git a/packages/extension/media/icon.png b/packages/extension/media/icon.png
new file mode 100644
index 0000000..02a4ba0
Binary files /dev/null and b/packages/extension/media/icon.png differ
diff --git a/packages/extension/media/sidebar.js b/packages/extension/media/sidebar.js
index 724c093..a9f752e 100644
--- a/packages/extension/media/sidebar.js
+++ b/packages/extension/media/sidebar.js
@@ -4,10 +4,10 @@
* Runs inside the VS Code webview (Chromium sandbox). Communicates with the
* extension host exclusively via postMessage (no DOM event handlers in HTML).
*
- * Message protocol (host → webview):
+ * Message protocol (host -> webview):
* {type:'state', activeFile} - active-file state push
*
- * Message protocol (webview → host):
+ * Message protocol (webview -> host):
* {type:'ready'} - page loaded; request current state
* {type:'compile', uri} - run bpmnscript.compile on this URI
* {type:'decompile', uri} - run bpmnscript.decompile on this URI
@@ -27,7 +27,7 @@
vscode.postMessage({ type: 'ready' });
// -------------------------------------------------------------------------
- // Message handling (host → webview)
+ // Message handling (host -> webview)
// -------------------------------------------------------------------------
window.addEventListener('message', function (event) {
@@ -89,7 +89,7 @@
if (file.counterpart) {
container.appendChild(
linkButton(
- 'Open ' + file.counterpart.name + ' →',
+ 'Open ' + file.counterpart.name + ' ->',
file.counterpart.uri,
),
);
@@ -106,7 +106,7 @@
const pickBtn = document.createElement('button');
pickBtn.className = 'action secondary';
- pickBtn.textContent = 'Pick a BPMN file…';
+ pickBtn.textContent = 'Pick a BPMN file...';
pickBtn.title = 'Choose a .bpmn file to convert to BPMNscript';
pickBtn.addEventListener('click', function () {
vscode.postMessage({ type: 'pick' });
diff --git a/packages/extension/package.json b/packages/extension/package.json
index 8af5959..3131c43 100644
--- a/packages/extension/package.json
+++ b/packages/extension/package.json
@@ -2,10 +2,18 @@
"name": "vscode-bpmnscript",
"description": "VS Code extension for BPMNscript, bundling the Langium language server",
"version": "0.0.1",
- "displayName": "bpmnscript",
+ "displayName": "BPMNscript",
+ "publisher": "bpmnscript",
+ "license": "Apache-2.0",
+ "icon": "media/icon.png",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/BPMNscript/bpmnscript.git",
+ "directory": "packages/extension"
+ },
"type": "module",
"engines": {
- "vscode": "^1.100.0"
+ "vscode": "^1.134.0"
},
"categories": [
"Programming Languages"
@@ -16,7 +24,7 @@
{
"id": "bpmn-script",
"aliases": [
- "BpmnScript",
+ "BPMNscript",
"bpmn-script"
],
"extensions": [
@@ -52,7 +60,7 @@
},
{
"command": "bpmnscript.openAndDecompile",
- "title": "Open a BPMN file and convert it\u2026",
+ "title": "Open a BPMN file and convert it...",
"category": "BPMNscript"
}
],
@@ -95,13 +103,14 @@
"scripts": {
"clean": "shx rm -fr *.tsbuildinfo out syntaxes",
"vscode:prepublish": "npm run build",
- "build:prepare": "shx mkdir -p ./syntaxes/ && shx cp -f ../language/syntaxes/bpmn-script.tmLanguage.json ./syntaxes/bpmn-script.tmLanguage.json && shx cp -f ./injection/bpmn-script.injection.tmLanguage.json ./syntaxes/bpmn-script.injection.tmLanguage.json",
+ "build:prepare": "shx mkdir -p ./syntaxes/ && shx cp -f ../language/syntaxes/bpmn-script.tmLanguage.json ./syntaxes/bpmn-script.tmLanguage.json && shx cp -f ./injection/bpmn-script.injection.tmLanguage.json ./syntaxes/bpmn-script.injection.tmLanguage.json && shx cp -f ../../LICENSE ./LICENSE && shx cp -f ../../CHANGELOG.md ./CHANGELOG.md",
"build": "npm run build:prepare && tsc -b tsconfig.json && node esbuild.mjs",
"build:post": "npm run build:prepare && node esbuild.mjs",
"build:clean": "npm run clean && npm run build",
"watch": "npm run build:prepare && concurrently -n tsc,esbuild -c blue,yellow \"tsc -b tsconfig.json --watch\" \"node esbuild.mjs --watch\"",
"typecheck": "tsc -b tsconfig.test.json",
- "test": "vitest run"
+ "test": "vitest run",
+ "package": "vsce package"
},
"dependencies": {
"@bpmn-script/language": "0.0.1",
@@ -110,6 +119,7 @@
},
"devDependencies": {
"@types/vscode": "~1.134.0",
+ "@vscode/vsce": "^4.0.0",
"concurrently": "^10.0.5",
"esbuild": "~0.28.2",
"vitest": "^4.1.11"
diff --git a/packages/extension/src/extension/conversion-core.ts b/packages/extension/src/extension/conversion-core.ts
index f8f6939..fdd2c0c 100644
--- a/packages/extension/src/extension/conversion-core.ts
+++ b/packages/extension/src/extension/conversion-core.ts
@@ -1,7 +1,10 @@
-// No `vscode` import here: the unit tests run this without an editor host.
-// `conversion.ts` is the VS Code adapter.
+// No `vscode` import, so unit tests run this without an editor host.
-import { createBpmnScriptServices } from '@bpmn-script/language';
+import {
+ createBpmnScriptServices,
+ Diagnostic,
+ DiagnosticSeverity,
+} from '@bpmn-script/language';
import type { Model } from '@bpmn-script/language';
import { EmptyFileSystem, URI } from 'langium';
import * as path from 'node:path';
@@ -10,11 +13,14 @@ import {
irToXml,
xmlToIr,
irToDsl,
+ NO_PROCESS_MESSAGE,
+ readableParseError,
+ xmlInputProblem,
UnsupportedConstructError,
+ LayoutError,
} from '@bpmn-script/transform';
import type { ImportWarning, PrintWarning } from '@bpmn-script/transform';
-// Positions are 0-based, LSP convention.
export interface ConvDiagnostic {
line: number;
character: number;
@@ -26,7 +32,7 @@ export interface ConvDiagnostic {
}
export type CompileResult =
- | { ok: true; output: string }
+ | { ok: true; output: string; layoutWarning?: string }
| { ok: false; kind: 'validation'; diagnostics: ConvDiagnostic[] }
| { ok: false; kind: 'error'; message: string };
@@ -35,22 +41,17 @@ export type DecompileResult =
| { ok: false; kind: 'unsupported'; message: string }
| { ok: false; kind: 'error'; message: string };
-const SEVERITY_ERROR = 1;
-
-// Built once per module load: creating the services is expensive.
const { shared } = createBpmnScriptServices(EmptyFileSystem);
let nextDocId = 0;
export async function compileDslToBpmn(
source: string,
- sourceFileName: string,
exporterVersion: string,
): Promise {
const uri = URI.parse(`memory:///conv-${nextDocId++}.bpmnscript`);
- // Registered so the DocumentBuilder can resolve cross-references, and
- // removed afterwards so the index does not grow with every call.
+ // Registered so cross-references resolve, removed so the index does not grow.
const doc = shared.workspace.LangiumDocumentFactory.fromString(
source,
uri,
@@ -61,7 +62,7 @@ export async function compileDslToBpmn(
await shared.workspace.DocumentBuilder.build([doc], { validation: true });
const errors = (doc.diagnostics ?? []).filter(
- (d) => d.severity === SEVERITY_ERROR,
+ (d) => d.severity === DiagnosticSeverity.Error,
);
if (errors.length > 0) {
const diagnostics: ConvDiagnostic[] = errors.map((d) => ({
@@ -69,17 +70,21 @@ export async function compileDslToBpmn(
character: d.range.start.character,
endLine: d.range.end.line,
endCharacter: d.range.end.character,
- message: typeof d.message === 'string' ? d.message : d.message.value,
- severity: SEVERITY_ERROR,
+ message: Diagnostic.getMessageString(d),
+ severity: DiagnosticSeverity.Error,
text: doc.textDocument.getText(d.range),
}));
return { ok: false, kind: 'validation', diagnostics };
}
- const ast = doc.parseResult.value as Model;
+ // After the error gate: a keyword typo also parses into a model with no processes.
+ if (doc.parseResult.value.processes.length === 0) {
+ return { ok: false, kind: 'error', message: NO_PROCESS_MESSAGE };
+ }
+
let ir;
try {
- ir = astToIr(ast);
+ ir = astToIr(doc.parseResult.value);
} catch (err) {
return {
ok: false,
@@ -89,17 +94,27 @@ export async function compileDslToBpmn(
}
let output;
+ let layoutWarning: string | undefined;
try {
- output = await irToXml(ir, { sourceFileName, exporterVersion });
+ output = await irToXml(ir, { exporterVersion });
} catch (err) {
- return {
- ok: false,
- kind: 'error',
- message: `IR to XML conversion failed: ${(err as Error).message}`,
- };
+ if (err instanceof LayoutError) {
+ output = err.xml;
+ layoutWarning = err.message;
+ } else {
+ return {
+ ok: false,
+ kind: 'error',
+ message: `IR to XML conversion failed: ${(err as Error).message}`,
+ };
+ }
}
- return { ok: true, output };
+ return {
+ ok: true,
+ output,
+ ...(layoutWarning !== undefined ? { layoutWarning } : {}),
+ };
} catch (err) {
return {
ok: false,
@@ -113,8 +128,12 @@ export async function compileDslToBpmn(
export async function decompileBpmnToDsl(
xml: string,
- _sourceFileName: string, // unused; keeps the signature parallel to compile
): Promise {
+ const problem = xmlInputProblem(xml);
+ if (problem !== undefined) {
+ return { ok: false, kind: 'error', message: problem };
+ }
+
let ir;
let warnings: ImportWarning[];
try {
@@ -123,10 +142,11 @@ export async function decompileBpmnToDsl(
if (err instanceof UnsupportedConstructError) {
return { ok: false, kind: 'unsupported', message: err.message };
}
+ // A parser error quotes the rest of the document, too long for a notification.
return {
ok: false,
kind: 'error',
- message: (err as Error).message,
+ message: readableParseError((err as Error).message, xml),
};
}
@@ -145,8 +165,7 @@ export async function decompileBpmnToDsl(
return { ok: true, output, warnings: [...warnings, ...printWarnings] };
}
-// Duplicated from `packages/cli/src/util.ts` rather than imported: importing it
-// would pull chalk and commander into the extension bundle.
+// Importing the cli's `resolveOutputPath` would pull chalk and commander into the bundle.
export function swapExtension(filePath: string, newExt: string): string {
const dir = path.dirname(filePath);
const base = path.basename(filePath, path.extname(filePath));
diff --git a/packages/extension/src/extension/conversion.ts b/packages/extension/src/extension/conversion.ts
index 4b43ea5..64d6710 100644
--- a/packages/extension/src/extension/conversion.ts
+++ b/packages/extension/src/extension/conversion.ts
@@ -5,13 +5,16 @@ import {
decompileBpmnToDsl,
swapExtension,
} from './conversion-core.js';
-import type { ConvDiagnostic } from './conversion-core.js';
-function resolveSourceUri(uri?: vscode.Uri): vscode.Uri | undefined {
- return uri ?? vscode.window.activeTextEditor?.document.uri;
+type ConvertHandler = (uri?: vscode.Uri) => Promise;
+
+interface Source {
+ uri: vscode.Uri;
+ name: string;
+ text: string;
}
-// Prefers an open TextDocument so unsaved edits convert, not the stale file.
+// An open TextDocument carries unsaved edits.
async function readText(sourceUri: vscode.Uri): Promise {
const openDoc = vscode.workspace.textDocuments.find(
(doc) => doc.uri.toString() === sourceUri.toString(),
@@ -23,11 +26,32 @@ async function readText(sourceUri: vscode.Uri): Promise {
return new TextDecoder().decode(bytes);
}
+async function readSource(
+ uri: vscode.Uri | undefined,
+ ext: '.bpmnscript' | '.bpmn',
+ otherCommand: 'Decompile to BPMNscript' | 'Compile to BPMN',
+): Promise {
+ const sourceUri = uri ?? vscode.window.activeTextEditor?.document.uri;
+ if (!sourceUri) {
+ await vscode.window.showWarningMessage(
+ `BPMNscript: No file selected. Open a ${ext} file or select one in the Explorer.`,
+ );
+ return undefined;
+ }
+ const name = path.basename(sourceUri.fsPath);
+ if (path.extname(sourceUri.fsPath).toLowerCase() !== ext) {
+ await vscode.window.showWarningMessage(
+ `BPMNscript: "${name}" is not a ${ext} file; use "${otherCommand}" for it.`,
+ );
+ return undefined;
+ }
+ return { uri: sourceUri, name, text: await readText(sourceUri) };
+}
+
async function confirmOverwrite(outputUri: vscode.Uri): Promise {
try {
await vscode.workspace.fs.stat(outputUri);
} catch {
- // stat throws when the target does not exist: nothing to overwrite.
return true;
}
const answer = await vscode.window.showWarningMessage(
@@ -38,143 +62,109 @@ async function confirmOverwrite(outputUri: vscode.Uri): Promise {
return answer === 'Overwrite';
}
-function toVsDiagnostic(d: ConvDiagnostic): vscode.Diagnostic {
- return new vscode.Diagnostic(
- new vscode.Range(d.line, d.character, d.endLine, d.endCharacter),
- d.message,
- d.severity === 1
- ? vscode.DiagnosticSeverity.Error
- : vscode.DiagnosticSeverity.Warning,
+async function writeOutput(
+ source: Source,
+ ext: '.bpmn' | '.bpmnscript',
+ verb: 'Compiled' | 'Decompiled',
+ output: string,
+): Promise {
+ const outputPath = swapExtension(source.uri.fsPath, ext);
+ const outputUri = vscode.Uri.file(outputPath);
+ if (!(await confirmOverwrite(outputUri))) {
+ return undefined;
+ }
+ await vscode.workspace.fs.writeFile(
+ outputUri,
+ new TextEncoder().encode(output),
+ );
+ await vscode.window.showTextDocument(outputUri);
+ void vscode.window.showInformationMessage(
+ `BPMNscript: ${verb} "${source.name}" -> "${path.basename(outputPath)}"`,
);
+ return outputUri;
}
-export function compileCommand(
- diagnostics: vscode.DiagnosticCollection,
- extensionVersion: string,
-): (uri?: vscode.Uri) => Promise {
- return async (uri?: vscode.Uri): Promise => {
- const sourceUri = resolveSourceUri(uri);
- if (!sourceUri) {
- await vscode.window.showWarningMessage(
- 'BPMNscript: No file selected. Open a .bpmnscript file or select one in the Explorer.',
- );
+export function compileCommand(extensionVersion: string): ConvertHandler {
+ return async (uri) => {
+ const source = await readSource(
+ uri,
+ '.bpmnscript',
+ 'Decompile to BPMNscript',
+ );
+ if (!source) {
return undefined;
}
- const sourceFileName = path.basename(sourceUri.fsPath);
- const text = await readText(sourceUri);
-
- const result = await compileDslToBpmn(
- text,
- sourceFileName,
- extensionVersion,
- );
-
- // Cleared on every outcome; the validation branch repopulates.
- diagnostics.delete(sourceUri);
-
- if (result.ok) {
- const outputPath = swapExtension(sourceUri.fsPath, '.bpmn');
- const outputUri = vscode.Uri.file(outputPath);
-
- if (!(await confirmOverwrite(outputUri))) {
- return undefined;
+ const result = await compileDslToBpmn(source.text, extensionVersion);
+ if (!result.ok) {
+ if (result.kind === 'validation') {
+ // The language client already publishes these diagnostics.
+ await vscode.window.showTextDocument(source.uri);
+ await vscode.commands.executeCommand('workbench.action.problems.focus');
+ await vscode.window.showErrorMessage(
+ `BPMNscript: "${source.name}" has ${result.diagnostics.length} compilation error(s). See the Problems panel.`,
+ );
+ } else {
+ await vscode.window.showErrorMessage(
+ `BPMNscript: Failed to compile "${source.name}": ${result.message}`,
+ );
}
-
- await vscode.workspace.fs.writeFile(
- outputUri,
- new TextEncoder().encode(result.output),
- );
-
- await vscode.window.showTextDocument(outputUri);
-
- void vscode.window.showInformationMessage(
- `BPMNscript: Compiled "${sourceFileName}" -> "${path.basename(outputPath)}"`,
- );
-
- return outputUri;
- } else if (result.kind === 'validation') {
- diagnostics.set(sourceUri, result.diagnostics.map(toVsDiagnostic));
- await vscode.window.showErrorMessage(
- `BPMNscript: "${sourceFileName}" has ${result.diagnostics.length} compilation error(s). See the Problems panel.`,
- );
return undefined;
- } else {
- await vscode.window.showErrorMessage(
- `BPMNscript: Failed to compile "${sourceFileName}": ${result.message}`,
+ }
+
+ const outputUri = await writeOutput(
+ source,
+ '.bpmn',
+ 'Compiled',
+ result.output,
+ );
+ if (outputUri && result.layoutWarning) {
+ void vscode.window.showWarningMessage(
+ `BPMNscript: "${source.name}" compiled without a diagram: ${result.layoutWarning}`,
);
- return undefined;
}
+ return outputUri;
};
}
-export function decompileCommand(
- diagnostics: vscode.DiagnosticCollection,
-): (uri?: vscode.Uri) => Promise {
- return async (uri?: vscode.Uri): Promise => {
- const sourceUri = resolveSourceUri(uri);
- if (!sourceUri) {
- await vscode.window.showWarningMessage(
- 'BPMNscript: No file selected. Open a .bpmn file or select one in the Explorer.',
- );
+export function decompileCommand(): ConvertHandler {
+ return async (uri) => {
+ const source = await readSource(uri, '.bpmn', 'Compile to BPMN');
+ if (!source) {
return undefined;
}
- const sourceFileName = path.basename(sourceUri.fsPath);
- const text = await readText(sourceUri);
-
- const result = await decompileBpmnToDsl(text, sourceFileName);
-
- diagnostics.delete(sourceUri);
-
- if (result.ok) {
- const outputPath = swapExtension(sourceUri.fsPath, '.bpmnscript');
- const outputUri = vscode.Uri.file(outputPath);
-
- if (!(await confirmOverwrite(outputUri))) {
- return undefined;
- }
-
- await vscode.workspace.fs.writeFile(
- outputUri,
- new TextEncoder().encode(result.output),
- );
-
- await vscode.window.showTextDocument(outputUri);
-
- void vscode.window.showInformationMessage(
- `BPMNscript: Decompiled "${sourceFileName}" -> "${path.basename(outputPath)}"`,
- );
-
- if (result.warnings.length > 0) {
- // Each entry leads with its element id, as `bpmns parse` does: several
- // messages describe the route on from a step without naming it, and
- // two of those read as one line repeated when the id is left off.
- const details = result.warnings
- .map((w) => `${w.elementId}: ${w.message}`)
- .join('; ');
- void vscode.window.showWarningMessage(
- `BPMNscript: "${sourceFileName}" reported ${result.warnings.length} item(s) during decompile: ${details}`,
- );
- }
-
- return outputUri;
- } else if (result.kind === 'unsupported') {
+ const result = await decompileBpmnToDsl(source.text);
+ if (!result.ok) {
await vscode.window.showErrorMessage(
- `BPMNscript: "${sourceFileName}" contains an unsupported construct: ${result.message}`,
+ result.kind === 'unsupported'
+ ? `BPMNscript: "${source.name}" contains an unsupported construct: ${result.message}`
+ : `BPMNscript: Failed to decompile "${source.name}": ${result.message}`,
);
return undefined;
- } else {
- await vscode.window.showErrorMessage(
- `BPMNscript: Failed to decompile "${sourceFileName}": ${result.message}`,
+ }
+
+ const outputUri = await writeOutput(
+ source,
+ '.bpmnscript',
+ 'Decompiled',
+ result.output,
+ );
+ if (outputUri && result.warnings.length > 0) {
+ // Several messages do not name their step, so the id tells them apart.
+ const details = result.warnings
+ .map((w) => `${w.elementId}: ${w.message}`)
+ .join('; ');
+ void vscode.window.showWarningMessage(
+ `BPMNscript: "${source.name}" reported ${result.warnings.length} item(s) during decompile: ${details}`,
);
- return undefined;
}
+ return outputUri;
};
}
export function pickBpmnAndDecompileCommand(
- decompile: (uri?: vscode.Uri) => Promise,
+ decompile: ConvertHandler,
): () => Promise {
return async (): Promise => {
const picked = await vscode.window.showOpenDialog({
diff --git a/packages/extension/src/extension/main.ts b/packages/extension/src/extension/main.ts
index b560d27..f313bfa 100644
--- a/packages/extension/src/extension/main.ts
+++ b/packages/extension/src/extension/main.ts
@@ -17,19 +17,15 @@ let client: LanguageClient;
export async function activate(
context: vscode.ExtensionContext,
): Promise {
- const convDiagnostics =
- vscode.languages.createDiagnosticCollection('bpmnscript');
- context.subscriptions.push(convDiagnostics);
-
const extensionVersion = String(
(context.extension.packageJSON as { version?: string }).version ?? '0.0.1',
);
- const decompile = decompileCommand(convDiagnostics);
+ const decompile = decompileCommand();
context.subscriptions.push(
vscode.commands.registerCommand(
'bpmnscript.compile',
- compileCommand(convDiagnostics, extensionVersion),
+ compileCommand(extensionVersion),
),
vscode.commands.registerCommand('bpmnscript.decompile', decompile),
vscode.commands.registerCommand(
@@ -51,7 +47,7 @@ export async function activate(
vscode.window.onDidChangeActiveTextEditor(() => void provider.refresh()),
);
- // Safe before resolveWebviewView: refresh() no-ops until the view exists.
+ // refresh() no-ops until the view exists.
void provider.refresh();
client = await startLanguageClient(context);
@@ -92,7 +88,7 @@ async function startLanguageClient(
const client = new LanguageClient(
'bpmn-script',
- 'BpmnScript',
+ 'BPMNscript',
serverOptions,
clientOptions,
);
diff --git a/packages/extension/src/extension/sidebar-view-provider.ts b/packages/extension/src/extension/sidebar-view-provider.ts
index a21a3a9..a2319ec 100644
--- a/packages/extension/src/extension/sidebar-view-provider.ts
+++ b/packages/extension/src/extension/sidebar-view-provider.ts
@@ -4,8 +4,7 @@ import * as fs from 'node:fs';
import { randomBytes } from 'node:crypto';
import { swapExtension } from './conversion-core.js';
-// Types shared between host and webview (duplicated in sidebar.js as JSDoc).
-
+// media/sidebar.js reads this shape untyped: change both.
interface Counterpart {
uri: string;
name: string;
@@ -41,7 +40,6 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
webviewView.webview.html = this._buildHtml(webviewView.webview);
// retainContextWhenHidden is false, so the view is rebuilt on every show.
- // Without collecting the disposable the listeners pile up.
const disposables: vscode.Disposable[] = [];
webviewView.webview.onDidReceiveMessage(
(message: unknown) => void this._handleMessage(message),
@@ -50,7 +48,6 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
);
webviewView.onDidDispose(() => {
disposables.forEach((d) => d.dispose());
- // A later refresh() must be a no-op, not a post to a disposed webview.
this._view = undefined;
});
}
@@ -158,7 +155,6 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
).fsPath;
let html = fs.readFileSync(templatePath, 'utf-8');
- // replaceAll: {{NONCE}} and {{CSP_SOURCE}} each appear more than once.
html = html
.replaceAll('{{NONCE}}', nonce)
.replaceAll('{{CSP_SOURCE}}', cspSource)
diff --git a/packages/extension/test/bundled-conversion.e2e.test.ts b/packages/extension/test/bundled-conversion.e2e.test.ts
index 1790125..74b34c6 100644
--- a/packages/extension/test/bundled-conversion.e2e.test.ts
+++ b/packages/extension/test/bundled-conversion.e2e.test.ts
@@ -1,10 +1,6 @@
-// Run `npm run build` before this suite: it consumes the compiled out/
-// directories of language, transform, and extension.
-//
-// What conversion makes of a given input is `conversion-core.test.ts`. What is
-// under test here is the one thing only a bundle can answer: whether the
-// transform still finds its moddle asset once esbuild has flattened it into a
-// single CommonJS file, which is how the extension actually ships.
+// Needs `npm run build` first. Checks that the transform still finds its
+// moddle asset once esbuild flattens it into the single CommonJS file the
+// extension ships as.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'node:fs';
@@ -17,30 +13,20 @@ import * as esbuild from 'esbuild';
// @ts-ignore esbuild.mjs is a plain JS module with no type declarations.
import { sharedBuildOptions, assetCopyPlugin } from '../esbuild.mjs';
-// Vitest transforms TS in place, so import.meta.url resolves to this source
-// file, not to any out/ directory.
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-const REPO_ROOT = path.resolve(__dirname, '../../..');
-
-const EXT_DIR = path.resolve(__dirname, '..');
+const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
+const EXT_DIR = fileURLToPath(new URL('..', import.meta.url));
const GOLDEN_GENERATED_BPMN = path.resolve(
REPO_ROOT,
'tests/golden/invoice-approval-generated.bpmn',
);
-if (!fs.existsSync(GOLDEN_GENERATED_BPMN)) {
- throw new Error(`Fixture not found: ${GOLDEN_GENERATED_BPMN}`);
-}
-
describe('bundled asset resolution and transform under the shim', () => {
- // Unique per run: parallel suites would otherwise collide on these paths.
+ // Unique per run so parallel suites do not collide.
const runId = `${process.pid}-${Date.now()}`;
const verifyEntryFile = path.join(os.tmpdir(), `verify-entry-${runId}.js`);
- // Under out/extension/ so assetCopyPlugin lands operaton-moddle.json beside
- // it whatever the cwd, which is what the import.meta.url shim then resolves.
+ // Under out/extension/ so assetCopyPlugin lands operaton-moddle.json where
+ // the import.meta.url shim resolves.
const verifyOutfile = path.resolve(
EXT_DIR,
'out',
@@ -49,6 +35,8 @@ describe('bundled asset resolution and transform under the shim', () => {
);
beforeAll(async () => {
+ // The only check on assetCopyPlugin's outdir branch: the build below takes
+ // the outfile branch, and the extension itself ships through outdir.
const moddlePath = path.resolve(
EXT_DIR,
'out',
@@ -70,7 +58,7 @@ describe('bundled asset resolution and transform under the shim', () => {
' const bpmnPath = process.argv[2];',
" const xml = readFileSync(bpmnPath, 'utf-8');",
' const { ir } = await xmlToIr(xml);',
- " const bpmnOut = await irToXml(ir, { sourceFileName: 'verify', exporterVersion: '0.0.1' });",
+ " const bpmnOut = await irToXml(ir, { exporterVersion: '0.0.1' });",
" process.stdout.write('PROCESS_ID:' + ir.id + '\\n');",
' process.stdout.write(bpmnOut);',
'})().catch(err => { console.error(err); process.exit(1); });',
@@ -78,8 +66,7 @@ describe('bundled asset resolution and transform under the shim', () => {
fs.writeFileSync(verifyEntryFile, entrySource, 'utf-8');
- // nodePaths is required: the entry lives in /tmp/, so esbuild's resolution
- // walk finds no node_modules and cannot resolve @bpmn-script/transform.
+ // The entry lives in /tmp/, where esbuild's node_modules walk finds nothing.
await esbuild.build({
...sharedBuildOptions,
entryPoints: [verifyEntryFile],
@@ -90,8 +77,6 @@ describe('bundled asset resolution and transform under the shim', () => {
}, 60_000);
afterAll(() => {
- // Only the two per-run files; out/extension/operaton-moddle.json belongs to
- // the production build and must survive.
if (fs.existsSync(verifyEntryFile)) fs.unlinkSync(verifyEntryFile);
if (fs.existsSync(verifyOutfile)) fs.unlinkSync(verifyOutfile);
});
diff --git a/packages/extension/test/conversion-core.test.ts b/packages/extension/test/conversion-core.test.ts
index bdc25bb..1ab55fb 100644
--- a/packages/extension/test/conversion-core.test.ts
+++ b/packages/extension/test/conversion-core.test.ts
@@ -1,12 +1,7 @@
-// The editor-independent half of the conversion commands: what each direction
-// makes of a given input. `conversion.test.ts` covers what the VS Code adapter
-// then shows the author.
-
import { describe, expect, test, beforeAll } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { dirname } from 'node:path';
import { EmptyFileSystem } from 'langium';
import { validationHelper } from 'langium/test';
@@ -17,40 +12,21 @@ import { xmlToIr } from '@bpmn-script/transform';
import {
compileDslToBpmn,
decompileBpmnToDsl,
- swapExtension,
} from '../src/extension/conversion-core.js';
import type { ConvDiagnostic } from '../src/extension/conversion-core.js';
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
-const REPO_ROOT = path.resolve(__dirname, '../../..');
+const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
const INVOICE_APPROVAL_SRC = path.resolve(
REPO_ROOT,
'examples/spring-boot/processes/invoice-approval.bpmnscript',
);
-const GOLDEN_GENERATED_BPMN = path.resolve(
- REPO_ROOT,
- 'tests/golden/invoice-approval-generated.bpmn',
-);
-
const BAD_SERVICE_TASK_BPMN = path.resolve(
REPO_ROOT,
'tests/golden/bad-service-task-no-binding.bpmn',
);
-for (const [label, p] of [
- ['invoice-approval.bpmnscript', INVOICE_APPROVAL_SRC],
- ['invoice-approval-generated.bpmn', GOLDEN_GENERATED_BPMN],
- ['bad-service-task-no-binding.bpmn', BAD_SERVICE_TASK_BPMN],
-] as const) {
- if (!fs.existsSync(p)) {
- throw new Error(`Fixture not found: ${label} at ${p}`);
- }
-}
-
let validate: ReturnType>;
beforeAll(() => {
@@ -58,7 +34,6 @@ beforeAll(() => {
validate = validationHelper(services.BpmnScript);
});
-/** Every substring must appear, so a message that stops naming one fails. */
function expectMentions(text: string, mentions: readonly string[]): void {
for (const mention of mentions) {
expect(text, `expected to find "${mention}" in: ${text}`).toContain(
@@ -67,10 +42,7 @@ function expectMentions(text: string, mentions: readonly string[]): void {
}
}
-/**
- * A diagnostic minus its wording: the positions, the severity and the source
- * text are this module's own mapping, the sentence belongs to the validator.
- */
+// The wording belongs to the validator, the rest is this module's mapping.
type DiagnosticPosition = Omit;
type CompileOutcome =
@@ -91,7 +63,6 @@ describe('compileDslToBpmn', () => {
{ ok: true, reimportsAs: 'invoice-approval' },
],
[
- // String `name` in a numeric comparison: a severity-1 diagnostic.
'a type mismatch blocks the compile and reports the comparison it rejected',
`process p {\n var name: string\n if (name > 1000) { user A }\n}\n`,
{
@@ -99,7 +70,6 @@ describe('compileDslToBpmn', () => {
kind: 'validation',
diagnostics: [
{
- // 0-based, LSP convention.
line: 2,
character: 6,
endLine: 2,
@@ -111,13 +81,12 @@ describe('compileDslToBpmn', () => {
},
],
[
- // Uses `amount` without declaring it: severity 2, which must not block.
'an undeclared variable is only a warning, so the source still compiles',
`process p { if (amount > 1000) { user A } }`,
{ ok: true, reimportsAs: 'p' },
],
])('%s', async (_title, source, expected) => {
- const result = await compileDslToBpmn(source, 'test.bpmnscript', '0.0.1');
+ const result = await compileDslToBpmn(source, '0.0.1');
expect(result.ok).toBe(expected.ok);
if (expected.ok) {
@@ -129,8 +98,6 @@ describe('compileDslToBpmn', () => {
if (result.ok) return;
expect(result.kind).toBe(expected.kind);
- // The union type alone does not stop the adapter writing result.output if a
- // kind check goes missing, so assert the field is absent.
expect('output' in result).toBe(false);
if (result.kind !== 'validation') return;
expect(
@@ -140,15 +107,53 @@ describe('compileDslToBpmn', () => {
expected.diagnostics.map(() => true),
);
});
+
+ // `bpmn-auto-layout`'s grid solver throws on this validator-clean shape: a
+ // mixed true/false/expression `else if` chain feeding one `goto` each.
+ test('a layouter crash on a validator-clean goto graph still compiles, without a diagram, and reports why', async () => {
+ const source = `process p {
+ if (true) {
+ goto L
+ } else if (a.b) {
+ goto U
+ } else if (false) {
+ }
+ receive R
+ end E
+ emit compensation L
+ user U
+ end H
+}
+`;
+
+ const result = await compileDslToBpmn(source, '0.0.1');
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.output).toContain(' {
+ const result = await compileDslToBpmn(
+ '// nothing but a comment here\n',
+ '0.0.1',
+ );
+
+ expect(result).toEqual({
+ ok: false,
+ kind: 'error',
+ message: 'the file has no process',
+ });
+ });
});
-// `formHandlerClass` and the lane are both dropped without loss of
-// behavior, so `xmlToIr` warns instead of refusing.
const LANE_AND_ASYNC_ATTR_BPMN = `
-
+ S
@@ -170,7 +175,7 @@ const CONDITIONAL_START_BPMN = `
-
+ \${stockLevel < 5}
@@ -198,11 +203,6 @@ type DecompileRow = readonly [
describe('decompileBpmnToDsl', () => {
test.each([
- [
- 'a BPMN this tool generated comes back with nothing to report',
- fs.readFileSync(GOLDEN_GENERATED_BPMN, 'utf-8'),
- { ok: true, warnings: [], mentions: [] },
- ],
[
'a lane and an unsupported engine attribute are dropped with a warning each, naming the element they came off',
LANE_AND_ASYNC_ATTR_BPMN,
@@ -230,7 +230,7 @@ describe('decompileBpmnToDsl', () => {
{ ok: true, warnings: [], mentions: [] },
],
])('%s', async (_title, xml, expected) => {
- const result = await decompileBpmnToDsl(xml, 'input.bpmn');
+ const result = await decompileBpmnToDsl(xml);
expect(result.ok).toBe(expected.ok);
if (expected.ok) {
@@ -242,11 +242,8 @@ describe('decompileBpmnToDsl', () => {
result.warnings.map((w) => w.message).join('\n'),
expected.mentions,
);
- // Whatever it accepts, it must hand back a script the compiler accepts.
- // Parsing alone is not that bar: it takes source the validator refuses.
- // Severity 1 is the bar the compile direction blocks on, so it is the
- // bar the output has to clear; a BPMN carrying no variable declarations
- // decompiles to a script that warns about them.
+ // Parsing alone accepts source the validator refuses. Warnings are
+ // expected: a BPMN declares no variables.
const { diagnostics } = await validate(result.output);
expect(
diagnostics.filter((d) => d.severity === 1).map((d) => d.message),
@@ -258,16 +255,29 @@ describe('decompileBpmnToDsl', () => {
expect(result.kind).toBe(expected.kind);
expectMentions(result.message, expected.mentions);
});
-});
-describe('swapExtension', () => {
+ // The file picker offers every file, and the message goes into a
+ // notification whole.
+ const UNPARSABLE_TAG = ' {
- expect(swapExtension(input, newExt)).toBe(expected);
- },
- );
+ [
+ 'a long file that is not XML at all is named by a short preview of its start',
+ 'PK\u0003\u0004' + 'x'.repeat(300_000),
+ `not an XML document (starts with "PK${'x'.repeat(38)}")`,
+ ],
+ [
+ 'a document the parser chokes on is cut to its first line, capped, and stripped of control characters',
+ UNPARSABLE_TAG,
+ (
+ `unparsable content ${UNPARSABLE_TAG} detected`.slice(0, 200) + '...'
+ ).replace(/\p{C}/gu, ''),
+ ],
+ ])('%s', async (_title, xml, message) => {
+ expect(await decompileBpmnToDsl(xml)).toEqual({
+ ok: false,
+ kind: 'error',
+ message,
+ });
+ });
});
diff --git a/packages/extension/test/conversion.test.ts b/packages/extension/test/conversion.test.ts
index d01d7aa..583c2f4 100644
--- a/packages/extension/test/conversion.test.ts
+++ b/packages/extension/test/conversion.test.ts
@@ -1,70 +1,50 @@
-// `vscode` is injected by the extension host, not installed from npm, so it
-// has to be mocked with the surface the adapter touches. The conversion itself
-// is mocked too: what is under test is the notification each outcome composes.
+// `vscode` is injected by the extension host, not installed from npm.
import { beforeEach, describe, expect, test, vi } from 'vitest';
-// vi.mock factories are hoisted above the module body, so a plain top-level
-// const would still be in its temporal dead zone here. vi.hoisted is required.
+// vi.mock factories are hoisted above a plain top-level const.
const mocks = vi.hoisted(() => ({
showWarningMessage: vi.fn(),
showErrorMessage: vi.fn(),
showInformationMessage: vi.fn(),
showTextDocument: vi.fn(),
+ executeCommand: vi.fn(),
}));
-vi.mock('vscode', () => {
- class Range {
- constructor(
- public startLine: number,
- public startCharacter: number,
- public endLine: number,
- public endCharacter: number,
- ) {}
- }
- class Diagnostic {
- constructor(
- public range: Range,
- public message: string,
- public severity: number,
- ) {}
- }
- return {
- Range,
- Diagnostic,
- DiagnosticSeverity: { Error: 0, Warning: 1 },
- Uri: {
- file: (fsPath: string) => ({
- fsPath,
- toString: () => `file://${fsPath}`,
- }),
- },
- window: {
- activeTextEditor: undefined,
- showWarningMessage: mocks.showWarningMessage,
- showErrorMessage: mocks.showErrorMessage,
- showInformationMessage: mocks.showInformationMessage,
- showTextDocument: mocks.showTextDocument,
- showOpenDialog: vi.fn(),
- },
- workspace: {
- textDocuments: [],
- fs: {
- readFile: vi.fn().mockResolvedValue(new Uint8Array()),
- writeFile: vi.fn().mockResolvedValue(undefined),
- // Rejecting sends confirmOverwrite down its "nothing to overwrite"
- // branch, so no row below hits the modal.
- stat: vi.fn().mockRejectedValue(new Error('ENOENT')),
- },
+vi.mock('vscode', () => ({
+ Uri: {
+ file: (fsPath: string) => ({
+ fsPath,
+ toString: () => `file://${fsPath}`,
+ }),
+ },
+ window: {
+ activeTextEditor: undefined,
+ showWarningMessage: mocks.showWarningMessage,
+ showErrorMessage: mocks.showErrorMessage,
+ showInformationMessage: mocks.showInformationMessage,
+ showTextDocument: mocks.showTextDocument,
+ showOpenDialog: vi.fn(),
+ },
+ commands: {
+ executeCommand: mocks.executeCommand,
+ },
+ workspace: {
+ textDocuments: [],
+ fs: {
+ readFile: vi.fn().mockResolvedValue(new Uint8Array()),
+ writeFile: vi.fn().mockResolvedValue(undefined),
+ // Rejecting sends confirmOverwrite down its "nothing to overwrite"
+ // branch, so no row below hits the modal.
+ stat: vi.fn().mockRejectedValue(new Error('ENOENT')),
},
- };
-});
+ },
+}));
-vi.mock('../src/extension/conversion-core.js', () => ({
+vi.mock('../src/extension/conversion-core.js', async (importOriginal) => ({
+ ...(await importOriginal()),
compileDslToBpmn: vi.fn(),
decompileBpmnToDsl: vi.fn(),
- swapExtension: (fsPath: string, newExt: string) =>
- fsPath.replace(/\.[^./]+$/, newExt),
}));
import * as vscode from 'vscode';
@@ -81,27 +61,6 @@ import {
decompileCommand,
} from '../src/extension/conversion.js';
-// `set` is overloaded in the real API, so the spy is declared with the
-// two-argument form the adapter uses and the recorded calls stay readable.
-function fakeDiagnosticCollection() {
- const set =
- vi.fn<(uri: vscode.Uri, published: vscode.Diagnostic[]) => void>();
- const cleared = vi.fn<(uri: vscode.Uri) => void>();
- return {
- collection: {
- set,
- delete: cleared,
- } as unknown as vscode.DiagnosticCollection,
- set,
- cleared,
- };
-}
-
-function occurrences(haystack: string, needle: string): number {
- return haystack.split(needle).length - 1;
-}
-
-/** The first argument of every notification the run raised, by severity. */
function notifications(): Record<'info' | 'warning' | 'error', string[]> {
const firstArgs = (fn: { mock: { calls: unknown[][] } }): string[] =>
fn.mock.calls.map((call) => String(call[0]));
@@ -112,8 +71,16 @@ function notifications(): Record<'info' | 'warning' | 'error', string[]> {
};
}
-// Neither message names its own element: they say "here" and leave the id to
-// the caller, so unrendered they arrive as one line repeated.
+function shownDocuments(): string[] {
+ return mocks.showTextDocument.mock.calls.map(
+ (call) => (call[0] as vscode.Uri).fsPath,
+ );
+}
+
+function executedCommands(): string[] {
+ return mocks.executeCommand.mock.calls.map((call) => String(call[0]));
+}
+
const SAME_WORDING =
'The model weighs the route on from here with a condition, ' +
'which the script leaves out.';
@@ -122,24 +89,21 @@ type Expected = {
info?: string[];
warning?: string[];
error?: string[];
- /** The uri the command returns, or undefined where it gives up. */
returns: string | undefined;
- /** The diagnostics published per `set` call, `[]` where none is expected. */
- published?: vscode.Diagnostic[][];
+ shown?: string[];
+ executed?: string[];
};
type Row = readonly [
title: string,
command: 'compile' | 'decompile',
- result: CompileResult | DecompileResult,
+ // undefined: no uri and no active editor.
+ sourceName: string | undefined,
+ // undefined: refused before the core runs.
+ result: CompileResult | DecompileResult | undefined,
expected: Expected,
];
-const SOURCE_NAME = {
- compile: 'example.bpmnscript',
- decompile: 'example.bpmn',
-} as const;
-
describe('conversion commands: what the author is shown', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -147,32 +111,9 @@ describe('conversion commands: what the author is shown', () => {
test.each([
[
- 'a decompile that dropped something names the file once and the dropped item after it',
- 'decompile',
- {
- ok: true,
- output: 'process P { start S end E }',
- warnings: [
- {
- elementId: 'Task1',
- category: 'extensionAttribute',
- message:
- "The 'formHandlerClass' setting on 'Task1' was not imported",
- },
- ],
- },
- {
- info: ['BPMNscript: Decompiled "example.bpmn" -> "example.bpmnscript"'],
- warning: [
- 'BPMNscript: "example.bpmn" reported 1 item(s) during decompile: ' +
- "Task1: The 'formHandlerClass' setting on 'Task1' was not imported",
- ],
- returns: '/tmp/example.bpmnscript',
- },
- ],
- [
- 'two same-worded warnings are told apart by the element each is about',
+ 'a decompile that dropped things names the file once and each dropped item by its element',
'decompile',
+ 'example.bpmn',
{
ok: true,
output: 'process P { start S end E }',
@@ -196,11 +137,13 @@ describe('conversion commands: what the author is shown', () => {
`CheckStock: ${SAME_WORDING}; ReserveGoods: ${SAME_WORDING}`,
],
returns: '/tmp/example.bpmnscript',
+ shown: ['/tmp/example.bpmnscript'],
},
],
[
'a refused construct is reported as an error naming the file once, and nothing is written',
'decompile',
+ 'example.bpmn',
{
ok: false,
kind: 'unsupported',
@@ -215,8 +158,9 @@ describe('conversion commands: what the author is shown', () => {
},
],
[
- 'a validation failure reports the count and publishes the diagnostics to the Problems panel',
+ 'a validation failure focuses the Problems panel and reports the count',
'compile',
+ 'example.bpmnscript',
{
ok: false,
kind: 'validation',
@@ -237,57 +181,91 @@ describe('conversion commands: what the author is shown', () => {
'BPMNscript: "example.bpmnscript" has 1 compilation error(s). See the Problems panel.',
],
returns: undefined,
- published: [
- [
- new vscode.Diagnostic(
- new vscode.Range(0, 0, 0, 1),
- 'bad',
- vscode.DiagnosticSeverity.Error,
- ),
- ],
- ],
+ shown: ['/tmp/example.bpmnscript'],
+ executed: ['workbench.action.problems.focus'],
},
],
[
'an unexpected failure is reported as an error naming the file once',
'compile',
+ 'example.bpmnscript',
{ ok: false, kind: 'error', message: 'boom' },
{
error: ['BPMNscript: Failed to compile "example.bpmnscript": boom'],
returns: undefined,
},
],
- ])('%s', async (_title, command, result, expected) => {
- const diagnostics = fakeDiagnosticCollection();
+ [
+ 'decompile refuses a file that is not BPMN',
+ 'decompile',
+ 'example.bpmnscript',
+ undefined,
+ {
+ warning: [
+ 'BPMNscript: "example.bpmnscript" is not a .bpmn file; ' +
+ 'use "Compile to BPMN" for it.',
+ ],
+ returns: undefined,
+ },
+ ],
+ [
+ 'compile with nothing selected and no editor open says so and stops',
+ 'compile',
+ undefined,
+ undefined,
+ {
+ warning: [
+ 'BPMNscript: No file selected. Open a .bpmnscript file or select one in the Explorer.',
+ ],
+ returns: undefined,
+ },
+ ],
+ [
+ 'compile takes an upper-case extension',
+ 'compile',
+ 'ORDER.BPMNSCRIPT',
+ { ok: true, output: 'process P { start S end E }' },
+ {
+ info: ['BPMNscript: Compiled "ORDER.BPMNSCRIPT" -> "ORDER.bpmn"'],
+ returns: '/tmp/ORDER.bpmn',
+ shown: ['/tmp/ORDER.bpmn'],
+ },
+ ],
+ ])('%s', async (_title, command, sourceName, result, expected) => {
let handler: (uri?: vscode.Uri) => Promise;
if (command === 'compile') {
- vi.mocked(compileDslToBpmn).mockResolvedValue(result as CompileResult);
- handler = compileCommand(diagnostics.collection, '0.0.1');
+ if (result) {
+ vi.mocked(compileDslToBpmn).mockResolvedValue(result as CompileResult);
+ }
+ handler = compileCommand('0.0.1');
} else {
- vi.mocked(decompileBpmnToDsl).mockResolvedValue(
- result as DecompileResult,
- );
- handler = decompileCommand(diagnostics.collection);
+ if (result) {
+ vi.mocked(decompileBpmnToDsl).mockResolvedValue(
+ result as DecompileResult,
+ );
+ }
+ handler = decompileCommand();
}
- const sourceName = SOURCE_NAME[command];
- const returned = await handler(vscode.Uri.file(`/tmp/${sourceName}`));
+ const returned = await handler(
+ sourceName === undefined
+ ? undefined
+ : vscode.Uri.file(`/tmp/${sourceName}`),
+ );
- const { info = [], warning = [], error = [] } = expected;
+ const {
+ info = [],
+ warning = [],
+ error = [],
+ shown = [],
+ executed = [],
+ } = expected;
expect(returned?.fsPath).toBe(expected.returns);
expect(notifications()).toEqual({ info, warning, error });
+ expect(shownDocuments()).toEqual(shown);
+ expect(executedCommands()).toEqual(executed);
- // Stale diagnostics go on every outcome; only the validation branch fills
- // the panel again.
- expect(diagnostics.cleared).toHaveBeenCalledTimes(1);
- expect(
- diagnostics.set.mock.calls.map(([, published]) => published),
- ).toEqual(expected.published ?? []);
-
- // The file is named once per line. The success line is the exception: it
- // names the file it read and the file it wrote.
- for (const message of [...warning, ...error]) {
- expect(occurrences(message, sourceName)).toBe(1);
- }
+ const core = command === 'compile' ? compileDslToBpmn : decompileBpmnToDsl;
+ expect(core).toHaveBeenCalledTimes(result === undefined ? 0 : 1);
});
});
diff --git a/packages/extension/test/injection-grammar.test.ts b/packages/extension/test/injection-grammar.test.ts
index 3d4e336..2e880cb 100644
--- a/packages/extension/test/injection-grammar.test.ts
+++ b/packages/extension/test/injection-grammar.test.ts
@@ -1,7 +1,6 @@
-// These tests run the grammar's begin/end patterns through JS RegExp instead of
-// a TextMate engine. That only holds because the patterns stick to constructs
-// JS and Oniguruma agree on: literal backticks, alternation, \s, $, character
-// classes. Add an Oniguruma-only construct and this file stops modeling VS Code.
+// The begin/end patterns run through JS RegExp instead of a TextMate engine,
+// which holds only while they stick to constructs JS and Oniguruma agree on
+// (literal backticks, alternation, \s, $, character classes).
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
@@ -9,8 +8,6 @@ import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SCRIPT_FORMAT_ALIASES } from '@bpmn-script/language';
-// The TextMate scope each canonical script format embeds. A format missing here
-// has no installed grammar, and every fence tag reaching it must say so below.
const EMBEDDED_SCOPE_BY_FORMAT: Readonly> = {
javascript: 'source.js',
python: 'source.python',
@@ -18,8 +15,8 @@ const EMBEDDED_SCOPE_BY_FORMAT: Readonly> = {
groovy: 'source.groovy',
};
-// Tags deliberately left unhighlighted: VS Code ships no grammar for them.
-const NO_INSTALLED_GRAMMAR = new Set(['feel']);
+// VS Code ships no grammar for these.
+const NO_INSTALLED_GRAMMAR = new Set(['feel', 'juel']);
const EXTENSION_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -54,70 +51,51 @@ function matchFence(line: string): { name: string; rule: any } | undefined {
}
describe('fenced-script injection grammar', () => {
- it('injects into source.bpmn-script and is registered with injectTo', () => {
+ it('is registered against source.bpmn-script at a path build:prepare copies it to', () => {
expect(injection.injectionSelector).toContain('source.bpmn-script');
const entry = pkg.contributes.grammars.find(
(g: { scopeName: string }) => g.scopeName === injection.scopeName,
);
expect(entry).toBeDefined();
- expect(entry.injectTo).toContain('source.bpmn-script');
- expect(entry.path).toBe('syntaxes/bpmn-script.injection.tmLanguage.json');
- });
-
- it('build:prepare copies the injection asset into the runtime syntaxes dir', () => {
+ expect(entry.injectTo).toEqual(['source.bpmn-script']);
expect(pkg.scripts['build:prepare']).toContain(
- './injection/bpmn-script.injection.tmLanguage.json',
- );
- expect(pkg.scripts['build:prepare']).toContain(
- './syntaxes/bpmn-script.injection.tmLanguage.json',
+ `cp -f ./injection/bpmn-script.injection.tmLanguage.json ./${entry.path}`,
);
expect(() =>
- readFileSync(
- path.join(
- EXTENSION_DIR,
- 'syntaxes',
- 'bpmn-script.injection.tmLanguage.json',
- ),
- ),
+ readFileSync(path.join(EXTENSION_DIR, entry.path)),
).not.toThrow();
});
// Driven off the alias table, so a new alias fails here until it is either
// routed by the grammar or declared as having no installed grammar.
- it.each(Object.keys(SCRIPT_FORMAT_ALIASES))(
- 'tag ```%s routes to its embedded scope, or is a declared miss',
- (tag) => {
+ it('every alias tag routes to its embedded scope or is a declared miss, and unknown tags fall back to a plain block', () => {
+ for (const tag of [
+ ...Object.keys(SCRIPT_FORMAT_ALIASES),
+ 'kotlin',
+ 'sql',
+ ]) {
const match = matchFence('script demo ```' + tag);
- expect(match).toBeDefined();
- if (NO_INSTALLED_GRAMMAR.has(tag)) {
- expect(match!.name).toBe('plain-block');
- expect(match!.rule.contentName).toBeUndefined();
- expect(match!.rule.patterns).toBeUndefined();
- return;
- }
+ expect(match, tag).toBeDefined();
const format = SCRIPT_FORMAT_ALIASES[tag];
+ if (format === undefined || NO_INSTALLED_GRAMMAR.has(tag)) {
+ expect(
+ [match!.name, match!.rule.contentName, match!.rule.patterns],
+ tag,
+ ).toEqual(['plain-block', undefined, undefined]);
+ continue;
+ }
const embedded = EMBEDDED_SCOPE_BY_FORMAT[format];
expect(
embedded,
`tag '${tag}' normalizes to '${format}': give it an injection block and an EMBEDDED_SCOPE_BY_FORMAT entry, or add the tag to NO_INSTALLED_GRAMMAR`,
).toBeDefined();
- expect(match!.rule.contentName).toBe(`meta.embedded.block.${format}`);
- expect(match!.rule.patterns).toContainEqual({ include: embedded });
- },
- );
-
- it.each([['kotlin'], ['sql']])(
- 'unknown tag ```%s falls back to a plain block (no embedded include)',
- (tag) => {
- expect(SCRIPT_FORMAT_ALIASES[tag]).toBeUndefined();
- const match = matchFence('script demo ```' + tag);
- expect(match).toBeDefined();
- expect(match!.name).toBe('plain-block');
- expect(match!.rule.contentName).toBeUndefined();
- expect(match!.rule.patterns).toBeUndefined();
- },
- );
+ expect(match!.rule.contentName, tag).toBe(
+ `meta.embedded.block.${format}`,
+ );
+ expect(match!.rule.patterns, tag).toContainEqual({ include: embedded });
+ }
+ });
it('a bare closing fence starts no block and matches an end pattern', () => {
expect(matchFence('```')).toBeUndefined();
diff --git a/packages/extension/test/language-configuration.test.ts b/packages/extension/test/language-configuration.test.ts
new file mode 100644
index 0000000..f9d8c01
--- /dev/null
+++ b/packages/extension/test/language-configuration.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import * as path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { ID_TERMINAL } from '@bpmn-script/language';
+
+const EXTENSION_DIR = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ '..',
+);
+
+// JSONC: whole comment lines are stripped, not a trailing `//`, which would
+// eat the "lineComment": "//" value.
+function readWordPattern(): unknown {
+ const raw = readFileSync(
+ path.join(EXTENSION_DIR, 'language-configuration.json'),
+ 'utf8',
+ );
+ return JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')).wordPattern;
+}
+
+describe('language configuration', () => {
+ it("the editor's word pattern is the grammar's ID terminal", () => {
+ expect(readWordPattern()).toBe(ID_TERMINAL.source);
+ });
+});
diff --git a/packages/extension/test/sidebar-view-provider.test.ts b/packages/extension/test/sidebar-view-provider.test.ts
index faeb84e..5943a81 100644
--- a/packages/extension/test/sidebar-view-provider.test.ts
+++ b/packages/extension/test/sidebar-view-provider.test.ts
@@ -1,6 +1,3 @@
-// `vscode` is injected by the extension host, not installed from npm, so it is
-// mocked. The fake view exists to fire onDidDispose on demand.
-
import { describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -19,7 +16,6 @@ vi.mock('vscode', () => ({
import { SidebarViewProvider } from '../src/extension/sidebar-view-provider.js';
-// _buildHtml reads media/sidebar.html from this directory.
const EXTENSION_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
diff --git a/packages/language/README.md b/packages/language/README.md
index 174ac5a..8c95cb7 100644
--- a/packages/language/README.md
+++ b/packages/language/README.md
@@ -4,20 +4,21 @@ The Langium package: grammar, parser, AST, validator, and language server for BP
It's the reference for what counts as valid syntax, and the piece that turns source text into the tree the rest of the toolchain works on.
The language's rules are written once, in `src/bpmn-script.langium`.
-[Langium](https://langium.org/) reads that grammar and generates a parser, the AST types as TypeScript interfaces, and a language server that powers highlighting, autocompletion, and inline diagnostics.
+[Langium](https://langium.org/) reads that grammar and generates a parser, the AST types as TypeScript interfaces, and a language server that powers highlighting, autocompletion, hover, navigation, rename, and inline diagnostics.
None of the generated code is checked in; `npm run build` regenerates it.
The hand-written part is the validator (`src/bpmn-script-validator.ts`), which covers the structural rules a parser can't express, such as "a service task needs exactly one binding attribute" or "a variable reference must match a declared `var` type".
Langium already reports syntax errors and unresolved references, so the validator adds the BPMN-specific and type-system rules on top.
-The package also owns the base VS Code TextMate grammar (`syntaxes/bpmn-script.tmLanguage.json`, generated by `langium generate` and git-ignored).
+The package also owns the base VS Code TextMate grammar (`syntaxes/bpmn-script.tmLanguage.json`, generated by `langium generate`, adjusted by `textmate-postprocess.mjs`, and git-ignored).
`packages/extension` layers a hand-maintained injection grammar over it for embedded-language highlighting inside a `script` body; see [`packages/extension/README.md`](../extension/README.md).
## The language
-One `process` block per file.
Steps run top to bottom, so sequence flow is never written out, and control flow uses structured statements instead.
-Optional `var` declarations go in the process header, before the body, alongside the header's own settings: `versionTag`, which labels the deployed process definition, and `historyTimeToLive`, `candidateStarterUsers`, and `candidateStarterGroups`, which the engine reads before it starts an instance.
+Optional `var` declarations go in the process header, before the body, alongside the header's own settings: `versionTag`, which labels the deployed process definition, `historyTimeToLive`, `candidateStarterUsers` and `candidateStarterGroups`, whose entries are listed as candidate starters (Tasklist's process list; `BpmnDeployer.addAuthorizations` stores each entry as written), and `isStartableInTasklist`, an unquoted boolean that, set to `false`, hides the process from Tasklist's start list.
+`historyTimeToLive` is the number of days the engine keeps a finished instance's history, written `PD` or ``, and an absent value compiles to `P30D`, since `HistoryTimeToLiveParser.parseAndValidate` refuses a null value under the engine's default `enforceHistoryTimeToLive`; the cleanup itself runs only once its batch window is configured.
+Every header value is stored as written, never evaluated: `label`, `documentation` and `versionTag` are quoted strings, the tag at most 64 characters, and no header setting takes a `"${...}"` expression.
```bpmnscript
process invoice-approval {
@@ -34,40 +35,52 @@ process invoice-approval {
}
```
-Every targetable statement carries an explicit id, which is what `goto` and boundary events refer to.
-The BPMN `name` is derived from that id (`ReviewInvoice` becomes "Review Invoice") unless a `label` setting gives one instead: `user ReviewInvoice(label: "Review invoice")`.
-A `documentation` setting carries free-form text alongside it, spelled the same way: `user ReviewInvoice(documentation: "Escalate to the senior approver above 1000.")`.
+An id is ASCII only: a letter or `_`, then any run of letters, digits and `_`, with single hyphens allowed between such runs (the `ID` terminal in the grammar).
+A step, a call and a sub-process derive the BPMN `name` from that id (`ReviewInvoice` becomes "Review Invoice") unless a `label` setting gives one instead: `user ReviewInvoice(label: "Review invoice")`; a `start` and an `end` are named by `label` alone, and `on`, `throw`, `emit` and `await` show no name at all.
+A `documentation` setting carries free-form text alongside `label`, spelled the same way: `user ReviewInvoice(documentation: "Escalate to the senior approver above 1000.")`.
### Statements
-| Statement | BPMN element | Notes |
-| ------------------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
-| `start X` / `end X` | start event / end event | a trigger on either, see [Starting on an event](#starting-on-an-event) and [Ending every path](#ending-every-path) |
-| `user X(...)` | user task | assignment and form attributes, see below |
-| `service X(...)` | service task | exactly one binding attribute, see below |
-| `script X` + a fenced body | script task | fence tag picks the language, see below |
-| `step X(...)` | task | no binding of its own |
-| `send X(...)` | send task | binds like a service task, see below |
-| `receive X(...)` | receive task | waits for a message, or for the engine, see below |
-| `decide X(...)` | business rule task | binds to a decision table, or like a service task, see below |
-| `if (cond) (settings)` / `else if` / `else` | exclusive gateway | settings on the head only, see [Job settings on a gateway](#job-settings-on-a-gateway) |
-| `while (cond) (settings) { }` | exclusive gateway loop | see [Job settings on a gateway](#job-settings-on-a-gateway) |
-| `do { } while (cond) (settings)` | exclusive gateway loop | see [Job settings on a gateway](#job-settings-on-a-gateway) |
-| `parallel (settings) { { } { } }` | parallel gateway fork and join, or inclusive when a branch is conditioned | see [Conditioning a parallel branch](#conditioning-a-parallel-branch) and [Job settings on a gateway](#job-settings-on-a-gateway) |
-| `subprocess X(...) { }` | embedded sub-process | a nested container, see below |
-| `attempt X(...) { }` | transaction sub-process | the same container, for work that can be given up, see [Giving a block of work up](#giving-a-block-of-work-up) |
-| `call X(...) { }` | call activity | starts another process, see below |
-| `goto X` | sequence flow | targets a step in the same container |
-| `on { }` | event sub-process | see [The event layer](#the-event-layer) |
-| `on Host: { }` | boundary event | see [Attaching a handler to one step](#attaching-a-handler-to-one-step) |
-| `await (...)` | intermediate catch event | see [Awaiting an event inline](#awaiting-an-event-inline) |
-| `await (settings) { (...) { } (...) { } }` | event-based gateway, a catch event per branch, exclusive join | see [Waiting on several triggers at once](#waiting-on-several-triggers-at-once) and [Job settings on a gateway](#job-settings-on-a-gateway) |
-| `throw` / `emit ` | throw event | see [The event layer](#the-event-layer) |
-| `emit link("X")` / `await link("X")` | intermediate throw and catch event, with no flow between them | see [Link events](#link-events) |
+| Statement | What it means | BPMN element | Notes |
+| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| `process X { }` | the BPMN process, one per file | `bpmn:process` | header settings and `var` declarations go before the body, see [The language](#the-language) |
+| `var x: number` | declare a variable so its uses get type-checked | none | the importer declares `var x: any` for every variable the printed script reads and nothing else declares |
+| `start X` / `end X` | where the flow begins and ends | start event / end event | |
+| `start X (...)` | start when an event arrives | start event with a trigger | see [Starting on an event](#starting-on-an-event) |
+| `end X terminate` | stop every running path | terminate end event | see [Ending every path](#ending-every-path) |
+| `end X cancel` | give up the block of steps around this end | cancel end event | see [Giving a block of work up](#giving-a-block-of-work-up) |
+| `user X(...)` | a step a person performs | user task | assignment and form attributes, see below |
+| `form { x: number "Label" }` | a form on a `start` or a `user` step; each field fills a process variable when the form is submitted | form fields on the start event or user task | see [Forms](#forms) |
+| `service X(...)` | a step the system performs: in the engine, by an external worker that picks it up by topic, or as a built-in shell command or mail | service task; `topic` makes it the external type, `type` a built-in one | exactly one binding attribute, see below |
+| `script X` + a fenced body | an inline script, in JS, Groovy, Python, Ruby or FEEL | script task | fence tag picks the language, see below |
+| `step X(...)` | a step nothing in the engine automates | task | |
+| `send X(...)` | a step that sends something out | send task | binds like a service task, see below |
+| `receive X(...)` | wait here until a named message arrives, or until the engine is told to move on | receive task | see below |
+| `decide X(...)` | a step a decision table answers, or one bound like a service task | business rule task | see below |
+| `if (cond) (settings)` / `else if` / `else` | a decision: one branch runs | exclusive gateway | settings on the head only, see [Job settings on a gateway](#job-settings-on-a-gateway) |
+| `while (cond) (settings) { }` | a loop that checks its condition before each pass | exclusive gateway loop | see [Job settings on a gateway](#job-settings-on-a-gateway) |
+| `do { } while (cond) (settings)` | a loop that runs its body once before the first check | exclusive gateway loop | see [Job settings on a gateway](#job-settings-on-a-gateway) |
+| `parallel (settings) { { } { } }` | branches that run at the same time; a branch headed by `if` runs only when its condition holds, and an `else` branch only when no sibling's condition did | parallel gateway fork and join, or inclusive when a branch is conditioned | see [Conditioning a parallel branch](#conditioning-a-parallel-branch) and [Job settings on a gateway](#job-settings-on-a-gateway) |
+| `subprocess X(...) { }` | a group of steps as one unit | embedded sub-process | a nested container, see below |
+| `attempt X(...) { }` | a group of steps that can be given up as one | transaction sub-process | the same container, for work that can be given up, see [Giving a block of work up](#giving-a-block-of-work-up) |
+| `call X(...) { }` | start another process and wait for it | call activity | see below |
+| `goto X` | jump to a named step in the same container | sequence flow | see [What `goto` can reach](#what-goto-can-reach) |
+| `on { }` | catch an event anywhere in this body | event sub-process | see [The event layer](#the-event-layer) |
+| `on Host: { }` | catch an event only while `Host` runs | boundary event | see [Attaching a handler to one step](#attaching-a-handler-to-one-step) |
+| `await (...)` | stop here until the event arrives | intermediate catch event | see [Awaiting an event inline](#awaiting-an-event-inline) |
+| `await (settings) { (...) { } (...) { } }` | wait on several triggers, continue down whichever fires first | event-based gateway, a catch event per branch, exclusive join | see [Waiting on several triggers at once](#waiting-on-several-triggers-at-once) and [Job settings on a gateway](#job-settings-on-a-gateway) |
+| `throw` / `emit ` | raise an event, ending the path or continuing | throw event | see [The event layer](#the-event-layer) |
+| `emit link("X")` / `await link("X")` | jump to the catch of the same name, a diagram's off-page connector | intermediate throw and catch event, with no flow between them | see [Link events](#link-events) |
+| `for each x in c` | run a step once per item, or a set number of times | multi-instance marker | see [Repetition](#repetition) |
+| `asyncBefore: true` | engine settings: async, exclusive, priority, retries, or, with a `run` prefix, on each run of a repetition | `operaton:` attribute/element | see [Attribute keys per element](#attribute-keys-per-element) |
+| `input x = "..."` | data into a step's execution, and `output` back out | `operaton:inputOutput` | see [Input and output parameters](#input-and-output-parameters) |
+| `on create(class: "...")` | code on a step's lifecycle, not a caught event | execution / task listener | see [Listeners](#listeners) |
Every statement in that table that takes settings of its own also takes the engine settings and an execution listener, and most of them take input and output parameters as well.
-The ten that map to an activity also take a repetition clause, see [Repetition](#repetition).
+The ten that map to an activity (`user`, `service`, `script`, `step`, `send`, `receive`, `decide`, `subprocess`, `attempt`, and `call`) also take a repetition clause between the id and the parens, see [Repetition](#repetition).
A brace holds what has internal structure: the body of a `while`, `do`, `parallel`, `subprocess`, `attempt`, `on`, or multi-branch `await`, and the form fields, parameters, listeners, error mappings, and call mappings an element carries.
+The last four rows are members of an element rather than statements: the `for` clause is that repetition modifier, an engine setting joins the rest in the `( )`, a parameter and a listener go in the `{ }`, and not every element takes every one.
+The listener row reuses `on` instead of opening a new event sub-process; the body block a handler always carries and a listener never does is what tells the two apart ([ADR-0020](../../docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md)).
#### Attribute keys per element
@@ -76,7 +89,7 @@ Five engine execution settings are legal on every element that takes settings, `
| Element | Keys beyond the engine settings |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
-| `start` | `initiator` |
+| `start` | `initiator`, on the process's own start |
| `end`, `await`, `on`, `subprocess`, `attempt` | none |
| `user` | `assignee`, `formKey`, `formRef`, `binding`, `version`, `candidateGroups`, `candidateUsers`, `dueDate`, `followUpDate`, `priority` |
| `service` | `class`, `expression`, `delegate`, `topic`, `type`, `resultVariable`, `taskPriority` |
@@ -87,57 +100,28 @@ Five engine execution settings are legal on every element that takes settings, `
| `decide` | `class`, `expression`, `delegate`, `topic`, `type`, `decision`, `binding`, `version`, `mapDecisionResult`, `resultVariable`, `taskPriority` |
| `call` | `process`, `binding`, `version`, `businessKey`, `mapper`, `mapperDelegate` |
| `throw`, `emit` | `class`, `expression`, `delegate`, `topic` (on a `message` trigger only) |
-| process header | `label`, `documentation`, `versionTag`, `historyTimeToLive`, `candidateStarterUsers`, `candidateStarterGroups` |
+| process header | `label`, `documentation`, `versionTag`, `historyTimeToLive`, `candidateStarterUsers`, `candidateStarterGroups`, `isStartableInTasklist` |
The engine settings are `asyncBefore` and `asyncAfter`, which put a transaction boundary before or after the step, `exclusive`, which says whether the engine may run the step's jobs beside other jobs of the same instance, `jobPriority`, which orders those jobs in the queue, and `retryCycle`, the ISO cycle a failed job is retried on.
+`exclusive`, `jobPriority`, or `retryCycle` written without `asyncBefore` or `asyncAfter` beside it configures no job and draws a warning, since Operaton creates the job only from the async flags (BpmnParse.parseAsynchronousContinuation, DefaultFailedJobParseListener.parseActivity); a timer carrier is exempt, since the timer itself creates the job.
+A process's signal start, an awaited signal, and a signal race branch are exempt for `jobPriority` alone: the subscription job BpmnParse.parseSignalCatchEventDefinition declares for them takes its priority from the catcher and runs when the throwing side is async, while `exclusive` and `retryCycle` still reach that job only through the async flags.
The gateways that `if`, `while`, `do...while`, `parallel`, and a multi-branch `await` synthesize take the same five settings on the statement head, see [Job settings on a gateway](#job-settings-on-a-gateway).
`taskPriority` rides a `topic` binding alone, see [External task extras](#external-task-extras).
A repeated step also takes `runAsyncBefore`, `runAsyncAfter`, `runExclusive`, and `runRetryCycle`, written to the repetition itself and read by the engine onto each run rather than around the whole loop.
-There is no `runJobPriority`, since the engine reads a job priority off the step alone, see [Repetition](#repetition).
+There is no `runJobPriority`: `jobPriority` already prices each run's job once a step repeats, because `createActivityOnScope` reads it off the step regardless of what repeats around it; the whole-loop job takes the process's own priority instead, see [Repetition](#repetition).
+A user task's `assignee` is the user id: a bare word or a quoted string reaches the engine as written, and `"${who}"` reads a variable.
+`candidateUsers` and `candidateGroups` are read the same way.
+`dueDate` and `followUpDate` take a period starting with `P`, an ISO date-time such as `"2026-01-01T09:00:00"`, or a `"${...}"` expression, the shapes `DueDateBusinessCalendar.resolveDuedate` parses when the task is created.
+`priority` is an integer or an expression yielding one; `TaskDecorator.initializeTaskPriority` parses a constant with `Integer.parseInt` at task creation, so `"high"` deploys and then fails the instance.
A user task names its deployed form with `formKey` or with `formRef`, never both.
`formRef` also needs `binding: latest`, `binding: deployment`, or `version: ` beside it, the same version-pinning a `call` or a `decide` step carries, since the engine refuses to deploy a form reference it cannot resolve a version for.
-
-#### Job settings on a gateway
-
-An `if`, a `while`, a `do...while`, a `parallel`, and a multi-branch `await` each synthesize a gateway with no name to hang a setting on.
-Its five settings sit in a parens on the statement head instead, right where an ordinary element's settings sit.
-`if`, `parallel`, and a multi-branch `await` also synthesize a join beside the split, and the join's settings share the same parens under a `join`-prefixed spelling: `joinAsyncBefore`, `joinAsyncAfter`, `joinExclusive`, `joinJobPriority`, `joinRetryCycle`.
-An `if` chain lowers to one exclusive-gateway split no matter how many `else if` branches it carries, so the head parens govern the whole chain and an `else if` head takes none of its own.
-
-```bpmnscript
-process order-fulfillment {
- var amount: number
-
- start OrderPlaced
-
- if (amount > 10000) (asyncBefore: true, joinJobPriority: 5) {
- user AuditReview(assignee: "auditor")
- } else {
- user SkipAudit(assignee: "clerk")
- }
-
- parallel (exclusive: true, joinAsyncAfter: true) {
- {
- user PackItems
- }
- {
- user PrintLabel
- }
- }
-
- end OrderShipped
-}
-```
-
-A `while` or a `do...while` has one gateway, so a `join`-prefixed key on either is an error naming the plain spelling to write instead.
-A multi-branch `await` head refuses `asyncAfter`: `BpmnParse.parseEventBasedGateway` refuses the flag on the gateway it deploys, so the setting has to move onto whichever branch trigger should carry it.
-A `join`-prefixed setting on a statement whose branches all end their own path is a warning rather than an error, since the join it would set is never reached.
+`version` is a positive whole number, quoted or not, or an expression yielding one, on a `formRef`, a `call`, and a `decide` step alike: the engine parses it as an integer when the step runs (`BaseCallableElement.getVersion`, `TaskEntity.initializeFormRefFromTaskDefinition`), so `1.5` deploys and then fails the instance.
#### Forms
-A `start` and a `user` task each take one `form` block, `operaton:formData` on the wire, which Tasklist renders as one input per field and which fills one process variable per field when the form is submitted.
-A field takes the shape every element takes ([ADR-0029](../../docs/decisions/0029-one-bracket-shape-for-every-element.md)): `id: type "label" = default (settings) { members }`, the label and the default optional.
+A process's own `start` and a `user` task each take one `form` block, `operaton:formData` on the wire, which Tasklist renders as one input per field and which fills one process variable per field when the form is submitted.
+A field takes the shape every element takes ([ADR-0011](../../docs/decisions/0011-structured-grammar-one-bracket-shape.md)): `id: type "label" = default (settings) { members }`, the label and the default optional.
The parens hold what the engine validates a submission under, plus the date pattern; the braces hold what the field is built from, an `enum`'s values and its `property` lines.
`id` is the process variable the field fills, so a `var` of the same name has to declare the same type.
@@ -145,22 +129,28 @@ The type is `string`, `number`, `boolean`, `date`, or `enum`.
`number` is written as `long` on the wire, the engine's own name for it, and an `enum` field stores the id of the chosen value, so its variable is a string.
Each setting in the parens but `pattern` is one `operaton:constraint`, evaluated in the order written.
-`FormValidators.createValidator` fails the deployment on any constraint name the engine has not registered, so the seven below are the closed set the parens take.
+`FormValidators.createValidator` fails the deployment on any constraint name the engine has not registered, so the seven constraint names below, together with `pattern`, are the closed set the parens take.
-| Setting | What the engine checks | Fits | Value |
-| ---------------------------------- | ----------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ |
-| `required: true` | a value was submitted, or the variable or the default already holds one; empty text fails | any type | the literal `true` |
-| `readonly: true` | no value was submitted for the field | any type | the literal `true` |
-| `min: `, `max: ` | the submitted number is at least `n`, or below `n` (`MaxValidator.validate` is exclusive) | `number` | an integer literal or a quoted integer such as `"-5"` |
-| `minlength: `, `maxlength: ` | the submitted text has at least, or at most, `n` characters | `string` | an integer literal or a quoted integer |
-| `validator: "..."` | whatever the named class or expression decides | any type | a class name or a `"${...}"` expression, the shapes `class:` takes |
-| `pattern: "..."` | nothing; it is the format a submitted date is parsed with, `datePattern` on the wire | `date` | a non-empty quoted pattern such as `"dd/MM/yyyy"` |
+| Setting | What the engine checks | Fits | Value |
+| ---------------------------------- | ----------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------- |
+| `required: true` | a value was submitted, or the variable or the default already holds one; empty text fails | any type | the literal `true` |
+| `readonly: true` | no value was submitted for the field | any type | the literal `true` |
+| `min: `, `max: ` | the submitted number is at least `n`, or below `n` (`MaxValidator.validate` is exclusive) | `number` | an integer literal or a quoted integer such as `"-5"` |
+| `minlength: `, `maxlength: ` | the submitted text has at least, or at most, `n` characters | `string` | an integer literal or a quoted integer |
+| `validator: "..."` | whatever the named class or expression decides | any type | a non-empty class name or a `"${...}"` expression |
+| `pattern: "..."` | nothing; it is the format a submitted date is parsed with, `datePattern` on the wire | `date` | quoted `SimpleDateFormat` letters, quoted runs, and non-letters, such as `"dd/MM/yyyy"` |
A flag is on while written and off otherwise, so `required: false` is an error telling you to leave the setting out.
The bounds fit one type each: `AbstractNumericValidator.validate` throws on a submitted value that is not a number and `AbstractTextValueValidator.validate` on one that is not text, so a `min` on a `string` field would deploy and then fail every submission.
All four take an integer, since a `number` field is an Operaton `long`, whose validator parses the bound with `Long.parseLong`, and a length is a character count.
+`validator` cannot be empty, the one shape `FormValidators.createValidator` refuses to deploy, and `pattern` builds a `java.text.SimpleDateFormat`, which throws on a letter outside that set.
+
+A literal default is converted through the field's type on every render, exactly as a submitted value is (`FormFieldHandler.createFormField`).
+A `number` default has to be an integer, which `LongFormType.convertValue` reads with `Long.valueOf`; a `boolean` default has to be `true` or `false`, since `BooleanFormType.convertValue` reads anything else as `false` with no error at all; and a `date` default with no `pattern` setting is read against the engine's own default, `"dd/MM/yyyy"` (`ProcessEngineConfigurationImpl.initFormTypes`), so an ISO-shaped default such as `"2026-01-01"` is refused unless a `pattern` says otherwise.
+A bare name or a `"${...}"` expression is left to the engine, since it is evaluated fresh at every render.
An `enum` field lists its values in the braces, one `id "Label"` line each in the order the form offers them, the label optional.
+One offering no values at all draws a warning rather than an error, since the engine deploys the form and then rejects every value submitted for the field.
Its default, when it is a literal, has to name one of them: `FormFieldHandler.createFormField` converts the default through the type on every render of the form, so a default naming no value deploys and then fails to open.
A `property