README
¶
Atlas examples
Runnable BPMN models that double as showcases of what the atlas engine can do
and as deterministic test scenarios. Every request/response below was verified
against a live atlas server (0.1.0-dev).
Reading them as a user, not as a developer? The handbook's Beispiele chapter (
/handbuch.html#beispiele) describes every example here in both German and English — what it is for and how it is built — renders its diagram, and installs it into your own instance in one click. It also carries the per-worker commissioning runbooks (/handbuch.html#runbooks): what to do at Google, Microsoft or Atlassian before a task can run. This file stays the developer-facing reference: the verified runs, the element visits, the MCP calls.The two do not drift apart.
go test ./examplesregenerates the catalogue the chapter serves (api/web/examples-catalog.json) from the files here and fails when an example has no card, when a card names an example that does not exist, or when two examples would ship the same form id.atlas hub. Every installable example is also published in the atlas hub sample registry (
hub/), bundled by theme into a few applications, so an installation imports them from the Console without a binary upgrade. A new installable example needs a bundle inhub/bundles.json. Versions there are immutable: after changing an example, regenerate the catalogue as above and rungo run ./cmd/atlas hub lock, orgo test ./cmd/atlasfails.
| File | What it is |
|---|---|
pruefe-auftrag.bpmn |
The minimal runnable scenario: Start → Script Task "prüfe den Auftrag" → Ende. A single inline FEEL script releases an order at/below the 1000 € approval limit and flags larger ones. Self-completing, no external worker. |
pruefe-datensaetze.bpmn + pruefe-datensaetze.dmn + csv-upload-form.json + row-correction-form.json |
CSV batch validation, fully in-process (ADR-0084 + ADR-0086 + ADR-0087): the instance starts and parks at a "CSV hochladen" user task — the Quality Manager uploads the file in its form (the Tasks app submits the content as csvText). A script task holds the column layout (columnConfig); a CSV-import service task (io.atlas.csv-import) parses it into rows; then a multi-instance subprocess runs a business rule task per row against the DMN decision RowValid (email/group/licence). A valid row collects its verdict; an invalid one parks on a correction user task, and once corrected it re-validates (loop until valid). Self-completing; no ingestion endpoint or separate screen. |
order-fulfillment.bpmn |
A self-completing order-fulfillment process that exercises inline scripts and all three gateway kinds, and drives itself to an end event with no external workers attached. |
cart-total.bpmn |
A shopping-cart checkout that computes an order total (subtotal → rebate → VAT → shipping) entirely in inline FEEL, and routes on the computed sum. Self-completing. |
order-to-cash.bpmn |
The full order lifecycle: cart calculation → approval (≥ 100 €, user task) → parallel delivery & billing (service tasks). Parks on the worker-backed steps — a realistic, not-fully-automatic process. |
order-to-cash-app.html |
A self-contained single-page app that mirrors order-to-cash.bpmn: edit the cart, watch the sum compute live, approve, and clear the delivery/billing tasks. No server needed — open it in a browser. |
order-to-cash-live.bpmn |
The live sibling: same flow, but delivery/billing are userTasks (HTTP-completable) instead of serviceTasks, so a browser app can drive the real instance end to end. |
../api/web/order-to-cash-live.html |
The live app — served by the atlas server itself (/order-to-cash-live.html). It deploys the model, starts a real instance with your cart, and completes each task over the HTTP API. No business logic in the client; atlas runs the process. |
../api/web/order-to-cash-jobs.html |
The live app against the service-task model: it lists the parked jobs (GET /instances/{key}/jobs) and completes them (POST /jobs/{key}/complete) — the app acts as the job worker. |
galsync.bpmn |
Cross-forest Global Address List sync: a DirSync delta over the source forest, then a contact created, updated or removed in the target one, looping on a timer with the cookie carried in a variable. MIM ships this as a management agent; here it is a process, because GALSync is a policy rather than a protocol. Der Lookup „gibt es den Kontakt schon?" ist seit ADR-0166 (fünfte Ergänzung) derselbe AD-Worker-Typ, der ihn auch schreibt — vorher musste dafür der generische LDAP-Worker an denselben Forest gebunden werden. |
ad-gruppenzuweisung.bpmn |
Benutzer einer AD-Gruppe zuweisen (ADR-0166, fünfte Ergänzung): das Beispiel zur AD-Suche. Zwei atlas:adConnector-Tasks mit operation="search" suchen erst das Konto über seinen sAMAccountName, dann die Gruppe über ihren cn; Exklusiv-Gateways verzweigen auf =benutzer.found bzw. =gruppe.found, ein Zweig legt die fehlende Gruppe an, und add-group-member setzt die Mitgliedschaft. Der Lehrsatz ist, warum die Suche überhaupt nötig ist: eine Mitgliedschaft adressiert die Gruppe über ihren Distinguished Name, und ein DN ist eine Position im Baum — nichts, was eine Fachabteilung mitliefert. Das Ergebnis {found, count, dn, entries} trennt beides sauber: found ist die Verzweigung, dn die Adresse für den nächsten Task. Nichts gefunden ist eine Antwort und kein Incident (Pfad „Benutzer unbekannt"), maxEntries="1" macht einen zweiten Treffer zum Befund statt zum Zufall. Parkt auf jedem AD-Task, bis eine Worker-Instanz für den Worker-Typ ad läuft — mit ATLAS_AD_MOCK läuft das Modell komplett gegen ein Verzeichnis im Arbeitsspeicher (ADR-0181). |
entra-create-account.bpmn |
A PowerShell jobScript task that creates an EntraID account — the worker-backed counterpart: its token parks on the script job until a PowerShell script worker runs it. It signs in app-only with a certificate from the worker account's store, picked by subject so a renewal needs no change in atlas; the tenant is a server constant (server.ENTRA_TENANT_ID, set under Admin → Engine → Server constants), the client id and the certificate subject are application constants (application.ENTRA_CLIENT_ID, set on the application's Constants tab), each per installation (ADR-0453, ADR-0456). No secret passes through atlas — a script never receives the worker's environment (ADR-0047). |
entra-konten-rezertifizierung.bpmn |
Konten-Rezertifizierung (Entra ID) (ADR-0172, amended): das Gegenstück zum Anlegen — hier befragt ein Prozess das Verzeichnis. Ein atlas:entraConnector mit list-users holt die aktiven Konten eines Bereichs (der $filter ist FEEL, also listet der Prozess den Bereich auf, um den es gerade geht), ein Inline-FEEL-Skript gleicht sie gegen die UPN-Liste aus dem Personalsystem ab, und eine parallele Multi-Instance deaktiviert je verwaistes Konto. Der Lehrsatz steht als das im Diagramm, was nicht darin steht: nirgends wird geblättert. Eine Graph-Collection kommt seitenweise hinter einem @odata.nextLink, aber der Worker folgt den Seiten selbst — deshalb ist konten eine Liste und der Abgleich ein einzelner Ausdruck statt einer Schleife über einen Continuation-Token. Parkt auf beiden Entra-Tasks, bis eine Worker-Instanz mit --connector entra läuft (der Worker-Typ entra ist worker-only, damit das Tenant-Credential nie in die Engine gelangt). |
entra-team-onboarding.bpmn |
Team-Onboarding (Entra ID) (ADR-0172, amended): das dritte Entra-Beispiel — hier baut ein Prozess einen Arbeitsplatz auf. Eine gerade Kette von atlas:entraConnector-Tasks legt Konto (create-user → konto), Lizenz (assign-license), Projektgruppe (create-group → gruppe), Mitgliedschaft (add-group-member), Team (create-team) und Team-Mitgliedschaft (add-team-member) an. Der rote Faden ist die id-Kette: alles adressiert über =konto.id/=gruppe.id, und weil die id eines Teams die seiner M365-Gruppe ist, nehmen die Team-Schritte dieselbe =gruppe.id — kein zweites Team-Feld. Das Anfangspasswort steht im passwordProfile einer Startvariablen, nie im Diagramm. Parkt auf jedem Task, bis eine Worker-Instanz mit --connector entra läuft. |
entra-delta-sync.bpmn |
Delta-Sync (Entra ID) (ADR-0172, amended): das vierte Entra-Beispiel — hier liest ein Prozess nur, was sich geändert hat. Ein atlas:entraConnector mit delta-users liest ab dem Cursor letzterDeltaLink (leer beim ersten Lauf) die geänderten und entfernten Benutzer nach aenderungen — ein Objekt { value, deltaLink }. Der Lehrsatz ist, wo der Cursor lebt: nicht im Worker (der ist zustandslos, worker-only), sondern im Prozess. Der neue =aenderungen.deltaLink wird persistiert und beim nächsten Lauf als letzterDeltaLink wieder hereingereicht, genau wie galsync.bpmn den DirSync-Cookie durch eine Variable reicht. Gelöschte Objekte kommen in value mit @removed markiert mit — ein Leaver-Flow verliert sie so nicht. Parkt auf dem Entra-Task, bis eine Worker-Instanz mit --connector entra läuft. |
entra-verzeichnis-abgleich.bpmn |
Verzeichnis-Abgleich (Entra ID) (ADR-0332): das Delta-Beispiel zu Ende gedacht — hier führt atlas seine eigenen Konten und Gruppen aus dem Mandanten nach. Timer-Start R/PT1H (ADR-0051), dann GET /api/v1/directory-sync für die beiden Cursor und die Revision, delta-users und delta-groups ab diesen Cursorn, und POST /api/v1/directory-sync meldet beides zurück. Der erste Lauf schreibt nichts: der Meldetask trägt apply: false, entscheidet aber alles und legt einen Bericht ab — dieselbe Auswertung, nur der letzte Schritt fällt aus, und der Cursor bleibt stehen, solange nichts geschrieben wurde. Der Schalter heisst apply und nicht dryRun, weil ein fehlendes JSON-Feld der Nullwert ist und der „nichts schreiben“ heissen muss. Braucht einen Entra-Worker (lesend: User.Read.All, Group.Read.All) und einen REST-Worker „atlas“ mit einem API-Token des Scopes directory — der erreicht genau diese beiden Routen und kann insbesondere nicht deployen. |
bestandsaufnahme.bpmn |
Bestandsaufnahme (Entra ID) (ADR-0333): was am ersten Tag schon da war, als vorbestehend eintragen. delta-groups als vollständige Aufzählung, dann POST /api/v1/inventory-load — die Rechte bekommen den Ursprung legacy, weil atlas sie nicht vergeben hat und das auch sagen soll. Ohne Timer: eine Bestandsaufnahme wird angestossen, nicht eingeplant, und der Trockenlauf (apply: false) ist nur dann ein Schutz, wenn jemand den Bericht liest. Sie trägt ausschliesslich ein — ein Recht, das die Meldung nicht nennt, wird nie entzogen, weil eine Meldung die Teilantwort eines Systems ist und Schweigen kein Beleg. Braucht einen Entra-Worker (lesend) und einen REST-Worker „atlas“ mit einem API-Token des Scopes inventory. |
abgleich.bpmn |
Abgleich (Entra ID) (ADR-0334): täglich fragen, ob noch stimmt, was atlas über die Zugriffsrechte behauptet. Timer R/P1D, delta-groups, dann POST /api/v1/reconciliation. Hier bedeutet Schweigen etwas — anders als bei der Bestandsaufnahme: ein geführtes Recht, das die Lesung nicht nennt, gilt als fehlend. Deshalb nennt der Lauf in refs, was er vollständig gelesen hat, und ausserhalb davon wird nichts geschlossen. Der Prozess verändert nichts; übernehmen, entziehen oder streichen sind drei einzelne Handlungen eines Menschen unter Operations → Reconciliation. Gleiche Voraussetzungen wie die Bestandsaufnahme. |
austrittspruefung.bpmn |
Austrittsprüfung (Entra ID) (ADR-0334): derselbe Abgleich um die andere Achse — nicht „stimmt diese Gruppe noch“, sondern „hält diese Person noch irgendwo etwas“. Der Lauf nennt in subjects die Person, deren Bestand er vollständig gelesen hat; aus Gruppenlesungen liesse sich diese Frage nicht zusammensetzen. Das leere Ergebnis ist die Antwort: der Bericht sagt im Klartext, dass die Person draussen ist. Ein Subjekt ohne Konto gilt dabei nie als sauber — die Abwesenheit eines Kontos ist nicht die Abwesenheit von Zugriff. Start mit der Variable person (Entra-Objekt-Id), ohne Timer. |
exchange-austritt.bpmn |
Austritt: Postfach übergeben (Exchange) (ADR-0462): der Leaver-Teil im Postfach. get-mailbox liest das Postfach zuerst (postfach) — eine falsche Adresse scheitert, bevor etwas geändert ist —, ein inklusives Gateway nimmt Litigation Hold (enable-litigation-hold) und Weiterleitung (set-forwarding) unabhängig voneinander, danach Abwesenheitsnotiz (Text aus einem FEEL-Script), Umwandlung in ein geteiltes Postfach (convert-mailbox), Vollzugriff für die Übernahme (add-full-access), Ausblenden aus dem Adressbuch (update-mailbox) und eine parallele Multi-Instance über die Verteilerlisten, die ein OPATH-Filter auf den DistinguishedName findet (list-distribution-groups → remove-distribution-group-member). Zeigt drei Regeln: ein Parameter, der null ergibt, wird weggelassen (aufbewahrungTage); ein Apostroph im DN wird für OPATH verdoppelt; eine leere Liste ist für die Multi-Instance kein Sonderfall. Startvariablen mitarbeiter, uebernahme, austrittsdatum, aufbewahrung, weiterleiten. Parkt auf jedem Exchange-Task, bis eine Worker-Instanz mit --connector exchange läuft. |
rezertifizierung.bpmn |
Rezertifizierung (Entra ID) (ADR-0341): quartalsweise fragen, ob die Rechte, die Leute haben, noch gebraucht werden. delta-users samt manager, dann POST /api/v1/recertification — daraus wird je gehaltenem Recht eine Frage, zugeordnet an die Führungskraft der haltenden Person. Wer prüft, bestimmt das Modell und nicht atlas: eine Vorgesetztensuche ist eine Verzeichnisfrage, genau wie bei der Genehmigungsregel superior. Wen die Abbildung nicht nennt, dessen Zeile ist nicht zugeordnet und landet beim Eröffnenden — kein Fehler, sondern die Antwort „niemand“. Schweigen ist nie eine Entscheidung: unbeantwortete Zeilen bleiben unbeantwortet, auch wenn die Kampagne schliesst. Beantwortet wird einzeln unter Tasks → Access review; mehrere auf einmal ist bewusst nicht vorgesehen. |
befristung.bpmn |
Befristung (ADR-0344): täglich zurückgeben, was hätte enden sollen. Ein Produkt nennt mit maxDays eine Obergrenze; wer darunter bestellt, bekommt ein Recht mit Enddatum. Ablauf ist nicht Entzug — am Tag danach hat das Zielsystem die Mitgliedschaft noch, also bleibt das Recht geführt und wird als überfällig gemeldet. Zurückgegeben wird über die Bestellzeile: nur ein bestelltes Recht trägt je ein Ende, und deren Rückgabe entzieht nach der eingefrorenen Freigabe — stärker als der Weg, den Abgleich und Rezertifizierung nehmen müssen. Die Obergrenze gilt nie für ein gefundenes Recht: dessen Startdatum ist der Moment des Findens. Braucht einen REST-Worker „atlas“ mit einem Token der Rolle user. |
erinnerung/ |
Erinnerung (ADR-0343): wöchentlich jeder prüfenden Person sagen, was auf sie wartet. GET /api/v1/recertification, dann je Person eine Aufruf-Aktivität als Multi-Instance über distinct values(...) — eine Person mit vierzig Zeilen bekommt eine Mail und nicht vierzig. Der gerufene Prozess fragt GET /api/v1/pending-work?principal=… und schickt nur bei counts.total > 0. Atlas verschickt nichts; das Modell tut es, mit dem Mail-Task, den es längst gibt. Der Parameter ist dem Operator vorbehalten — ein Portal, in dem jeder die offenen Posten jedes anderen aufzählen kann, ist ein Organigramm mit Arbeitslasten daran. Braucht einen REST-Worker „atlas“ mit einem Token des Scopes reminders an einem Konto mit der Rolle operator, und einen Mail-Worker „mail“. |
unvereinbarkeit.bpmn |
Unvereinbarkeit (ADR-0342): wöchentlich melden, wer eine Kombination hält, die der Katalog verbietet. Erklärt wird sie als dritte Kantenart excludes — die einzige symmetrische, weshalb die Freigabe beide Richtungen aufschreibt: wer nur eine läse, fände die Hälfte der Verletzungen und meldete den Bestand als halb sauber. Eine Bestellung, die eine verbotene Kombination herstellen würde, wird abgelehnt, gegen Gehaltenes und gegen denselben Warenkorb. Rückwirkend gilt die Regel trotzdem — anders als eine Befristung, die Teil des Vergebenen ist. Ein Konflikt hat keinen Schuldigen: nichts entzieht automatisch, der Befund nennt beide Seiten, und gehandelt wird über Rückgabe oder Rezertifizierung. Braucht einen REST-Worker „atlas“ (Rolle operator) und einen Mail-Worker „mail“. |
mssql-eintrittsmeldung.bpmn |
Eintrittsmeldung (MS SQL) (ADR-0173): das Beispiel zum Datenbank-Worker. Ein atlas:mssqlConnector mit operation="query-one" schlägt die Person über ihr Kürzel nach, ein Exklusiv-Gateway verzweigt auf = person != null, und ein zweiter mit operation="execute" setzt sie aktiv. Der Lehrsatz ist, warum die Anweisung literal ist: Das statement-Feld hat als einziges bewusst keinen fx-Schalter, denn eine aus Prozessdaten zusammengesetzte Anweisung wäre eine Injection, die keinen Anführungszeichenfehler mehr bräuchte. Werte kommen deshalb über parametersVariable herein — eine Liste bindet der Reihe nach (@p1), ein Objekt über die Namen (@id), und Namensbindung kann nur SQL Server: MariaDB und PostgreSQL lehnen ein Objekt ab, statt es in eine Reihenfolge zu bringen, die niemand geschrieben hat. Deshalb sind die drei Produkte drei Worker-Typen und kein Auswahlfeld. query-one liefert null statt einer leeren Liste und scheitert bei zwei Treffern — „die Zeile zu dieser Person", die zweimal passt, ist eine falsche Annahme und kein Ergebnis. Parkt auf jedem SQL-Task, bis eine Worker-Instanz für den Worker-Typ mssql läuft; einen In-Process-Fallback gibt es hier als einzigem Worker-Typ nicht, weil die Verbindungszeichenfolge die Zugangsdatei selbst ist und die Engine sie nie halten soll. Mit ATLAS_MSSQL_MOCK läuft das Modell komplett gegen hinterlegte Antworten im Arbeitsspeicher des Workers. |
sharepoint-fallakte/ |
Fallakte (SharePoint) (ADR-0141, ergänzt 2026-10-22): die Listen- und Bibliotheksoperationen an einem zusammenhängenden Fall. sharepoint-fallakte.bpmn liest einen Antrag aus der Liste Antraege (get-item, Startvariable antragId), legt ihm einen Ordner an (create-folder), füllt eine Briefvorlage aus der Bibliothek Vorlagen aus (download-file + FEEL replace), legt den Brief mit Eigenschaften ab — aber nur, wenn get-file ihn nicht schon findet, denn ein Upload ersetzt —, führt den Antrag nach (update-item) und schreibt ein Protokoll (create-item). sharepoint-aufraeumen.bpmn findet erledigte Anträge mit einem OData-Filter Seite für Seite (list-items, pageToken) und löscht je Antrag erst den Ordner (delete-file), dann den Eintrag (delete-item), beides als parallele Multi-Instance. Zeigt typisierte Spaltenwerte (ein Wahrheitswert wird Ja/Nein, number(…) eine Zahl), die Null-Falle von replace und welche Schritte gefahrlos wiederholbar sind (alle ausser create-item). Parkt auf jedem SharePoint-Task, bis ein Worker intranet konfiguriert ist; beide Abläufe laufen in connector/sharepoint/example_test.go gegen eine nachgebildete Site durch. |
blick-schlagzeilen.bpmn |
Schlagzeilen aus einem RSS-Feed (ADR-0118 + ADR-0190): der Ausbau des Web-Scraping-Workers in einem Diagramm — derselbe Task, der HTML per CSS-Selektor ausliest, holt mit format="rss" den Feed von Blick.ch und schreibt strukturierte Einträge (title, link, description, published) statt Strings nach schlagzeilen; maxItems="15" ist die Schranke ohne Folge-Skript. Der Lehrsatz ist, wer das Format wählt: das Modell beim Deployment, nicht die Antwort zur Laufzeit — ein Server, der seinen Feed als application/xml ausliefert, ändert damit nicht die Gestalt einer Prozessvariablen. Ein Inline-FEEL-Skript behält die Einträge, deren Titel das Suchwort trägt (mit for e in … statt mit einem Filter, denn ein Filter wertet jeden freien Namen gegen den Eintrag aus und macht suchwort still zu null), ein Exklusiv-Gateway routet auf count(treffer) > 0 und parkt sonst nie. Läuft ohne Konfiguration und ohne Credential: webscrape ist ein Worker-Typ, den atlas standardmässig auslagert (ADR-0164) und für den es die Worker-Instanz selbst startet. |
hypothekarzinsen-migrosbank.bpmn |
Hypothekarzinsen täglich erfassen (ADR-0118 + ADR-0231): das Gegenstück zu blick-schlagzeilen.bpmn — dort liefert die Quelle einen fertigen RSS-Feed, hier gibt es nur eine HTML-Seite, und trotzdem kommt pro Tabellenzeile ein Objekt heraus. Der Selektor wählt die Zeile, drei <atlas:scrapeField>-Kinder wählen darin die Zellen (laufzeit, vorzugszins, standardzins); ohne Felder wäre es weiterhin die flache String-Liste aus ADR-0118. Der Lehrsatz ist, wie man die richtige Tabelle trifft: sechs Tabellen der Seite tragen dieselbe CSS-Klasse, also identifiziert der Selektor sie über ihren Inhalt (table:has(th:contains('Online-Hypothek'))) und nicht über ihre Position — nth-of-type(1) träfe hier alle sechs, weil jede die erste ihres eigenen Elternelements ist. Ein Timer-Start (0 6 * * *, ADR-0051) holt die Seite jeden Morgen, ein Exklusiv-Gateway trennt "heute keine Änderung" von "der Selektor trifft nichts mehr", ein Inline-FEEL-Skript macht aus "1.130 %" die Zahl 1.13 und hängt das Abrufdatum an. Der Ablage-Schritt ins Google-Sheet ist bewusst noch ein Mockup-Task (ADR-0120): der Prozess läuft ohne Credential durch, und zu ersetzen ist genau dieser eine Task. |
bonitaet-mockup.bpmn |
Bonitätsprüfung mit simuliertem Umsystem (ADR-0120): ein serviceTask im Mockup-Modus (atlas:mockupConnector) — die Engine simuliert die Umsystem-Abfrage selbst, mit zufälliger Antwortzeit (PT1S–PT4S, als Timer), einer geskripteten Antwort (resultExpression → bonitaet) und einer Ausfallquote (failRate + errorCode), die einen BPMN-Fehler wirft, den eine Error-Boundary in einen manuellen Pfad leitet. Ein Exklusiv-Gateway verzweigt auf die simulierte Antwort. Zeigt, wie sich ein Prozess end-to-end durchspielen lässt, bevor das echte (per REST angebundene) Umsystem existiert — kein externer Worker nötig. Setzt man failRate auf 0, entfällt der Ausfall; min == max macht die Dauer fix. |
jira-zugangsantrag/ |
Zugangsantrag mit Freigabe (Jira) (ADR-0201): der ausgehende Weg — ein Antrag legt einen Jira-Vorgang an (create-issue → ticket), ein User-Task in atlas holt die menschliche Freigabe, und je nach Entscheidung schaltet transition-issue den Vorgang weiter (mit dem Kommentar im selben Aufruf) oder add-comment hält die Ablehnung fest, ohne ihn zu schliessen. Drei Dinge sind Absicht: der Default des Gateways ist die Ablehnung (ein Gateway, dessen Bedingung nicht greift, darf keinen Zugang gewähren), der Übergangsname steht im Startformular statt im Modell (der Worker löst ihn über den Namen auf, den Jira auf dem Knopf zeigt, und der ist pro Workflow verschieden), und die Begründungsfelder sind Pflicht, weil FEEL-Verkettung null propagiert — eine fehlende Variable macht den ganzen Ausdruck leer, nicht nur ihren Teil. jira-verbindungstest.bpmn daneben ist der Dreizeiler, mit dem man die Einrichtung prüft, bevor man das grosse Modell startet. |
jira-ticket-eingang/ |
Ticket-Eingang (Jira) (ADR-0214 + ADR-0223): die Gegenrichtung — hier hört atlas auf Jira. Ein Nachrichten-Start wartet auf jira.ticket.created, und eine Ereignisüberwachung unter Console → Workers → Events veröffentlicht jeden Vorgang, den ihre JQL findet, unter diesem Namen; atlas pollt, es muss also nichts aus dem Internet den Server erreichen. Die Instanz startet ohne Formular — issueKey, projectKey, summary, reporter und der ganze Vorgang kommen aus dem Ereignis. Der Lehrsatz ist die Kette dahinter: reporter ist ein Anzeigename, Jira übergibt einen Vorgang aber an eine accountId, also steht search-users dazwischen und assign-issue dahinter — mit =konten[1].accountId, denn FEEL-Listen sind 1-basiert. Der Default des Gateways ist der Zweig ohne Zuweisung, weil ein leeres Ergebnis dreierlei heissen kann („niemand passt", „das Profil ist nicht auffindbar", „dem Worker fehlt Benutzer und Gruppen durchsuchen") und Jira die drei nicht unterscheidet. |
google-sheets-antragseingang/ |
Antragseingang (Google Sheets) (ADR-0235 + ADR-0234): beide Richtungen an einer Tabelle. google-sheets-verbindungstest.bpmn fährt die ganze Schleife — anlegen, Kopfzeile schreiben, Zeilen anhängen, mit header="true" als Liste von Objekten zurücklesen — und zeigt dabei die drei Formen, die values annimmt: Zeilen, Objekte (über columns, denn die Schlüsselreihenfolge eines Objekts ist keine Spaltenreihenfolge und wird nicht geraten) und einen einzelnen Wert. antragseingang.bpmn ist die Gegenrichtung: ein Zeilen-Watch veröffentlicht jede neue Zeile als Nachricht, die Instanz startet ohne Formular — die Spalten sind schon Variablen —, und sie schreibt das Ergebnis in ihre eigene Zeile zurück, was rowNumber möglich macht. Zwei Dinge sind Absicht: die Bedingung rechnet number(Betrag), weil Sheets Zellen als Text liefert und ein Vergleich zwischen Text und Zahl in FEEL null ist, und die Statusspalte ist zugleich das Muster gegen doppelte Zeilen — Zustellung ist „mindestens einmal", und eine Tabelle hat keinen Idempotenzschlüssel. |
pruefung/ |
Prüfung mit Zeitlimit und DMN-Bewertung (ADR-0028 + ADR-0040 + DMN): ein Assessment als Prozess. Ein User-Task trägt das Prüfungsformular (vier Single-Choice-Fragen); ein unterbrechendes Boundary-Timer-Event (PT45M) setzt das Zeitlimit — läuft es ab, endet der Prozess in „Zeit abgelaufen". Ein Skript zählt die Punkte, ein Business-Rule-Task bewertet sie über die DMN-Tabelle Notenschluessel (Bestehensgrenze + Notenstufen — fachlich pflegbar, nicht im Code), und ein Exklusiv-Gateway verzweigt auf bestanden. Zeigt, was atlas einer reinen Prüfungssoftware voraushat: hartes Zeitlimit, auditierbarer DMN-Trace, ergebnisabhängige Folgeschritte. |
reisebuchung/ |
Abhängige Formulare auf zwei Ebenen (ADR-0028 + DMN): ein Reisebuchungs-Prozess, der (A) im Formular eine kaskadierende Zielauswahl und ein bedingtes Feld zeigt (form-js valuesExpression / conditional.hide), und (B) im Prozess je nach Daten unterschiedliche Formulare erzwingt — eine DMN-Tabelle entscheidet aus Reiseart + Alter, ob Visum- und/oder Einverständnis-Formular Pflicht sind, Inclusive-Gateways routen darauf. Zeigt sauber, welche „Abhängigkeit" ins Formular gehört und welche in den Prozess. Die Schritte nach den Reisedaten sind ein öffentlicher Wizard (ADR-0449): Wer den öffentlichen Link öffnet, wird ohne Konto von Maske zu Maske geführt. Zwei Kunden-Apps unter api/web/ (Fall B: der öffentliche Wizard; fall-a/: ein User-Task, im Client geblättert). |
arbeitsplatz/ |
Arbeitsplatz-Ausstattung — ein interner Wizard (ADR-0449): das Gegenstück zur öffentlichen Reisebuchung. Die Personalabteilung meldet einen Eintritt; die erste Maske ist der vorgesetzten Person zugewiesen (assignee="=vorgesetzter"), denn Personen mit der Rolle user starten keine Prozesse. Ab dieser Maske führt die Tasks-App sie von Maske zu Maske: Ein Exklusiv-Gateway wählt nach ihrer Antwort die Laptop-, die Desktop- oder gleich die Software-Maske, ein Script schätzt die Kosten, die Zusammenfassung zeigt sie in einem deaktivierten Feld. Die Output-Mappings des Subprozesses geben weiter, was die IT für die Bereitstellung danach braucht. Drei Durchläufe laufen als Test gegen genau diese Dateien. |
bewerbermanagement/ |
Eine kleine Applikation, nicht ein Muster (ADR-0128 + ADR-0077 + ADR-0120): Bewerbermanagement als Applikation aus zwei Prozessen, drei Formularen und einer DMN-Entscheidung — gemeinsam publiziert und versioniert. Der Hauptprozess bw-bewerbung lässt eine DMN-Tabelle entscheiden, wer eingeladen wird und in wie vielen Runden (Ausgabe runden ist eine Liste), iteriert mit einer seriellen Multi-Instance-Call-Activity über diese Liste und ruft je Runde den eigenständigen Teilprozess bw-interview auf — mit explizitem Datenvertrag (propagateAll…="false" + ioMapping), den beide Enden des Teilprozesses erfüllen. Dazu: ein nicht unterbrechender Boundary-Timer als Erinnerung, eine Error-Boundary auf einem Mockup-Task mit Ausfallquote (das ausgefallene Umsystem wird zur Aufgabe für einen Menschen) und ein Event-Subprozess für den Rückzug, der jederzeit eintreffen kann. Alle Umsysteme sind Mockups (ADR-0120), also läuft die Applikation end-to-end ohne eine einzige echte Anbindung. Das durchgängige Beispiel des Handbuch-Kapitels „Werkstatt". |
account-bestellung/ |
Account-Bestellung (Entra ID) (ADR-0128 + ADR-0029 + ADR-0172): eine Applikation mit öffentlichem Startformular, DMN-gesteuertem Kontotyp-Mapping (KontotypMapping: Kürzel, accountEnabled, Kategorie, usageLocation), menschlicher Freigabe und Entra-Provisionierung — plus ein einbettbares HTML+JS-Widget, das die Bestellung von einer beliebigen Website aus startet. Der Lehrsatz ist das fail-closed Gatter: ein Exklusiv-Gateway prüft, ob der UPN dem Testmuster entspricht, und der Default ist das Ende ohne jeden Entra-Aufruf — ein Gateway, dessen Bedingung wegen eines Tippfehlers nicht greift, darf kein Konto anlegen. Schreibt scharf gegen contoso.com, aber nur gegen klar benannte Test-Objekte. |
entra-onboarding-selfservice/ |
Onboarding-Self-Service (Entra ID) (ADR-0172): der Fachbereich bestellt den Arbeitsplatz selbst, die IT gibt frei. Startformular → Script-Task (displayName/mailNickname vorschlagen) → dasselbe fail-closed Testgatter wie oben → User-Task „freigeben" (der Admin setzt das Initialpasswort, es wird nie mitbestellt) → create-user → zwei optionale Schritte (Lizenz, Gruppe), die je an einem Gateway auf „ist das Feld gesetzt" hängen und auf dasselbe Ende zulaufen — zweimal dasselbe Muster statt eines Zweigwaldes. |
benutzerverwaltung/ |
atlas verwaltet seine eigenen Benutzer (ADR-0122 + ADR-0123): Aufnahme, Zugriffs-Review und Offboarding als drei Prozesse mit sechs Formularen — der erste Insasse des geschützten System-Projekts. Die Abgrenzung ist der Punkt: die native Users-Konsole bleibt der direkte, synchrone Weg, diese Prozesse sind die Vordertür für regierte Änderungen (Antrag, Freigabe, Audit, Mail), und beide schreiben in denselben User-Store. Der Schreibpfad ist ein enger userConnector, der nur für Prozesse des System-Projekts läuft. Kopien liegen unter api/systemprocesses/, sind ins Binary eingebettet und werden beim Serverstart idempotent deployed — bei Änderungen beide Stellen angleichen. |
identitaet-lebenszyklus/ |
Eine Prozessinstanz je Identität (ADR-0028 + ADR-0040): kann eine Instanz je Mensch den kompletten Lebenszyklus führen, inklusive aller bestellten Business Services mit ihrem Zustand? Ja — mit zwei dauerhaft nebenläufigen Token im Wurzel-Scope: Spur A ist der Lebenszyklus (jeder Zustand ein Receive-Task, der Token steht in dem Kästchen, das den Zustand benennt), Spur B ein ereignisbasiertes Gateway in einer Endlosschleife als Ereignis-Hub. Warum nicht ein Ereignis-Subprozess je Bestellung: der schreibt still nichts (Daten-Assoziationen werden nur im Wurzel-Scope verdrahtet), und ein nicht unterbrechender Boundary-Event feuert genau einmal. Die gemessene Grenze liegt nicht im Schreibpfad, sondern im Lesepfad — Details in TESTBERICHT.md. Daneben ein Mengentest-Seeder, der zugleich der Aufräum-Hebel ist. |
m365-dienste/ |
Microsoft-365-Dienste – ein Shop mit Governance (ADR-0172, ADR-0462, ADR-0463, ADR-0436): zwei Kataloge mit neun Produkten über die Worker-Typen Entra, Exchange und Teams Phone (Lizenz mit Postfach, Teams-Telefonie, Team, Funktionspostfach, Verteiler, Postfachzugriff, Sitzungszimmer, Anrufwarteschlange, Telefonzentrale), 15 Prozesse, 5 Entscheidungen und 21 Formulare; Lizenz, Team, Funktionspostfach und Postfachzugriff genehmigt die vorgesetzte Person der Person, für die bestellt wird (eigener Genehmigungsprozess mit get-user-manager des Entra-Workers). Governance steckt im Modell: Die Entscheidung M365Namensregel bildet jeden Namen und lehnt reservierte und zu lange mit Begründung ab, jedes Exchange-Objekt trägt eine Herkunftskennzeichnung, über die Rückgabe und Änderungen es wiederfinden, und eine wöchentliche Prüfung meldet, was ausserhalb von Kennzeichnung und Namensregel entstanden ist. Zwei öffentliche Formulare (Partnerzugang, Verteiler-Anmeldung) legen nichts ohne menschliche Prüfung an. Worker ucc_entra, ucc_exchange, ucc_teams; Installation mit installieren.sh bzw. installieren.ps1. |
ad-dienste/ |
Active-Directory-Dienste – Gruppen, Berechtigungen und Konten als Shop (ADR-0166, ADR-0425, ADR-0429, ADR-0436): ein Katalog mit neun Produkten über den Worker-Typ AD (Worker ad_verzeichnis): Sicherheitsgruppe mit verantwortlicher Person (managedBy), drei Berechtigungen als Gruppenmitgliedschaft mit einem gemeinsamen Lebenszyklus und der Genehmigung durch die Person, die die Gruppe verantwortet, Admin-, Service-, Test- und externe Konten mit Entsperren und Passwort zurücksetzen, und die Versetzung in eine andere Organisationseinheit. 8 Prozesse, 3 Entscheidungen (Namensregel, Berechtigungen, Organisationseinheiten) und 11 Formulare. Befristung über maxDays und accountExpires, Herkunftskennzeichnung im Attribut info, kein Freitext in einem LDAP-Filter. Installation mit installieren.sh bzw. installieren.ps1; was der Worker im Verzeichnis darf, steht im README. |
lizenzmanagement/ |
Lizenzmanagement – Microsoft-365-Lizenzverträge mit Kontingent, Verrechnung und Abo-Karenz (ADR-0370, ADR-0425, ADR-0429, ADR-0528): Lizenzverträge pro Kunde und Projekt mit PSP-Element und Menge pro SKU, genehmigt von der Tenant-verantwortlichen Person; die Kontingentprüfung, die die Lizenzbestellung von m365-dienste als Call Activity aufruft (mit LIZENZ_VERTRAG_PRUEFEN=ein); täglicher Abgleich mit dem Tenant (list-subscribed-skus) und Nachkauf im EA, monatliche Verrechnung pro PSP-Element, Vorbereitung des EA-Jahrestags und Rückführung in der Abo-Karenz nach einer Entzugsregel des Kunden. Den Stand halten lange laufende Instanzen (Vertragsbuch, Tenant-Kontingent), die Buchungen als Nachrichten nacheinander annehmen. 13 Prozesse, 9 Entscheidungen, 22 Formulare. Installation mit installieren.sh bzw. installieren.ps1. |
informationstransfer/ |
Informationstransfer – Ressourcen eines Kontos auf ein anderes übertragen (BIT «Transfer Resources»; ADR-0314, ADR-0425, ADR-0473, ADR-0528): bei Internalisierung, Externalisierung, Amts- oder Abteilungswechsel gehen Primäradresse und UPN, Postfachinhalt, Kontingent, Telefonnummer, manuelle Gruppen (AD und Entra ID), Kürzel und – nur bei Internalisierung – das Cloud-Konto (Hard Match über onPremisesImmutableId) vom Quell- auf das Zielkonto über. Die Entscheidung InfotransferOptionen prüft die Kombination und nennt der bestellenden Person die Gründe; Identity Management genehmigt, jede Löschung wird unmittelbar davor freigegeben, der Entra-Transfer ist ausgeschaltet, bis ENTRA_TRANSFER=ein gesetzt ist. Exchange Server oder Exchange Online (POSTFACH_SYSTEM); was eine Plattform nicht kann, ist eine Aufgabe. 9 Prozesse, 1 Entscheidung, 9 Formulare. Installation mit installieren.sh bzw. installieren.ps1. |
ad-objektmodell/ |
ArchiMate-Export nach atlas (ADR-0230 + ADR-0232): kein Prozess, sondern eine Untersuchung — warum ein sauber gepflegtes ArchiMate-3.1-Modell nicht als Informationsmodell importierbar ist, und wie der Export am Ende doch in atlas landete. Atlas liest genau zwei Dokumente (eigenes JSON, UML XMI 2.5.1); die Formaterkennung wählt bei ArchiMate den XMI-Pfad und findet nichts, weil dessen Elemente unter <elements> stehen. Der Unterschied ist aber nicht nur syntaktisch: ArchiMate kennt für ein BusinessObject keine typisierten Attribute — und genau die braucht ein Informationsmodell, damit der itemSubjectRef eines Datenobjekts etwas zum Auflösen hat. Mit beiden Konvertierungsskripten und beiden Zwischenständen. |
mahnwesen/ |
Offene Rechnung mahnen — das Beispiel zur Eskalation, der einzigen Ereignisart, die einen Fall aus einem Teilprozess nach oben meldet, ohne ihn abzubrechen. Am Teilprozess „Zahlung überwachen" (Timer → Mahnung → Timer → Eskalation) hängen zwei Boundaries, und ihr Unterschied ist die ganze Fachlichkeit: die Nachricht zahlung.eingegangen unterbricht (der Mahnlauf ist gegenstandslos), die Eskalation unterbricht nicht (cancelActivity="false" — der Lauf endet ordentlich und der Inhaber wird gefragt). Der Teilprozess ist keine Kosmetik: eine Eskalation wird an der umschliessenden Aktivität gefangen, ohne sie gäbe es keine. Die Fristen sind Startvariablen, weil <timeDuration> FEEL nimmt (ADR-0055) — mit PT2S läuft derselbe Prozess in Sekunden statt Wochen. Der Mahn-Task ist ein Mockup, das Modell läuft also ohne Mailserver. |
preisaenderung/ |
Preisänderung erreicht jede offene Offerte — das Beispiel zum Signal, und die Gegenprobe zu mahnwesen/: eine Nachricht trifft genau eine Instanz (die mit passendem Korrelationsschlüssel), ein Signal trifft alle, die gerade warten, und weiss nicht, wie viele das sind — weshalb es bewusst keinen Schlüssel hat. preisliste.bpmn wirft, offerte.bpmn fängt an einer nicht unterbrechenden Boundary; unterbrechend würde jede Preisänderung jede offene Offerte vernichten. Das Ergebnis landet in angebotNeu und überschreibt angebot nicht: was dem Kunden genannt wurde, ist ein Fakt und keine Variable. Verifiziert: drei wartende Offerten, eine Instanz des Werfers, drei nachgerechnete Angebote. |
reisestorno/ |
Reise buchen und sauber rückabwickeln — das Beispiel zur Kompensation: ein Fehler springt aus einer Aktivität heraus, die gerade schiefging, eine Kompensation macht Aktivitäten rückgängig, die längst erfolgreich abgeschlossen sind. Flug und Hotel tragen je eine Kompensations-Boundary, an der über eine <association> ein Task mit isForCompensation="true" hängt — kein Sequenzfluss, sonst liefe er immer. Zwei Wurf-Ereignisse wickeln rückwärts ab (erst Hotel, dann Flug), weil Stornofristen sich unterscheiden. Der Beweis steht in den Variablen: mit zahlungOk: false stehen hotelstorno und flugstorno auf der Instanz, mit true fehlen beide. Alle Umsysteme sind Mockups. |
umzug/ |
Umzug organisieren — atlas für eine Privatperson, und das einzige Modell im Repo mit einem Termin statt einer Dauer: <timeDate> nimmt einen FEEL-Ausdruck (ADR-0055), der mit echter Datumsarithmetik rechnet (Umzugstermin − Kündigungsfrist). Drei Stränge laufen parallel (kündigen, Umzugsfirma, Adressen), ein paralleles Join wartet auf alle drei — ein exklusives wäre der häufigste Modellierfehler überhaupt. Zwei Stolpersteine stecken in dem einen Timer-Ausdruck, beide beim Bauen tatsächlich passiert und beide mit demselben Incident (result is not a valid date): das date(...) um die Variable (eine Prozessvariable wird als JSON persistiert und kommt als String zurück) und ein Offset statt einer Zonen-ID ("09:00:00Z" geht, "09:00:00@Europe/Zurich" nicht). |
Looking for a model that parks on human tasks so you can watch the task lifecycle? See
../postman/order-approval.bpmnand the Postman onboarding kit. The two examples are complementary: that one shows the human-in-the-loop side, this one shows the fully automatic side.
order-fulfillment.bpmn — the self-completing showcase
A small order-to-fulfillment flow, modelled as a realistic happy path with data-based branching and concurrency:
Order received
→ Register order (seed case data)
→ Score risk (compute from prior data)
→ Order value? ──ᴇxᴄʟᴜsɪᴠᴇ──┐
≥ 1000 otherwise
│ └→ Auto-approve small order → (Order fast-tracked)
┌──ᴘᴀʀᴀʟʟᴇʟ (fork)──┐
Reserve stock Prepare invoice
└────ᴘᴀʀᴀʟʟᴇʟ (join)────┘ ← waits for BOTH
→ Which notifications? ──ɪɴᴄʟᴜsɪᴠᴇ (fork)──┐
priority EU region otherwise
Priority EU compliance Standard
handling check notification
└────────ɪɴᴄʟᴜsɪᴠᴇ (join)────────┘ ← waits for the branches that opened
→ Order fulfilled
Why it is interesting
The whole model runs to completion the moment you start an instance, because:
- Every activity is an inline FEEL script task (
<zeebe:script expression="…" resultVariable="…"/>). Atlas evaluates these inside the engine — unlike aserviceTask, an<atlas:jobScript>, or abusinessRuleTask, an inline script needs no external worker, so the token never parks. (AserviceTaskwith no worker attached would sit active forever — correct engine behavior, but not what you want in a smoke test.) - The process seeds its own data.
atlas_create_instance/POST …/instanceshere is called with no input variables, so the first script task writes the case data (order) as a FEEL context, and every downstream decision reads from it. That makes each run deterministic — the same path every time — which is exactly what you want from a regression scenario.
Engine capabilities it demonstrates
| Capability | Where in the model |
|---|---|
| Inline FEEL script task (in-engine, no worker) | every scriptTask |
| Variables written by one step, read by the next | score_risk reads order.amount/order.items |
| FEEL context literals + member access | register_order writes {amount, region, priority, items} |
| FEEL arithmetic | score_risk (= order.amount / 100 + order.items) |
| Data-based exclusive gateway + default flow | value_gw (= order.amount > 1000, default flow_express) |
| Parallel split & synchronizing join | fanout / fanin |
| Inclusive split (multi-branch) & synchronizing join | notify_split / notify_join |
| Default-flow suppression when a condition matches | flow_notify_standard is not taken |
| Multiple end events | order_fulfilled, order_fast_tracked |
Modelling conventions (Camunda best practices)
The model follows the naming and structure guidance from the Camunda BPMN modelling best practices:
- Tasks are named verb + object — Register order, Reserve stock.
- Gateways are named as a question — Order value?, Which notifications? —
and their outgoing flows are labelled with the answers (
≥ 1000,otherwise,priority,EU region). - Events are named object + past participle — Order received, Order fulfilled.
- Splitting and joining gateways are paired (
fanout/fanin,notify_split/notify_join); routing is never done by branching a task's outgoing flows directly. - A single blank start event; explicit, differently-named end states.
The model ships hand-authored
<bpmndi:BPMNDiagram>(it did not, until the handbook started rendering every example on its card): one straight main axis, the express bypass and the two parallel and three inclusive branches each in a lane of their own, and every gateway exit labelled with its answer. Atlas can auto-generate a layout on deploy, and does for a model without one — but a generated layout is runnable rather than readable, which is not the standard for a model whose whole job is to be read (seeAGENTS.md, "Authoring BPMN models").
Run it
Via the atlas MCP server (AI agents)
atlas_deploy { xml: <contents of order-fulfillment.bpmn> } → returns a definition `key`
atlas_create_instance { key: <that key> } → runs to idle (here: completed)
atlas_process_runtime { key: <that key> } → per-element visit counts
Via curl
BASE=http://localhost:8080
KEY=$(curl -s -X POST $BASE/api/v1/deployments \
-H 'Content-Type: application/xml' \
--data-binary @order-fulfillment.bpmn | python3 -c 'import sys,json;print(json.load(sys.stdin)["key"])')
curl -s -X POST $BASE/api/v1/processes/$KEY/instances -H 'Content-Type: application/json' -d '{}'
curl -s "$BASE/api/v1/processes/$KEY/runtime"
Expected result — use it as a test
Because the run is deterministic, the per-element visit counts are a precise
assertion target. After one instance the definition should report
instances: 0, tokens: 0 (the instance completed) with:
| Element | Type | Visits |
|---|---|---|
order_received |
StartEvent | 1 |
register_order |
ScriptTask | 1 |
score_risk |
ScriptTask | 1 |
value_gw |
ExclusiveGateway | 1 |
fanout |
ParallelGateway | 1 |
reserve_stock |
ScriptTask | 1 |
prepare_invoice |
ScriptTask | 1 |
fanin |
ParallelGateway | 2 (one visit per incoming branch; join fires once) |
notify_split |
InclusiveGateway | 1 |
notify_priority |
ScriptTask | 1 |
notify_eu |
ScriptTask | 1 |
notify_standard |
ScriptTask | 0 (default suppressed — a condition matched) |
notify_join |
InclusiveGateway | 2 (one visit per opened branch) |
order_fulfilled |
EndEvent | 1 |
auto_approve / order_fast_tracked |
ScriptTask / EndEvent | 0 (express path not taken) |
Those three highlighted numbers are the interesting invariants:
fanin = 2proves the parallel join synchronised both branches.notify_join = 2proves the inclusive join waited for exactly the branches that opened (two), not all three and not just one.notify_standard = 0proves the inclusive default was correctly suppressed because at least one condition held.
To exercise the other exclusive branch, change the seeded amount in
register_order to a value ≤ 1000; the instance then takes
Auto-approve small order → Order fast-tracked instead.
cart-total.bpmn — shopping-cart sum calculation
A checkout that turns a cart of line items into a payable order total, computed step by step in inline FEEL — the engine does real money arithmetic, no worker involved:
Warenkorb abgeschickt
→ Warenkorb uebernehmen cart = positions (or a default cart if none given)
→ Artikelanzahl zaehlen itemCount = sum(for p in cart return p.qty)
→ Zwischensumme berechnen subtotal = decimal(sum(for p in cart
return p.price * p.qty), 2)
→ Rabattsatz ermitteln discountRate = «rule table» (customerType × subtotal)
→ Rabatt berechnen discount = decimal(subtotal * discountRate, 2)
→ Zwischensumme ≥ 50 €? ──┐
ja nein
Gratisversand Standardversand
shipping = 0 shipping = 4.90
└──────────┬──────────┘
→ Nettobetrag net = decimal(subtotal − discount, 2)
→ MwSt (19%) tax = decimal(net * 0.19, 2)
→ Gesamtsumme total = decimal(net + tax + shipping, 2)
→ Summe > 100 €? ── ja → Freigabe erforderlich
nein → Bestellung bestaetigt
Dynamic input via start variables
The cart and the customer type are declared as start variables
(atlas:startForm), so in the Modeler's Deploy form (or the Variables panel) you
enter your own cart and every figure recomputes:
| Start variable | Type | Default |
|---|---|---|
positions |
json | the 3-item demo cart |
customerType |
string | Business (Business | Private) |
Start-variable defaults are applied by the Deploy form, not by a bare
instance-create, so the first task Warenkorb uebernehmen guards positions with
a default cart — meaning an instance started with no input still self-completes
against the demo cart, which is what makes this usable as a deterministic test.
The discount — a FEEL rule table
Rabattsatz ermitteln is an inline FEEL decision table (the same logic a DMN
businessRuleTask would model, but evaluated in-engine so it needs no external
decision service):
| Customer type | Order value | Rate |
|---|---|---|
| Business | ≥ 1000 € | 15 % |
| Business | otherwise | 10 % |
| Private | ≥ 200 € | 5 % |
| Private | otherwise | 0 % |
Want a real DMN task instead? Reference a decision with
<zeebe:calledDecision decisionId="…">and register that decision as a DMN reference in atlas (Modeler → business rule task → decision picker) first — a plain deploy of a business rule task whose decision isn't registered is refused (no DMN model provides …).
The calculation — default cart, Business customer
sum(for p in cart return p.price * p.qty) is the cart sum; decimal(x, 2) rounds
to cents (FEEL uses exact decimals, so the figures are reproducible):
| Step | FEEL | Value |
|---|---|---|
| Zwischensumme | decimal(sum(p.price·p.qty), 2) |
58.30 € |
| Rabattsatz | rule table: Business, < 1000 € | 10 % |
| Rabatt | decimal(subtotal · discountRate, 2) |
− 5.83 € |
| Nettobetrag | subtotal − discount |
52.47 € |
| MwSt 19 % | decimal(net · 0.19, 2) |
+ 9.97 € |
| Versand (frei ≥ 50 €) | — | 0.00 € |
| Gesamtsumme | decimal(net + tax + shipping, 2) |
62.44 € |
Both gateways branch on the computed sum, so the visited end event alone proves
the arithmetic. Expected visits after one default-cart instance
(instances: 0, tokens: 0):
| Element | Type | Visits |
|---|---|---|
cart_submitted |
StartEvent | 1 |
take_cart · count_items · subtotal |
ScriptTask | 1 each |
discount_rate · discount_amt |
ScriptTask | 1 each |
ship_gw |
ExclusiveGateway | 1 |
free_ship |
ScriptTask | 1 |
std_ship |
ScriptTask | 0 (subtotal ≥ 50 → free shipping) |
ship_join |
ExclusiveGateway | 1 |
net · tax · grand_total |
ScriptTask | 1 each |
approval_gw |
ExclusiveGateway | 1 |
confirmed |
EndEvent | 1 |
needs_approval |
EndEvent | 0 (total ≤ 100 → no approval) |
To land on the other branches, start with your own positions: a cart under 50 €
takes Standardversand (4.90 € shipping), and one whose total exceeds 100 € reaches
Freigabe erforderlich. Setting customerType to Private switches the discount
row.
Scope: this models the cart → order-sum calculation. It does not cover the downstream order-to-cash lifecycle (delivery, invoicing, settlement) — those are worker-backed steps that park until completed.
order-to-cash.bpmn — the full order lifecycle
Where cart-total.bpmn stops at the order sum, this model carries the order all
the way to cash, and shows what a realistic (not fully automatic) process looks
like:
Bestellung eingegangen
→ Warenkorb → Zwischensumme → Rabattsatz → Rabatt → Gesamtsumme (inline FEEL, auto)
→ Summe > 100 €? ── ja → [User-Task] Bestellung freigeben ──┐
nein ───────────────────────────────────┤
→ ⟨parallel⟩ │
Liefern: [Service] Ware kommissionieren → Ware versenden
Verrechnen: [Service] Rechnung erstellen → Zahlung verbuchen
⟨join⟩
→ Auftrag abgeschlossen
The calculation part self-completes, then the instance parks: on the
Bestellung freigeben user task (only when the sum exceeds 100 €), and on the two
parallel service tasks Ware kommissionieren (job kommissionierung) and
Rechnung erstellen (job fakturierung). Those tokens wait until a user
completes the task / a job worker runs — the honest behaviour of a live business
process. Verified on the live server: with the default cart the instance parks
with one token on pick and one on invoice.
positions and customerType are the same start variables as cart-total.
order-to-cash-app.html — an interactive single-page app
A self-contained SPA (vanilla HTML/JS, no build, no server) that mirrors the BPMN and embodies its forms:
- the cart is the start form — add/remove positions, pick the customer type;
- the sum recomputes live in the exact same FEEL logic (rule-table discount, VAT, free shipping ≥ 50 €), with the matched discount rule highlighted;
- Bestellung freigeben is the user-task form (shown only above 100 €);
- Liefern and Verrechnen are two lanes whose service tasks you clear one by one — standing in for the job workers — until the order closes.
Open it:
- Download and open the file in any browser, or
- browse the repo file and use a raw-HTML preview
(
https://htmlpreview.github.io/?<raw file URL>) if the repo is public, or - serve
examples/via GitHub Pages for a permanent link (ask and I'll add a Pages workflow).
Live mode — api/web/order-to-cash-live.html
order-to-cash-app.html simulates the process in the browser. The live app
drives a real atlas instance instead, and shows how little client code that
takes — the business logic stays in the engine. Two facts shape it:
- User tasks, not service tasks. Only
userTasks can be completed over HTTP (POST /api/v1/tasks/{key}/complete); service-task jobs need a gRPC worker. So the live model,order-to-cash-live.bpmn, makes delivery and billing user tasks the app can clear. - Same-origin, because the server sends no CORS headers. The app therefore
lives in
api/web/and is served by atlas itself athttps://<your-atlas>/order-to-cash-live.html, sofetch("/api/v1/…")is same-origin. (Opening the file from another origin would be blocked by the browser unless a proxy adds CORS. The base-URL field at the bottom lets you repoint it if you have one.)
The entire integration is three calls — POST /deployments, POST /processes/{key}/instances, POST /tasks/{key}/complete — and a live request log
on the page makes them visible. Because it ships under api/web/, it is embedded
in the binary: rebuild and redeploy the atlas server, then open
/order-to-cash-live.html.
Service-task variant — api/web/order-to-cash-jobs.html
The live app above uses user tasks because only they are listed by GET /tasks.
The jobs variant drives the service-task model instead, using the read side
of the operator job affordance:
GET /api/v1/instances/{key}/jobslists every activatable job the instance is parked on — of any type, not just user tasks (this is the endpoint that makes the operator complete usable from a client), andPOST /api/v1/jobs/{key}/completefinishes each one, the same call for a service-task job and the approval user-task job alike.
So the app becomes the job worker: list the parked jobs, complete them, watch the parallel delivery/billing branches close. Same tiny client, different mechanism.
Clean up
The instance completes, so it does not block anything, but to remove the definition from the engine and disk:
atlas_delete_process { key: <that key> } # or: curl -X DELETE $BASE/api/v1/processes/$KEY
Directories
¶
| Path | Synopsis |
|---|---|
|
workertypes
|
|
|
echo
command
Command echo is the smallest Worker Type that can be installed on atlas as an artifact: a separate executable built against workersdk alone, which atlas launches, verifies and supervises (ADR-0535, migration step 3).
|
Command echo is the smallest Worker Type that can be installed on atlas as an artifact: a separate executable built against workersdk alone, which atlas launches, verifies and supervises (ADR-0535, migration step 3). |