diff --git a/Abacus.Run.slnx b/Abacus.Run.slnx
index 01ac204..56ed2ec 100644
--- a/Abacus.Run.slnx
+++ b/Abacus.Run.slnx
@@ -3,11 +3,13 @@
+
+
diff --git a/README.md b/README.md
index ded4847..8ef54b2 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,12 @@ builder.Services
`OrderWorkflow` must implement `IWorkflowDefinition` or `IWorkflowDefinition`. Use `WorkflowBuildContext.Node(...)` to attach host executors and declare approval gates.
+There are two ways to author a workflow, and both produce an `IWorkflowDefinition` on the same
+runtime: **in C#**, as above, or **as a JSON document** ([Authoring with the DSL](#authoring-with-the-dsl)
+below). Code computes, documents compose; a host can run both at once. Complete references:
+[Authoring workflows in C#](docs/workflow-authoring-guide.md) and
+[Authoring workflows with the Abacus DSL](docs/dsl-authoring-guide.md).
+
### Declaring executor gates
A node attached with no gate block runs autonomously. Pass a gate block to require a human decision, either always or under a predicate:
@@ -422,6 +428,69 @@ is bound. Both are verified against real servers in `tests/Abacus.Run.BrokerTest
Full walkthrough: [Events, history, and SSE](docs/wiki.md#events-history-and-sse) and
[Event broker](docs/wiki.md#event-broker-and-event-driven-workflows).
+## Authoring with the DSL
+
+A workflow can be a **JSON document** instead of C#: validated against a published schema,
+interpreted at build time, and registered exactly like a compiled definition. Same graph, same
+executors, same gates, same events — the DSL is a second front end onto the runtime, not a fork.
+
+The governing rule is that **the DSL composes but never computes**. A document declares which nodes
+exist, how they connect, and when an edge is taken; it carries no behaviour. Every unit of work is a
+capability the host already shipped, so the answer to "the DSL cannot express this" is always
+*register a node*, never *embed a script*.
+
+```json
+{
+ "dsl": "abacus.workflow/1.0",
+ "name": "order-settlement",
+ "version": "1.0.0",
+ "context": { "type": "object", "required": ["orderId", "amount"] },
+ "start": "price",
+ "output": ["settle"],
+ "nodes": [
+ { "id": "price", "kind": "transform", "set": { "total": "$ctx.amount * 1.2" } },
+ { "id": "settle", "kind": "http",
+ "method": "POST",
+ "url": "https://ledger.internal/v1/settlements",
+ "allowedHosts": ["ledger.internal"],
+ "body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}",
+ "gate": { "mode": "conditional", "when": "$.total > 25000", "reason": "RegulatedSettlement" } }
+ ],
+ "edges": [ { "from": "price", "to": "settle" } ]
+}
+```
+
+```csharp
+builder.Services.AddWorkflowHost(configuration)
+ .AddWorkflow() // compiled, unchanged
+ .UseDsl()
+ .AddDslNode(new RiskScoringNodeFactory()) // extend the vocabulary
+ .AddDslWorkflowsFromDirectory("workflows/"); // compose it
+
+app.MapDslApi();
+```
+
+Node kinds cover `transform`, `http`, `llm`, `delay`, `approval`, `publish`, `wait-event`, `fan-in`
+and `custom`. Expressions are a closed, total language — absence is a value rather than an exception,
+conditions are strictly boolean, and arithmetic is decimal. Validation runs in two phases, and every
+diagnostic carries a JSON Pointer:
+
+```
+DSL0412 error /nodes/3/gate/when Unknown function 'lookupCustomer'. Did you mean 'coalesce'?
+DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. Did you mean 'settle'?
+```
+
+An invalid document fails startup. A published `(name, version)` is immutable, enforced by a
+canonical hash of the document. Routes: `GET /dsl/schema`, `/dsl/nodes`, `/dsl/functions`,
+`/dsl/documents`, and `POST /dsl/validate`. The ordinary catalog reports which front end authored
+each version: `GET /workflows/{name}` carries `source` — `dsl` or `compiled` — and, for a document,
+its `documentHash`.
+
+Full walkthrough: [Authoring with the DSL](docs/wiki.md#authoring-with-the-dsl) and the complete
+reference, [Authoring workflows with the Abacus DSL](docs/dsl-authoring-guide.md) — whose mirror for
+the compiled path is [Authoring workflows in C#](docs/workflow-authoring-guide.md).
+Schema: [docs/schema/abacus-workflow-dsl-1.0.json](docs/schema/abacus-workflow-dsl-1.0.json).
+
## Audit records
Events record what the runtime did. An audit record answers the separate question of why a run's
@@ -549,8 +618,10 @@ at startup.
| `src/Abacus.Run` | Headless framework: workflow runtime, dispatch, executors, middleware, in-memory store defaults, and HTTP API endpoints |
| `src/Abacus.Adapters.Cache.Redis` | Redis adapters: Streams event bus, workflow event broker, cross-replica control channel |
| `src/Abacus.Adapters.Messaging.RabbitMQ` | RabbitMQ adapter: topic-exchange workflow event broker |
+| `src/Abacus.Run.Dsl` | Declarative authoring: JSON Schema validation, the AbEx expression language, and the document interpreter |
| `src/Abacus.Run.Service` | Deployable host: control-plane UI, SQL Server stores, the SQLite audit-record store, startup wiring, and the example workflow |
| `tests/Abacus.Run.UnitTests` | Unit coverage for runtime behavior; references the library only |
+| `tests/Abacus.Run.DslTests` | Expression, validation and interpreter coverage for the DSL |
| `tests/Abacus.Run.IntegrationTests` | HTTP, control-plane, and architecture-boundary coverage against the real host |
| `tests/Abacus.Run.ChaosTests` | Failure and lifecycle resilience coverage |
| `tests/Abacus.Run.BrokerTests` | The distributed brokers against real Redis and RabbitMQ, via Testcontainers |
diff --git a/docs/dsl-authoring-guide.md b/docs/dsl-authoring-guide.md
new file mode 100644
index 0000000..2178f56
--- /dev/null
+++ b/docs/dsl-authoring-guide.md
@@ -0,0 +1,1389 @@
+# Authoring workflows with the Abacus DSL
+
+A complete reference for building a workflow definition as a JSON document, covering every framework
+capability and how — or whether — a document reaches it.
+
+Mirror of [Authoring workflows in C#](workflow-authoring-guide.md): the same runtime, the same
+catalog, the same gates and events — reached from JSON instead of code. The
+[wiki](wiki.md#two-ways-to-author-a-workflow) introduces both and is the operational manual behind
+them. The schema is at
+[`docs/schema/abacus-workflow-dsl-1.0.json`](schema/abacus-workflow-dsl-1.0.json) and served live
+from `GET /dsl/schema`.
+
+---
+
+## Contents
+
+- [1. The model](#1-the-model)
+- [2. The envelope](#2-the-envelope)
+- [3. Document anatomy](#3-document-anatomy)
+- [4. AbEx — the expression language](#4-abex--the-expression-language)
+- [5. Templates](#5-templates)
+- [6. Node kinds](#6-node-kinds)
+- [7. Edges](#7-edges)
+- [8. Approval gates](#8-approval-gates)
+- [9. Notifications and events](#9-notifications-and-events)
+- [10. Domain events: publishing, waiting, triggering](#10-domain-events-publishing-waiting-triggering)
+- [11. Failure, retry and limits](#11-failure-retry-and-limits)
+- [12. Custom nodes](#12-custom-nodes)
+- [13. Registration and hosting](#13-registration-and-hosting)
+- [14. Validation and diagnostics](#14-validation-and-diagnostics)
+- [15. Versions, identity and drift](#15-versions-identity-and-drift)
+- [16. What a document inherits for free](#16-what-a-document-inherits-for-free)
+- [17. Framework coverage map](#17-framework-coverage-map)
+- [18. Declared but not yet enforced](#18-declared-but-not-yet-enforced)
+- [19. Not expressible](#19-not-expressible)
+- [Appendix A — worked variations](#appendix-a--worked-variations)
+- [Appendix B — full field reference](#appendix-b--full-field-reference)
+
+---
+
+## 1. The model
+
+> **The governing rule: the DSL composes, it never computes.**
+>
+> A document declares *which* nodes exist, *how* they connect, and *when* an edge is taken. It never
+> carries behaviour. Every unit of work is a capability the host already shipped — a built-in node
+> kind, or a custom node registered by name.
+
+Three consequences follow, and they explain most of the design:
+
+1. **There is no `delegate` kind and never will be.** Arbitrary code is precisely what a document
+ must not carry. When the DSL cannot express something, the answer is *register a node*.
+2. **A document's ceiling is the host's node catalog**, not the JSON syntax. Extending the DSL is an
+ engineering task (ship a factory), not an authoring one.
+3. **A document is safe to accept from outside the build.** It cannot execute, reach the filesystem,
+ open a socket the host has not allow-listed, or loop unboundedly.
+
+A DSL document registers as an ordinary `IWorkflowDefinition`. It appears in the same catalog, starts
+through the same route, checkpoints through the same store, and is controlled by the same endpoints
+as a compiled workflow. Nothing downstream of registration knows the difference.
+
+---
+
+## 2. The envelope
+
+Every DSL node sends and receives one message type. That is what makes every edge type-check by
+construction, and what lets a checkpoint serialize without a bespoke converter.
+
+```json
+{
+ "ctx": { "orderId": "ORD-1", "amount": 100 },
+ "data": { "net": 100, "vat": 20, "total": 120 },
+ "meta": { "node": "price", "superstep": 2, "attempt": 1 }
+}
+```
+
+| Part | What it is |
+| --- | --- |
+| `ctx` | The **start context**, frozen. Copied through every node unchanged, so an expression at any depth can read it. A compiled node closes over C# scope; a document has none, so the envelope carries one. |
+| `data` | The **current value**. What a node reads, and what it replaces. |
+| `meta` | Provenance the interpreter maintains. Read-only. |
+
+Two nodes bracket every DSL graph and are not declared in the document:
+
+- **`$entry`** converts the start context into the first envelope. Without it nothing would run: the
+ runner sends the deserialized context typed as `JsonElement`, and the engine routes by type.
+- **`$exit`** unwraps the envelope to produce the workflow result — **the result is `data`, not the
+ envelope**. The context is machinery, not an answer. If `data` is not an object it is wrapped as
+ `{ "value": … }` so the result shape stays predictable.
+
+Their ids begin with `$`, which a declared node id cannot, so they can never collide.
+
+---
+
+## 3. Document anatomy
+
+```json
+{
+ "dsl": "abacus.workflow/1.0",
+ "name": "order-settlement",
+ "version": "1.2.0",
+ "description": "Prices an order, escalates large ones, settles.",
+
+ "context": { "type": "object", "required": ["orderId"] },
+ "start": "price",
+ "output": ["settle"],
+
+ "nodes": [ … ],
+ "edges": [ … ],
+
+ "triggers": [ … ],
+ "notifications": { … },
+ "onFailure": [ … ],
+ "audit": { … },
+ "limits": { … }
+}
+```
+
+| Field | Required | Purpose |
+| --- | --- | --- |
+| `dsl` | ✔ | Media identifier selecting schema and interpreter. Currently `abacus.workflow/1.0`. |
+| `name` | ✔ | Registry key. Lowercase kebab, `^[a-z][a-z0-9-]{0,63}$`. |
+| `version` | ✔ | SemVer. Instances pin it; a published version is immutable. |
+| `description` | | Shown in the catalog and used as the audit record description. |
+| `context` | | JSON Schema the start payload must satisfy. Enforced on every start request. |
+| `start` | ✔ | The node the run begins at. |
+| `output` | | Nodes whose `data` becomes the result. Defaults to every terminal node. |
+| `nodes` | ✔ | 1–500 nodes. |
+| `edges` | | 0–2000 edges. |
+| `triggers` | | Topics that start an instance. |
+| `notifications` | | Emission level, per-node overrides, SSE on/off. |
+| `onFailure` | | Failure classification rules. |
+| `audit` | | Declares an audit record shape. |
+| `limits` | | `maxAttempts`, `maxLifetimeHours`. |
+
+`dsl` is versioned deliberately. A future `1.1` adds optional fields and stays readable by a `1.0`
+interpreter; a `2.0` does not, and is refused by major version rather than failing on some field it
+does not recognise.
+
+### The context schema
+
+This is the DSL's answer to a compiled workflow's `TContext`. It is a full JSON Schema, and a start
+request that fails it is rejected with **400** and per-field errors before any instance row is
+created:
+
+```json
+"context": {
+ "type": "object",
+ "required": ["orderId", "amount"],
+ "properties": {
+ "orderId": { "type": "string", "minLength": 1 },
+ "amount": { "type": "number", "minimum": 0 },
+ "currency": { "type": "string", "enum": ["GBP", "USD", "EUR"] }
+ }
+}
+```
+
+Omit it and any JSON object is accepted.
+
+---
+
+## 4. AbEx — the expression language
+
+Conditions, guards, correlation keys and projections need *some* computation. The grammar is closed
+on purpose: **total** (no expression over any document can throw), **pure** (no I/O, no state), and
+**statically checkable** (every function resolved at validation time).
+
+### Roots
+
+| Root | Binds to | Example |
+| --- | --- | --- |
+| `$` | The current `data` | `$.total`, `$.lines[0].sku` |
+| `$ctx` | The frozen start context | `$ctx.orderId` |
+| `$run` | Run identity | `$run.instanceId`, `$run.tenantId`, `$run.workflow`, `$run.version`, `$run.attempt`, `$run.superstep`, `$run.now` |
+
+Every path starts with one of these three. There is deliberately **no `$node.`**: the engine is
+message-passing, a prior node's output is not ambiently available, and a root that pretended
+otherwise would be a lie the interpreter could not keep. Carry values forward in `data` — that is
+what a `transform` node is for.
+
+### Grammar
+
+```
+expr := or
+or := and ( "||" and )*
+and := cmp ( "&&" cmp )*
+cmp := add ( ("=="|"!="|"<"|"<="|">"|">=") add )? -- non-associative
+add := mul ( ("+"|"-") mul )*
+mul := unary ( ("*"|"/"|"%") unary )*
+unary := ("!"|"-") unary | primary
+primary := literal | path | call | "(" expr ")"
+path := ("$"|"$ctx"|"$run") ( "." ident | "[" integer "]" )*
+literal := number | 'single-quoted' | "double-quoted" | true | false | null
+```
+
+Precedence, loosest to tightest: `||`, `&&`, comparison, `+ -`, `* / %`, unary `!` `-`.
+
+Comparison is **non-associative**: `a < b < c` is refused at validation rather than silently
+comparing a boolean to a number. Write `a < b && b < c`.
+
+String literals prefer single quotes, because a document is already inside JSON: `"$.status == 'settled'"`
+needs no escaping, `"\"settled\""` does.
+
+### Functions
+
+The complete list. An unknown name is a **validation error** with a nearest-match suggestion, never a
+runtime surprise.
+
+| Function | Arity | Result |
+| --- | --- | --- |
+| `len(x)` | 1 | Characters of a string, elements of an array, properties of an object; `0` for anything else |
+| `has(path)` | 1 | Whether the path resolved to anything at all. A JSON `null` counts as present |
+| `lower(s)` / `upper(s)` | 1 | Case folding, invariant culture; absent for a non-string |
+| `contains(s, sub)` | 2 | Ordinal substring test |
+| `startsWith(s, p)` | 2 | Ordinal prefix test |
+| `endsWith(s, p)` | 2 | Ordinal suffix test |
+| `matches(s, pattern)` | 2 | Regex test. **The pattern must be a string literal**, and matching times out at 200 ms |
+| `coalesce(a, b, …)` | 1+ | First argument that is neither absent nor null |
+| `number(x)` | 1 | Number, or a parseable string; absent otherwise |
+| `string(x)` | 1 | Rendered form; absent stays absent |
+| `bool(x)` | 1 | Boolean, or `"true"`/`"false"`; absent otherwise |
+
+`matches` requires a literal pattern for a reason: a pattern assembled at run time cannot be reviewed
+by reading the document, and an unbounded pattern is the one genuinely dangerous construct in the
+grammar. The timeout means a pathological pattern is a non-match, never a stalled dispatcher.
+
+### Semantics
+
+These are the rules worth learning before they surprise you.
+
+**Absence is a value.** A path that does not resolve yields *absent*. It never throws.
+
+**Absence makes every comparison false — including `!=`.**
+
+```
+$.missing == 1 → false
+$.missing != 1 → false ← not true
+has($.missing) → false ← this is how you ask
+```
+
+A document asking whether a field it never set differs from a value must not be told "yes".
+
+**Conditions are strictly boolean.** Only `true` is true.
+
+```
+"when": "$.flag" → true only if flag is boolean true
+"when": "$.total" → false, even for 429.50
+"when": "$.name" → false, even for "abc"
+"when": "0" → false
+"when": "''" → false
+```
+
+There is no truthiness ladder to remember. Compare explicitly: `$.total > 0`, `len($.name) > 0`.
+
+**Comparison is JSON-typed.** Number-to-number is numeric, string-to-string is ordinal, anything
+cross-type is `false`. No coercion ladder — `$.count == "3"` is false.
+
+**Arithmetic is decimal, and numbers only.**
+
+```
+0.1 + 0.2 → 0.3 ← these documents price orders
+1 / 0 → absent ← not an error
+'a' + 'b' → absent ← + does not concatenate; that is what templates are for
+```
+
+**Short-circuiting works**, which makes the guard idiom cheap and safe:
+
+```
+"when": "has($.order) && $.order.total > 25000"
+```
+
+### Determinism
+
+> `$run.now` is **forbidden in edge conditions and gate predicates**, and permitted everywhere else.
+
+`BuildAsync` runs once per attempt, and a resumed instance must retrace the routing its checkpoint
+recorded. A condition that read the clock could take a different branch on resume — silent,
+intermittent, close to undebuggable. The validator refuses it statically (`DSL0413`).
+
+Need time-based routing? Compute it once in a `transform` and compare against that:
+
+```json
+{ "id": "stamp", "kind": "transform", "set": { "startedAt": "$run.now" } },
+{ "from": "stamp", "to": "expired", "when": "$.startedAt < '2026-01-01'" }
+```
+
+---
+
+## 5. Templates
+
+A `{{ … }}` placeholder inside a string evaluates a **full AbEx expression** and renders it as text.
+Templates appear in `http` URLs, headers and bodies, and in `llm` prompts.
+
+```json
+"url": "https://ledger.internal/v1/orders/{{ $ctx.orderId }}",
+"body": "{\"amount\":{{ $.total }},\"ref\":\"{{ upper($ctx.orderId) }}\"}"
+```
+
+- An **absent** placeholder renders as empty string — a template never fails a run over a missing
+ field.
+- An **unterminated** placeholder is emitted verbatim rather than truncating the rest of the string.
+- Numbers render without trailing zeros (`1.50` → `1.5`), booleans as `true`/`false`, objects and
+ arrays as JSON.
+
+**Templates and expressions are different surfaces.** A field is one or the other, never both:
+
+| Convention | Fields |
+| --- | --- |
+| Bare AbEx expression | `when`, `select`, `set` values, `payload` values, `correlationKey`, `contextFrom`, `notify.payload` values, `audit.key` |
+| `{{ }}` template | `url`, `headers`, `body`, `prompt`, `system` |
+
+---
+
+## 6. Node kinds
+
+Every node shares these fields:
+
+```json
+{
+ "id": "settle",
+ "kind": "http",
+ "description": "Posts the settlement to the ledger.",
+ "gate": { … },
+ "notify": { "name": "settled", "payload": { "ref": "$.body.reference" } }
+}
+```
+
+`id` is the identity everything else keys off — gate policy, node state, per-node notification
+overrides, the graph endpoint. **Renaming a node in a published version orphans any tenant policy
+written against the old id**; bump the version instead.
+
+### `transform` — projection
+
+The only node that computes, and it computes only through AbEx.
+
+```json
+{ "id": "price", "kind": "transform",
+ "set": {
+ "orderId": "$ctx.orderId",
+ "net": "$ctx.amount",
+ "vat": "$ctx.amount * 0.2",
+ "total": "$ctx.amount * 1.2",
+ "tier": "coalesce($ctx.tier, 'standard')"
+ },
+ "replace": false }
+```
+
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `set` | required | Target path → expression. Dotted targets create intermediate objects: `"order.total"` writes `{ "order": { "total": … } }` |
+| `replace` | `false` | `false` merges into existing `data`; `true` discards it first |
+
+**Every expression reads `data` as it was *before* the transform.** The order properties happen to be
+written in cannot change the result:
+
+```json
+"set": { "a": "$.a + 1", "b": "$.a + 10" }
+```
+
+With `data = { "a": 1 }` this yields `{ "a": 2, "b": 11 }` — `b` reads the old `a`, not the new one.
+
+An expression resolving to absent writes `null`.
+
+**Produces:** the `set` map merged into (or replacing) `data`.
+
+### `http` — outbound call
+
+```json
+{ "id": "settle", "kind": "http",
+ "method": "POST",
+ "url": "https://ledger.internal/v1/settlements",
+ "headers": { "X-Order": "{{ $ctx.orderId }}", "Accept": "application/json" },
+ "body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}",
+ "allowedHosts": ["ledger.internal"],
+ "timeoutSeconds": 30,
+ "successCodes": [200, 201, 202],
+ "sendIdempotencyKey": true }
+```
+
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `method` | `GET` | `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD` |
+| `url` | required | Templated |
+| `headers` | none | Values templated |
+| `body` | none | Templated |
+| `timeoutSeconds` | `30` | 1–600 |
+| `successCodes` | `200,201,202,204` | Anything else raises `ApiCallFailureException` |
+| `allowedHosts` | none | Egress allow-list. **Required** when the host enforces egress |
+| `sendIdempotencyKey` | `true` | Sends `Idempotency-Key: {instance}:{node}:{attempt}` |
+
+This is the framework's `ApiCallExecutor`, hosted inside the DSL node — the egress guard, the
+idempotency key, the `Retry-After` parsing and the typed failure exception all behave exactly as they
+do for a compiled workflow. Nothing is reimplemented.
+
+**Produces:** `{ "status": 200, "body": … }`. A JSON response body is parsed so it is addressable
+(`$.body.reference`); a non-JSON body lands as a string.
+
+### `llm` — model call
+
+```json
+{ "id": "summarise", "kind": "llm",
+ "model": "gpt-4o",
+ "system": "You summarise orders for an operations team.",
+ "prompt": "Summarise order {{ $ctx.orderId }} totalling {{ $.total }}.",
+ "promptVersion": "v3",
+ "temperature": 0.2,
+ "maxTokens": 500,
+ "streamDeltas": false,
+ "emitCompletion": true }
+```
+
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `model` | required | Resolved through `IChatClientResolver`, or a single registered `IChatClient` |
+| `system` / `prompt` | `prompt` required | Templated |
+| `promptVersion` | none | Travels into drift middleware and the completion event |
+| `temperature`, `maxTokens` | provider default | |
+| `streamDeltas` | `false` | Emits transient `llm.delta` events |
+| `emitCompletion` | `true` | Emits one `llm.completed` carrying model, tokens, cost and latency |
+
+**Produces:** `{ text, value, model, inputTokens, outputTokens, costUsd, finishReason, elapsedMs }` —
+so a document can branch on cost or token count, not just on the text.
+
+### `delay` — durable wait
+
+```json
+{ "id": "cool-off", "kind": "delay", "for": "PT4H" }
+```
+
+ISO-8601 duration. Writes a timer row rather than blocking, so a long delay costs no execution
+capacity.
+
+**Produces:** the envelope **unchanged**. A delay is about *when* the next node runs, not about what
+it receives — losing the payload would make every delay need a transform after it.
+
+> Requires an `ITimerService` registration. See [§18](#18-declared-but-not-yet-enforced).
+
+### `approval` — human decision as a node
+
+```json
+{ "id": "sign-off", "kind": "approval" }
+```
+
+Identity work; the pause is the point. The node **always carries a gate**, whether or not the
+document spells one out — an `approval` node with no `gate` block behaves as
+`{ "mode": "requireApproval" }`. Add a `gate` block to configure assignees, quorum or expiry.
+
+Use this when the decision belongs in the graph. Use a `gate` on a working node when the decision is
+configuration *about* that node.
+
+**Produces:** the envelope unchanged.
+
+### `publish` — emit a domain event
+
+```json
+{ "id": "announce", "kind": "publish",
+ "topic": "orders.settled",
+ "payload": { "order": "$ctx.orderId", "total": "$.total" },
+ "correlationKey": "$ctx.orderId",
+ "scope": "local" }
+```
+
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `topic` | required | Dot-segmented topic |
+| `payload` | whole `data` | Explicit projection. Defaulting to `data` rather than the envelope matters — a subscriber should receive the message, not this workflow's context |
+| `correlationKey` | none | Expression; lets a waiting instance match this message |
+| `scope` | `local` | `distributed` requires a broker that supports it, checked at build |
+
+**Produces:** the envelope unchanged — publishing is a side effect on the way past, so the node drops
+into an existing edge without rewiring the graph.
+
+### `wait-event` — park until a message arrives
+
+```json
+{ "id": "await-payment", "kind": "wait-event",
+ "topic": "payment.settled",
+ "correlationKey": "$ctx.orderId",
+ "timeout": "P3D",
+ "onExpiry": "deadStop" }
+```
+
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `topic` | required | Pattern: `*` matches one segment, `#` the remainder |
+| `correlationKey` | none | Receive only messages carrying this key |
+| `timeout` | none | ISO-8601 |
+| `onExpiry` | `deadStop` | `deadStop` terminates; `resume` continues so the graph can handle it |
+
+Runs twice: the first pass registers a durable subscription and parks (the instance checkpoints and
+releases its lease, so a three-day wait costs nothing); after delivery the runner resumes and the
+second pass returns the payload.
+
+**Produces:** `data` becomes the delivered payload. `ctx` survives the park, so `$ctx.orderId` still
+resolves afterwards.
+
+### `fan-in` — barrier aggregation
+
+```json
+{ "id": "join", "kind": "fan-in", "into": "branches" }
+```
+
+The target of a barrier edge. Holds each branch's arrival and emits once the last one lands; the
+expected count is read from the barrier edge in the document.
+
+**Produces:** `{ "": [ …each branch's data… ] }`, default `into` is `"items"`.
+
+Cannot carry a gate — a barrier aggregates work that has already happened, so pausing it would gate
+nothing.
+
+### `custom` — a registered node
+
+```json
+{ "id": "score", "kind": "custom", "node": "score-risk",
+ "with": { "model": "v3", "threshold": 0.82 } }
+```
+
+See [§12](#12-custom-nodes).
+
+---
+
+## 7. Edges
+
+One shape covers everything: `from` and `to`, either of which may be a list.
+
+```json
+"edges": [
+ { "from": "a", "to": "b" },
+ { "from": "b", "to": "large", "when": "$.total > 25000" },
+ { "from": "b", "to": "small", "when": "$.total <= 25000" },
+ { "from": "large", "to": ["notify-ops", "notify-customer"] },
+ { "from": ["notify-ops", "notify-customer"], "to": "join" },
+ { "from": "c", "to": ["one", "two", "three"], "select": "$.chosenIndices" }
+]
+```
+
+| Shape | Behaviour |
+| --- | --- |
+| `from: "a", to: "b"` | Sequential |
+| `+ "when": ""` | Traversed only when the expression is boolean `true` |
+| `from: "a", to: ["b","c"]` | Fan-out to every target |
+| `+ "select": ""` | Fan-out to the subset the expression names by index |
+| `from: ["a","b"], to: "c"` | Fan-in barrier — `c` runs once every source has delivered |
+
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `when` | none | Condition. Must be deterministic. Not valid on a barrier |
+| `select` | none | Fan-out only. A number picks one target; an array picks several. Out-of-range indices are ignored |
+| `label` | none | Shown in the graph view |
+| `idempotent` | `false` | Permits a duplicate unconditional edge |
+
+**Branching is two conditional edges out of one node** — there is no `switch`. Make the predicates
+exhaustive: a message matching neither simply stops there, and the run completes with no output.
+
+**A duplicate unconditional edge is refused** (`DSL0208`), because which one fires is ambiguous. Two
+*conditional* edges between the same pair are fine — that is exactly how a branch with a fallback is
+written. Set `idempotent: true` if the repeat is genuinely intended.
+
+**Cycles are allowed only when something on them yields.** Polling and wait-and-recheck are
+legitimate, but a cycle of pure compute nodes is a hot spin that occupies a dispatcher until the
+lifetime cap. Put a `delay`, `wait-event` or `approval` node on the cycle (`DSL0303`).
+
+---
+
+## 8. Approval gates
+
+A gate on any node makes the run pause for a human. Every option the compiled `ApprovalGateBuilder`
+offers is expressible.
+
+```json
+"gate": {
+ "mode": "conditional",
+ "when": "$.total > 25000",
+ "reason": "RegulatedSettlement",
+ "assignTo": ["group:finance", "user:cfo"],
+ "requireApprovers": 2,
+ "expiresAfter": "PT8H",
+ "onExpiry": { "action": "escalate", "assignTo": ["group:exec"] },
+ "allowModification": true,
+ "requireSegregationOfDuties": true,
+ "locked": true
+}
+```
+
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `mode` | required | `autonomous` (no gate), `requireApproval` (always), `conditional` (when the predicate trips) |
+| `when` | required for `conditional` | Deterministic AbEx predicate over the node's input |
+| `reason` | none | Surfaced to approvers and on the approval event |
+| `assignTo` | none | `user:`, `group:` or `role:` prefixed principals |
+| `requireApprovers` | `1` | Quorum |
+| `expiresAfter` | `PT24H` | ISO-8601 |
+| `onExpiry.action` | `deadStop` | `deadStop`, `reject`, `autoApprove`, `escalate` |
+| `onExpiry.assignTo` | | Required when the action is `escalate` |
+| `allowModification` | `false` | Approver may amend the node's input |
+| `requireSegregationOfDuties` | `false` | The decider may not be the initiator |
+| `locked` | `false` | Tenants may tighten this gate, never loosen it |
+
+Notes that bite:
+
+- **A gate with `assignTo` refuses an unauthenticated decider** with 403. That is correct, and worth
+ remembering when testing.
+- **Every gated node is reconfigurable per tenant** unless `locked` is set. See
+ [Tenant executor configuration](wiki.md#tenant-executor-configuration).
+- **`fan-in` nodes cannot be gated** (`DSL0501`).
+- A `conditional` gate with no `when` is refused (`DSL0502`) — it would never trip, which is the same
+ as having no gate.
+
+---
+
+## 9. Notifications and events
+
+### Per-node notification
+
+```json
+{ "id": "price", "kind": "transform",
+ "set": { "total": "$ctx.amount * 1.2" },
+ "notify": {
+ "name": "priced",
+ "payload": { "order": "$ctx.orderId", "total": "$.total" }
+ } }
+```
+
+Emits `custom.priced` on the instance's event stream after the node succeeds, interleaved correctly
+with the lifecycle events around it. The `custom.` prefix is applied by the framework and cannot be
+opted out of, so a workflow can never shadow a framework event. The payload is evaluated against the
+node's *output* envelope.
+
+A parked node emits nothing — the notification fires only on a real result.
+
+### Workflow-level policy
+
+```json
+"notifications": {
+ "level": "standard",
+ "stream": true,
+ "byNode": { "chatty-fan-out": "minimal", "the-interesting-one": "standard" },
+ "emits": ["priced", "settled"]
+}
+```
+
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `level` | `standard` | `minimal` (start, output, terminal), `lifecycle` (adds superstep boundaries), `standard` (adds per-node and workflow-defined events) |
+| `stream` | `true` | Whether events reach live SSE subscribers |
+| `byNode` | none | Per-node level override, in both directions |
+| `emits` | none | Names advertised by the catalog. Per-node `notify` names are added automatically |
+
+> **The durable event log is not optional and cannot be turned off.** `stream: false` switches off
+> only the live fan-out; every event is still written and readable at
+> `GET /v2/workflows/{name}/instances/{id}/events`. Only the *timing* of observability changes.
+
+Approvals, control actions, broker deliveries and terminal events are never suppressed at any level —
+they are facts about the system, not run chatter.
+
+---
+
+## 10. Domain events: publishing, waiting, triggering
+
+Three distinct capabilities, all reachable from a document.
+
+**Publish** — a `publish` node, [§6](#publish--emit-a-domain-event).
+
+**Wait** — a `wait-event` node, [§6](#wait-event--park-until-a-message-arrives).
+
+**Trigger** — a message *starts* an instance:
+
+```json
+"triggers": [
+ { "topic": "orders.placed" },
+ { "topic": "orders.*.amended", "contextFrom": "$.order" }
+]
+```
+
+| Field | Meaning |
+| --- | --- |
+| `topic` | Pattern. `*` matches one segment, `#` the trailing remainder |
+| `correlationKey` | **A literal filter value, not an expression.** The subscription is registered before any message exists, so there is nothing for a path to read. A key written to look like an expression is warned about |
+| `contextFrom` | An expression **rooted at the message payload**, projecting it into the workflow's start context. Omit to pass the whole payload through |
+
+`contextFrom` is a genuine expression because a message *does* exist when it runs. If it resolves to
+nothing the whole payload is used, so a mistyped path degrades rather than starting an empty run.
+
+---
+
+## 11. Failure, retry and limits
+
+```json
+"onFailure": [
+ { "match": { "exception": "ApiCallFailureException", "status": "5xx" }, "disposition": "retry" },
+ { "match": { "exception": "ApiCallFailureException", "status": "4xx" }, "disposition": "deadStop" },
+ { "match": { "node": "settle" }, "disposition": "escalate" }
+],
+"limits": { "maxAttempts": 5, "maxLifetimeHours": 72 }
+```
+
+Rules are evaluated **in declaration order**; the first match wins. Anything unmatched falls through
+to the framework's default classifier, which already knows that a rate limit is worth retrying and a
+validation error is not. **A document only has to state where it disagrees.**
+
+| `match` field | Matches |
+| --- | --- |
+| `exception` | A framework exception by name — a fixed whitelist, so a document cannot name arbitrary types |
+| `status` | An exact code (`404`) or a class (`5xx`); only meaningful for `ApiCallFailureException` |
+| `node` | Scopes the rule to one node id |
+
+Matchable exceptions: `WorkflowDeadStopException`, `ApprovalRejectedException`,
+`WorkflowValidationException`, `StructuredOutputException`, `ApiCallFailureException`,
+`LlmRateLimitException`, `LlmOverloadedException`, `DslContractException`.
+
+| Disposition | Effect |
+| --- | --- |
+| `retry` | Backoff and try again, until `maxAttempts` or `maxLifetimeHours` |
+| `deadStop` | Terminal. Retrying cannot help, so attempts are not burned discovering that |
+| `escalate` | Terminal, and flagged for operator attention |
+
+---
+
+## 12. Custom nodes
+
+The extension seam, and the whole reason the DSL has no ceiling.
+
+```csharp
+public sealed class RiskScoringNodeFactory : IDslNodeFactory
+{
+ public string Name => "score-risk";
+
+ // Validated against 'with' at registration, so a bad parameter fails startup.
+ public JsonNode? ParameterSchema => JsonNode.Parse("""
+ {
+ "type": "object",
+ "required": ["threshold"],
+ "properties": {
+ "threshold": { "type": "number", "minimum": 0, "maximum": 1 },
+ "model": { "type": "string" }
+ }
+ }
+ """);
+
+ public IHostExecutor Create(DslNodeContext context)
+ => new RiskScorer(
+ context.Node.Id,
+ context.Parameters["threshold"]!.GetValue(),
+ context.Require());
+}
+
+internal sealed class RiskScorer(string id, decimal threshold, IRiskService risk)
+ : HostExecutor(id)
+{
+ protected override async ValueTask ExecuteCoreAsync(
+ DslMessage input, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ decimal score = await risk.ScoreAsync(input.Data, cancellationToken);
+
+ var data = (JsonObject)(input.Data ?? new JsonObject()).DeepClone();
+ data["score"] = score;
+ data["flagged"] = score > threshold;
+
+ return input.WithData(data);
+ }
+}
+```
+
+```json
+{ "id": "score", "kind": "custom", "node": "score-risk",
+ "with": { "threshold": 0.82, "model": "v3" } }
+```
+
+Rules enforced at build time:
+
+- The executor **must** be a `HostExecutor`. Every edge carries the envelope,
+ and a node emitting anything else would break the *next* edge rather than its own.
+- The executor's id **must** match the declared node id — gate policy and node state key off it.
+- An unregistered `node` name fails registration (`DSL0601`), not the first run.
+- `with` is validated against `ParameterSchema` (`DSL0602`).
+
+`DslNodeContext` gives a factory everything a compiled definition gets:
+
+| Member | Purpose |
+| --- | --- |
+| `Node` | The declared node model, including its expressions |
+| `Document` | The whole document |
+| `Build` | The `WorkflowBuildContext` — instance id, tenant, attempt, audit |
+| `Parameters` | The `with` object, empty rather than null |
+| `Require()` / `Optional()` | Resolve host services |
+
+A custom node is hosted the same way a built-in one is, so it gets bound expression roots, its
+declared `notify`, and result projection for free. The author writes `ExecuteCoreAsync` and nothing
+else.
+
+---
+
+## 13. Registration and hosting
+
+```csharp
+builder.Services.AddWorkflowHost(builder.Configuration)
+ .AddWorkflow() // compiled, unchanged
+
+ .UseDsl() // routes work before any document exists
+ .ConfigureDsl(dsl =>
+ {
+ dsl.EnforceEgress = true;
+ dsl.Policy = DslPolicy.Default with { MaxNodes = 200 };
+ })
+
+ .AddDslNode(new RiskScoringNodeFactory())
+ .AddDslNode("stamp", ctx => new StampExecutor(ctx.Node.Id)) // delegate form
+
+ .AddDslWorkflow("workflows/order-settlement.json")
+ .AddDslWorkflowText(embeddedDocument, "embedded:order")
+ .AddDslWorkflowsFromDirectory("workflows/", "*.workflow.json", recursive: true);
+
+app.MapWorkflowApi();
+app.MapDslApi();
+```
+
+**Order does not matter.** Documents are parsed and validated once the container is built, against
+the *complete* node catalog — so `AddDslNode` may come after `AddDslWorkflow`. Making correctness
+depend on the order composition happened to be written in would be a trap.
+
+**An invalid document fails startup**, with *every* diagnostic from *every* failing document. Three
+broken documents should take one startup to fix, not three.
+
+Documents load from a directory in a stable ordinal order, so a conflict between two documents naming
+the same `(name, version)` names the same one every time rather than looking intermittent.
+
+### Routes
+
+| Route | Purpose |
+| --- | --- |
+| `GET /dsl/schema` | The published JSON Schema, for editor completion |
+| `GET /dsl/nodes` | Built-in kinds and every registered custom node with its parameter schema |
+| `GET /dsl/functions` | The closed expression vocabulary with arities |
+| `GET /dsl/documents` | Registered documents and their content hashes |
+| `POST /dsl/validate` | Validate a document without registering it |
+| `GET /workflows/{name}` | Not a DSL route, but reports `source` (`dsl` or `compiled`) and, for a document, its `documentHash` |
+
+`POST /dsl/validate` is what an authoring tool calls: it validates against the **live host's**
+catalog, which an offline linter cannot do. It reflects registered node names back to the caller, so
+give it the same authorization as the catalog routes.
+
+---
+
+## 14. Validation and diagnostics
+
+Two phases, because one cannot do the job.
+
+**Phase 1 — JSON Schema** checks shape: required properties, `kind`-discriminated variants, id and
+SemVer patterns, ISO-8601 durations, principal formats, enum values.
+
+**Phase 2 — the semantic validator** checks everything a schema cannot express. A schema cannot
+compare two array items, follow a reference, walk a graph, parse a sub-language, or know what the
+host has registered.
+
+The phases stop where continuing would be noise: a document failing the schema is not read into the
+model, because reporting forty type errors from a half-understood document buries the one that
+matters.
+
+### Diagnostic codes
+
+| Code | Check |
+| --- | --- |
+| `DSL0101` | `dsl` major version is supported |
+| `DSL0102` | Content hash conflicts with an already-published `(name, version)` |
+| `DSL0103` | Document is valid JSON and an object |
+| `DSL0104` | Document matches the JSON Schema |
+| `DSL0201` | Node ids are unique |
+| `DSL0202` | `start` names a real node |
+| `DSL0203` | Every `output` entry names a real node |
+| `DSL0207` | Every edge endpoint exists *(with a nearest-match suggestion)* |
+| `DSL0208` | No duplicate unconditional edge unless `idempotent` |
+| `DSL0301` | Every node is reachable from `start` *(warning)* |
+| `DSL0302` | No reachable dead end outside `output` *(warning)* |
+| `DSL0303` | No cycle without a `delay`, `wait-event` or `approval` on it |
+| `DSL0304` | Barrier sources are reachable, so the barrier can release |
+| `DSL0401` | Every expression parses |
+| `DSL0412` | Every function is known, with correct arity |
+| `DSL0413` | No non-deterministic value in an edge condition or gate predicate |
+| `DSL0414` | Expression depth within the limit |
+| `DSL0501` | No gate on a non-gateable kind |
+| `DSL0502` | A `conditional` gate has a `when` |
+| `DSL0503` | `escalate` expiry names escalation assignees |
+| `DSL0601` | Every `custom` node names a registered factory |
+| `DSL0602` | `with` satisfies the factory's parameter schema |
+| `DSL0603` | `http` nodes declare allowed hosts when egress is enforced |
+| `DSL0701` | Document, node, edge and expression limits |
+
+Every diagnostic carries a **JSON Pointer**:
+
+```
+DSL0412 error /nodes/3/gate/when Unknown function 'lookupCustomer'. Did you mean 'coalesce'?
+DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. Did you mean 'settle'?
+DSL0301 warn /nodes/7 Node 'notify' is unreachable from 'price'.
+```
+
+### Skipped checks
+
+`DSL0102`, `DSL0601`, `DSL0602` and `DSL0603` need a host. Validating offline reports them as
+**skipped** rather than passed, in a `skippedChecks` array:
+
+```json
+{ "valid": true, "skippedChecks": ["DSL0601", "DSL0602", "DSL0603", "DSL0102"] }
+```
+
+A check that silently did not run is worse than one that openly did not, because only the second can
+be acted on.
+
+### Limits
+
+| Limit | Default |
+| --- | --- |
+| Document size | 1 MB |
+| Nodes | 500 |
+| Edges | 2000 |
+| Expression depth | 32 |
+| Expression length | 2048 characters |
+| Regex match timeout | 200 ms |
+
+All configurable **down** through `ConfigureDsl`, none up.
+
+---
+
+## 15. Versions, identity and drift
+
+A document registers as `(name, version)` and inherits the framework's rule: **a published version is
+immutable.** Instances pin their version, and editing a document under a version its instances are
+running would rewrite history mid-flight.
+
+Identity is a canonical SHA-256 (RFC 8785 JCS) of the document:
+
+- Reformatting, whitespace and property reordering **do not** change the hash.
+- One byte of behaviour **does**.
+
+Registering a document whose `(name, version)` is already known with a different hash is a startup
+failure naming both hashes (`DSL0102`). Editing a workflow means bumping the version — which the
+compiled path already demands, stated in a way a document author actually encounters.
+
+`GET /dsl/documents` reports each registered document's hash, which answers the operational question
+directly: *is this instance running the document I am looking at?*
+
+---
+
+## 16. What a document inherits for free
+
+None of this is declared in a document, because none of it is the document's business. DSL nodes
+traverse exactly the same runtime as compiled ones.
+
+| Capability | How it applies |
+| --- | --- |
+| **Checkpointing and resume** | Every superstep checkpoints; a parked instance releases its lease and resumes on any replica |
+| **At-least-once execution** | Same lease-based dispatch and retry semantics |
+| **Executor middleware** | Every DSL node runs through the host's `IExecutorMiddleware` pipeline — logging, metrics, redaction, drift detection |
+| **Workflow middleware** | Same `IWorkflowMiddleware` wrapping of the whole run |
+| **Redaction** | The same rules apply to envelopes. Worth noting: the envelope deliberately carries more in flight (`ctx` travels with every message), so redaction matters more, not less |
+| **Egress control** | `http` nodes go through the same `EgressGuard`. A document cannot widen an allow-list the host has fixed |
+| **Multi-tenancy** | Definitions are global; instances are tenant-scoped; `$run.tenantId` is readable |
+| **Instance controls** | `cancel`, `suspend`, `resume`, `retry`, `rerun` work identically |
+| **Observability** | Event history, SSE streaming with `Last-Event-ID` catch-up, instance logs, the graph endpoint |
+| **Tenant gate configuration** | Gated DSL nodes are reconfigurable per tenant unless `locked` |
+| **Approval flow** | Quorum, expiry, escalation, segregation of duties, modification |
+
+The catalog reports a DSL node's `kind` in its metadata, so `GET /workflows/{name}/versions/{v}/nodes`
+and the graph view describe a document exactly as they describe a compiled definition.
+
+---
+
+## 17. Framework coverage map
+
+Every capability the compiled authoring surface offers, and how a document reaches it.
+
+| Framework capability | DSL |
+| --- | --- |
+| `IWorkflowDefinition` | The document itself; `context` schema replaces `TContext` |
+| `TransformExecutor` | `kind: "transform"` |
+| `DelegateExecutor` | ✖ **By design.** Use a `custom` node |
+| `ApiCallExecutor` | `kind: "http"` |
+| `LlmExecutor` | `kind: "llm"` |
+| `DelayExecutor` | `kind: "delay"` |
+| `HumanApprovalExecutor` | `kind: "approval"` |
+| `FanInExecutor` | `kind: "fan-in"` |
+| `PublishDomainEventExecutor` | `kind: "publish"` |
+| `WaitForDomainEventExecutor` | `kind: "wait-event"` |
+| Custom `HostExecutor` | `kind: "custom"` + `IDslNodeFactory` |
+| `RawNode` / `AIAgent` / sub-workflow bindings | ✖ Not exposed |
+| `AddEdge` | `{ from, to }` |
+| `AddEdge(condition)` | `{ from, to, when }` |
+| `AddFanOutEdge` | `{ from, to: [...] }` |
+| `AddFanOutEdge(targetSelector)` | `{ from, to: [...], select }` |
+| `AddFanInBarrierEdge` | `{ from: [...], to }` |
+| `WithOutputFrom` | `output` |
+| Edge labels / `idempotent` | `label`, `idempotent` |
+| `ApprovalGateBuilder` (all options) | `gate` block |
+| `.Locked()` | `gate.locked` |
+| `Classify(WorkflowFailure)` | `onFailure` rules, falling through to the default classifier |
+| `INotifyingWorkflow` | `notifications` |
+| `INodeNotifier.NotifyAsync` | `notify` on a node |
+| `IDomainEventTriggeredWorkflow` | `triggers` |
+| `IAuditedWorkflowDefinition` | `audit` — **shape only**, see §18 |
+| `IContextValidatingWorkflow` | `context` schema |
+| `MaxAttempts` / lifetime | `limits` |
+| `IWorkflowContext.QueueStateUpdateAsync` etc. | ✖ Not exposed; a `custom` node has full access |
+| Middleware, redaction, egress, tenancy, checkpointing | Inherited — see §16 |
+
+---
+
+## 18. Declared but not yet enforced
+
+These fields are accepted by the schema and parsed into the model, but **nothing acts on them yet**.
+They are documented here rather than quietly omitted, so nobody relies on behaviour that does not
+exist.
+
+| Field | Intended behaviour | Current behaviour |
+| --- | --- | --- |
+| `strict` | Enforce node `input`/`output` schemas at run time; a violation is dead-stop | Parsed, ignored |
+| node `input` / `output` | Per-node contract schemas | Parsed, ignored |
+| `result` | Schema the workflow result is expected to satisfy | Parsed, ignored |
+| `llm.structuredOutput` | Parse model output into a declared shape | Parsed, ignored — the node returns text |
+| `audit.key` | Business key the audit record opens under | Parsed and validated as an expression, ignored |
+| `audit.sections` | Sections the record may contain | **Declares the record shape**, so the state endpoint returns a non-null `audit` object — but no DSL node writes entries into it |
+
+The `audit` gap is the largest: a document can declare a record shape, and it will be advertised, but
+only a `custom` node can actually record anything into it (via `context.Build.Audit`). A document of
+built-in nodes produces an empty record.
+
+Two host prerequisites are also worth stating:
+
+- **`ITimerService` is not registered by the framework or the shipped host.** A `delay` node needs
+ one; register an implementation before using that kind.
+- **`IChatClient` or `IChatClientResolver` must be registered** for `llm` nodes. With a single
+ `IChatClient`, the `model` field is passed through as the model id but does not select a client.
+
+---
+
+## 19. Not expressible
+
+Deliberate boundaries, so you meet them here rather than in an error message.
+
+**No loops or iteration.** There is no `foreach`, and no way to sum or map over an array. Fan-out
+over branches is the intended shape for parallel work. Unbounded iteration in a checkpointed engine
+has real semantics to establish first — checkpoint size, superstep count, and what a retry means
+mid-iteration.
+
+> This is the most commonly hit limit. A workflow that must aggregate over a collection needs a
+> `custom` node — which is a five-line executor, not a workaround.
+
+**No sub-workflows.** The engine supports composing workflows; resolving and version-pinning one
+document from another needs its own design.
+
+**No runtime publication.** Documents load from disk or memory at startup. A management API that
+accepted them at run time would change the registry from immutable to mutable, which touches version
+resolution, dispatch, authorization, tenancy and in-flight instance migration.
+
+**No arbitrary code.** No scripting, no reflection by name into arbitrary types, no `eval`. Only
+registered factories.
+
+**No export from C#.** A compiled definition cannot be emitted as a document. The DSL is a different
+way in, not a serialization of the compiled path.
+
+---
+
+## Appendix A — worked variations
+
+Each mirrors a variation from the
+[C# guide's appendix](workflow-authoring-guide.md#appendix-a--worked-variations), so the two front
+ends can be read side by side.
+
+### A.1 Linear
+
+```json
+{
+ "dsl": "abacus.workflow/1.0",
+ "name": "linear", "version": "1.0.0",
+ "context": { "type": "object", "required": ["orderId", "amount"] },
+ "start": "price", "output": ["finish"],
+ "nodes": [
+ { "id": "price", "kind": "transform", "set": { "total": "$ctx.amount * 1.2" } },
+ { "id": "finish", "kind": "transform", "set": { "order": "$ctx.orderId", "total": "$.total", "status": "'done'" } }
+ ],
+ "edges": [ { "from": "price", "to": "finish" } ]
+}
+```
+
+### A.2 Branch
+
+```json
+"start": "classify", "output": ["escalate", "settle"],
+"nodes": [
+ { "id": "classify", "kind": "transform", "set": { "amount": "$ctx.amount" } },
+ { "id": "escalate", "kind": "transform", "set": { "route": "'manual'" } },
+ { "id": "settle", "kind": "transform", "set": { "route": "'auto'" } }
+],
+"edges": [
+ { "from": "classify", "to": "escalate", "when": "$.amount > 10000" },
+ { "from": "classify", "to": "settle", "when": "$.amount <= 10000" }
+]
+```
+
+Make the predicates exhaustive — a message matching neither stops there.
+
+### A.3 Fan-out and fan-in
+
+```json
+"start": "split", "output": ["join"],
+"nodes": [
+ { "id": "split", "kind": "transform", "set": { "seed": "$ctx.amount" } },
+ { "id": "ops", "kind": "transform", "set": { "channel": "'ops'", "value": "$.seed + 1" } },
+ { "id": "cust", "kind": "transform", "set": { "channel": "'customer'", "value": "$.seed + 2" } },
+ { "id": "join", "kind": "fan-in", "into": "notified" }
+],
+"edges": [
+ { "from": "split", "to": ["ops", "cust"] },
+ { "from": ["ops", "cust"], "to": "join" }
+]
+```
+
+Result: `{ "notified": [ { "channel": "ops", … }, { "channel": "customer", … } ] }`.
+
+### A.4 Approval gate
+
+```json
+{ "id": "settle", "kind": "http",
+ "url": "https://ledger.internal/v1/settlements",
+ "allowedHosts": ["ledger.internal"],
+ "method": "POST",
+ "gate": {
+ "mode": "conditional",
+ "when": "$.total > 25000",
+ "reason": "RegulatedSettlement",
+ "assignTo": ["group:finance", "user:cfo"],
+ "requireApprovers": 2,
+ "expiresAfter": "PT8H",
+ "onExpiry": { "action": "escalate", "assignTo": ["group:exec"] },
+ "allowModification": true,
+ "requireSegregationOfDuties": true,
+ "locked": true
+ } }
+```
+
+### A.5 Durable delay
+
+```json
+"nodes": [
+ { "id": "submit", "kind": "transform", "set": { "submitted": "true" } },
+ { "id": "coolOff", "kind": "delay", "for": "PT4H" },
+ { "id": "confirm", "kind": "transform", "set": { "confirmed": "$.submitted" } }
+],
+"edges": [
+ { "from": "submit", "to": "coolOff" },
+ { "from": "coolOff", "to": "confirm" }
+]
+```
+
+The envelope passes through the delay unchanged, so `confirm` still sees `submitted`.
+
+### A.6 HTTP call
+
+```json
+{ "id": "fetch", "kind": "http",
+ "method": "GET",
+ "url": "https://catalog.internal/v1/skus/{{ $ctx.sku }}",
+ "headers": { "Accept": "application/json" },
+ "allowedHosts": ["catalog.internal"],
+ "successCodes": [200, 404],
+ "timeoutSeconds": 10 }
+```
+
+Then branch on the status the node produced:
+
+```json
+{ "from": "fetch", "to": "found", "when": "$.status == 200" },
+{ "from": "fetch", "to": "missing", "when": "$.status == 404" }
+```
+
+Listing `404` as a success code is what turns "not found" into a branch rather than a failure.
+
+### A.7 LLM node
+
+```json
+{ "id": "summarise", "kind": "llm",
+ "model": "gpt-4o",
+ "system": "You write one-sentence order summaries.",
+ "prompt": "Order {{ $ctx.orderId }}, total {{ $.total }}. Summarise.",
+ "promptVersion": "v2",
+ "temperature": 0.2,
+ "maxTokens": 200 }
+```
+
+Branch on cost, which the node put on the envelope:
+
+```json
+{ "from": "summarise", "to": "review", "when": "$.costUsd > 0.5" }
+```
+
+### A.8 Started by an event
+
+```json
+"triggers": [ { "topic": "orders.placed", "contextFrom": "$.order" } ],
+"context": { "type": "object", "required": ["orderId"] },
+"start": "handle",
+"nodes": [ { "id": "handle", "kind": "transform", "set": { "sawOrder": "$ctx.orderId" } } ]
+```
+
+A message `{ "order": { "orderId": "ORD-9" }, "meta": … }` starts an instance whose context is
+`{ "orderId": "ORD-9" }`.
+
+### A.9 Publish and wait
+
+```json
+"nodes": [
+ { "id": "request", "kind": "publish",
+ "topic": "payment.requested",
+ "payload": { "order": "$ctx.orderId", "amount": "$.total" },
+ "correlationKey": "$ctx.orderId" },
+ { "id": "await", "kind": "wait-event",
+ "topic": "payment.settled",
+ "correlationKey": "$ctx.orderId",
+ "timeout": "P3D",
+ "onExpiry": "resume" },
+ { "id": "done", "kind": "transform",
+ "set": { "order": "$ctx.orderId", "paid": "$.amount" } }
+],
+"edges": [
+ { "from": "request", "to": "await" },
+ { "from": "await", "to": "done" }
+]
+```
+
+`onExpiry: "resume"` lets the graph handle a timeout instead of dead-stopping.
+
+### A.10 Quiet a chatty workflow
+
+```json
+"notifications": {
+ "level": "minimal",
+ "byNode": { "score": "standard" },
+ "stream": false
+}
+```
+
+Minimal everywhere, loud on the one interesting node, no live streaming — and the durable log still
+records everything.
+
+### A.11 A custom node doing the domain work
+
+```json
+"nodes": [
+ { "id": "load", "kind": "http", "url": "https://data.internal/v1/case/{{ $ctx.caseId }}",
+ "allowedHosts": ["data.internal"] },
+ { "id": "score", "kind": "custom", "node": "score-risk", "with": { "threshold": 0.8 } },
+ { "id": "route", "kind": "transform", "set": { "outcome": "$.flagged" } }
+],
+"edges": [
+ { "from": "load", "to": "score" },
+ { "from": "score", "to": "route" }
+]
+```
+
+The document orchestrates; the engineer's node computes. That division is the design.
+
+---
+
+## Appendix B — full field reference
+
+```jsonc
+{
+ "dsl": "abacus.workflow/1.0", // required
+ "name": "kebab-case-name", // required
+ "version": "1.0.0", // required, SemVer
+ "description": "…",
+ "context": { /* JSON Schema */ },
+ "result": { /* JSON Schema — not yet enforced */ },
+ "strict": false, // not yet enforced
+ "start": "node-id", // required
+ "output": ["node-id"], // defaults to terminal nodes
+
+ "nodes": [ // required, 1–500
+ {
+ "id": "node-id", // required
+ "kind": "transform", // required
+ "description": "…",
+ "input": { /* not yet enforced */ },
+ "output": { /* not yet enforced */ },
+
+ "gate": {
+ "mode": "conditional", // autonomous | requireApproval | conditional
+ "when": "$.total > 25000", // required for conditional
+ "reason": "…",
+ "assignTo": ["group:finance"],
+ "requireApprovers": 1,
+ "expiresAfter": "PT24H",
+ "onExpiry": { "action": "deadStop", "assignTo": [] },
+ "allowModification": false,
+ "requireSegregationOfDuties": false,
+ "locked": false
+ },
+
+ "notify": { "name": "priced", "payload": { "total": "$.total" } },
+
+ // kind: transform
+ "set": { "path.to.field": "" },
+ "replace": false,
+
+ // kind: http
+ "method": "GET",
+ "url": "",
+ "headers": { "K": "" },
+ "body": "",
+ "timeoutSeconds": 30,
+ "successCodes": [200, 201, 202, 204],
+ "allowedHosts": ["host"],
+ "sendIdempotencyKey": true,
+
+ // kind: llm
+ "model": "…",
+ "system": "",
+ "prompt": "",
+ "promptVersion": "…",
+ "structuredOutput": { /* not yet enforced */ },
+ "temperature": 0.2,
+ "maxTokens": 500,
+ "streamDeltas": false,
+ "emitCompletion": true,
+
+ // kind: delay
+ "for": "PT5M",
+
+ // kind: publish
+ "topic": "a.b.c",
+ "payload": { "k": "" },
+ "correlationKey": "",
+ "scope": "local", // local | distributed
+
+ // kind: wait-event
+ // "topic", "correlationKey" as above
+ "timeout": "P3D",
+ "onExpiry": "deadStop", // deadStop | resume
+
+ // kind: fan-in
+ "into": "items",
+
+ // kind: custom
+ "node": "registered-name",
+ "with": { }
+ }
+ ],
+
+ "edges": [ // 0–2000
+ {
+ "from": "a", // or ["a","b"] for a barrier
+ "to": "b", // or ["b","c"] for fan-out
+ "when": "", // not valid on a barrier
+ "select": "", // fan-out only
+ "label": "…",
+ "idempotent": false
+ }
+ ],
+
+ "triggers": [
+ { "topic": "a.b.*", "correlationKey": "literal", "contextFrom": "" }
+ ],
+
+ "notifications": {
+ "level": "standard", // minimal | lifecycle | standard
+ "stream": true,
+ "byNode": { "node-id": "minimal" },
+ "emits": ["name"]
+ },
+
+ "onFailure": [
+ { "match": { "exception": "ApiCallFailureException", "status": "5xx", "node": "id" },
+ "disposition": "retry" } // retry | deadStop | escalate
+ ],
+
+ "audit": { "key": "", "sections": ["submission", "outcome"] },
+
+ "limits": { "maxAttempts": 5, "maxLifetimeHours": 72 }
+}
+```
+
+---
+
+## Related
+
+- [Wiki: Authoring with the DSL](wiki.md#authoring-with-the-dsl) — the short version
+- [Authoring workflows in C#](workflow-authoring-guide.md) — the compiled path, in the same depth
+- [Wiki: two ways to author a workflow](wiki.md#two-ways-to-author-a-workflow) — choosing between them
+- [JSON Schema](schema/abacus-workflow-dsl-1.0.json) — the normative contract
+- [Design narrative](implementation/06-workflow-dsl-design.md) — why the DSL is shaped this way
+- [Implementation plan](implementation/07-workflow-dsl-implementation-plan.md) — status and deviations
diff --git a/docs/implementation/06-workflow-dsl-design.md b/docs/implementation/06-workflow-dsl-design.md
new file mode 100644
index 0000000..bb5252e
--- /dev/null
+++ b/docs/implementation/06-workflow-dsl-design.md
@@ -0,0 +1,438 @@
+# Workflow DSL — design narrative
+
+Abacus has one way to author a workflow: implement `IWorkflowDefinition` in C#,
+compile it, and register it at startup. That path is expressive, type-safe, and closed to anyone who
+cannot ship a build.
+
+This adds a second path. A workflow becomes a **JSON document** — validated against a published
+schema, interpreted at build time, and registered exactly like a compiled one. Nothing about the
+runtime changes. The DSL is a *front end* onto the same graph, the same executors, the same gates.
+
+---
+
+## 1. The governing rule
+
+> **The DSL composes; it never computes.**
+>
+> A document declares *which* nodes exist, *how* they connect, and *when* an edge is taken. It never
+> carries behaviour. Every unit of work a DSL workflow performs is a capability the host already
+> shipped and vetted — a built-in executor, or a custom node the host registered by name.
+
+Everything below follows from that sentence. It is what makes a document safe to accept from outside
+the build, cheap to validate, and honest about its ceiling: a DSL workflow can only do what the host
+already knows how to do, and the answer to "the DSL can't express this" is *register a node*, never
+*embed a script*.
+
+The corollary matters as much: **the DSL is not a replacement for the compiled path.** They are peers
+with different centres of gravity.
+
+| | Compiled definition | DSL document |
+| --- | --- | --- |
+| **Authored by** | An engineer with a build pipeline | Anyone with the schema |
+| **Expresses** | Arbitrary behaviour | Composition of registered behaviour |
+| **Typing** | Compile-time, generic | Runtime, JSON Schema per node |
+| **Changed by** | A release | An edited document |
+| **Ceiling** | The language | The registered node catalog |
+| **Best for** | Domain logic, novel executors | Orchestration, per-tenant variation, rapid iteration |
+
+A realistic system uses both: engineers ship nodes, and workflows wire them together.
+
+---
+
+## 2. What has to be true for JSON to describe this graph
+
+The compiled API is generic and delegate-shaped. Four features of it do not survive contact with a
+document, and each forces a decision.
+
+**Generic executors.** `HostExecutor` is parameterised, and JSON carries no type
+arguments. → *Decision D1: one envelope type.*
+
+**Delegates everywhere.** Edge conditions, gate predicates, transforms and correlation keys are all
+`Func<...>`. → *Decision D2: a closed expression language.*
+
+**Ambient C# scope.** A compiled node closes over whatever it likes. A document has no scope.
+→ *Decision D3: the envelope carries the run's context explicitly.*
+
+**Open-ended work.** `DelegateExecutor` accepts any lambda. A document must not.
+→ *Decision D4: a named node catalog with a registration seam.*
+
+---
+
+## 3. D1 — One envelope, uniformly typed
+
+Every DSL node is a `HostExecutor`. `DslMessage` is a sealed class wrapping a
+JSON object:
+
+```json
+{
+ "ctx": { "orderId": "ORD-1", "lines": [ … ] },
+ "data": { "total": 429.50 },
+ "meta": { "node": "price", "superstep": 3 }
+}
+```
+
+- **`ctx`** — the start context, deep-frozen. Copied through every node unchanged. This is D3: it
+ restores the ambient scope a document otherwise lacks, and it is the only reason an expression
+ eleven nodes deep can still say `$ctx.orderId`.
+- **`data`** — the current value. This is what a node reads and what it replaces.
+- **`meta`** — provenance the interpreter maintains. Read-only to expressions.
+
+Three things fall out of this, and they are the whole argument for it:
+
+1. **Every edge type-checks by construction.** There is no type-flow analysis to write, because there
+ is only one type. The engine's own `AddEdge` conditions are always `AddEdge`.
+2. **Checkpoint and resume are free.** The envelope is already JSON; there is no serializer to teach
+ about a DSL workflow's message types.
+3. **`TOut : class` is satisfied**, so the null-return park path — the mechanism behind approval
+ gates and event waits — works for DSL nodes with no change to `HostExecutor`.
+
+What it costs is compile-time type safety, and the replacement is explicit: a node may declare
+`input` and `output` JSON Schemas, enforced at runtime under `"strict": true`. A schema violation
+throws `DslContractException`, which classifies as **dead-stop** — a node handed the wrong shape will
+be handed it again on retry.
+
+### The definition's own generic parameters
+
+`DslWorkflowDefinition` implements `IWorkflowDefinition`. That makes the
+registry's type-bind step a no-op, which is correct but insufficient — a DSL document declares a
+`context` schema and the registry must honour it.
+
+This is **the one core change the DSL requires**: an opt-in interface consulted by
+`WorkflowRegistry.ValidateContext` after the type bind succeeds.
+
+```csharp
+public interface IContextValidatingWorkflow
+{
+ ContextValidationResult ValidateContext(JsonElement context);
+}
+```
+
+Additive, opt-in, and useful beyond the DSL — a compiled workflow wanting schema validation of its
+start payload gets it the same way. Nothing else in `Abacus.Run` changes to support the DSL.
+
+---
+
+## 4. D2 — AbEx, the expression language
+
+Conditions, guards, correlation keys and projections all need *some* computation. The requirement is
+narrow and the risk is not, so the grammar is closed.
+
+### Design constraints
+
+An expression must be **total** (no exceptions — a missing path is a value, not a fault), **pure**
+(no I/O, no state), **cheap** (bounded depth, no loops, no recursion), and **statically checkable**
+(every function and operator resolved at validation time, so a typo fails a document review rather
+than a production run).
+
+### Grammar
+
+```
+expr := or
+or := and ( "||" and )*
+and := unary ( "&&" unary )*
+unary := "!" unary | cmp
+cmp := add ( ("=="|"!="|"<"|"<="|">"|">=") add )?
+add := mul ( ("+"|"-") mul )*
+mul := primary ( ("*"|"/"|"%") primary )*
+primary := literal | path | call | "(" expr ")"
+call := ident "(" [ expr ("," expr)* ] ")"
+path := root ( "." ident | "[" integer "]" )*
+root := "$" | "$ctx" | "$run" | ident
+literal := number | string | "true" | "false" | "null"
+```
+
+### Roots
+
+| Root | Binds to | Notes |
+| --- | --- | --- |
+| `$` | `data` of the current envelope | `$.total`, `$.lines[0].sku` |
+| `$ctx` | the frozen start context | available at every node |
+| `$run` | `instanceId`, `tenantId`, `attempt`, `superstep`, `workflow`, `version`, `now` | |
+
+There is deliberately **no `$node.`**. The engine is message-passing; a prior node's output is
+not ambiently available, and a root that pretended otherwise would be a lie the interpreter could not
+keep. A workflow that needs an earlier value carries it forward in `data` — which is what an explicit
+`transform` node is for.
+
+### Functions
+
+A closed set. An unknown name is a **validation error**, not a runtime one.
+
+| Function | Result |
+| --- | --- |
+| `len(x)` | length of a string or array; `0` for `null` |
+| `has(path)` | whether the path resolves to anything other than absent |
+| `lower(s)` / `upper(s)` | case folding, invariant culture |
+| `contains(s, sub)`, `startsWith(s, p)`, `endsWith(s, p)` | ordinal string tests |
+| `matches(s, pattern)` | regex, compiled once, **200 ms match timeout**, non-backtracking where the pattern allows |
+| `coalesce(a, b, …)` | first non-absent, non-null argument |
+| `number(x)`, `string(x)`, `bool(x)` | explicit coercion |
+
+`matches` is the one dangerous entry; the timeout and the compile-time pattern check are what earn
+its place. If a future review disagrees, it is the removable one.
+
+### Semantics, stated so there is nothing to guess
+
+- **Absence is a value.** A path that does not resolve yields *absent*. Absent propagates through
+ comparisons as `false`, through `has()` as `false`, through `coalesce()` as skipped. It never
+ throws.
+- **Conditions are strict.** An expression used as a condition must evaluate to boolean `true` to be
+ taken. Absent, `null`, `0`, and `""` are all **false**, and a non-boolean is a *validation* error
+ where the type is statically knowable. There is no JavaScript truthiness here; the surprise is not
+ worth the keystrokes.
+- **Comparison is JSON-typed.** Number-to-number is numeric; string-to-string is ordinal; anything
+ cross-type is `false` for ordering operators and `false` for `==`. No coercion ladder.
+- **Arithmetic is decimal.** These documents price orders. Binary floating point is the wrong default
+ and `0.1 + 0.2` is the wrong first impression. Division by zero yields absent.
+
+### Determinism where routing depends on it
+
+`BuildAsync` runs **once per attempt**, and a resumed instance must retrace the routing its
+checkpoint recorded. So:
+
+> `$run.now` and any future non-deterministic function are **forbidden in edge conditions and gate
+> predicates**, and permitted in templates and projections.
+
+A non-deterministic condition would let a resumed run take a different branch than the one it
+checkpointed — silent, intermittent, and close to undebuggable. The validator rejects it by static
+inspection rather than trusting the author to remember.
+
+---
+
+## 5. D4 — The node catalog
+
+`kind` is the discriminator. Every value maps to an executor the host already ships:
+
+| `kind` | Executor | Notes |
+| --- | --- | --- |
+| `transform` | `TransformExecutor` | `set` map of target path → AbEx expression |
+| `http` | `ApiCallExecutor` | egress allow-list required |
+| `llm` | `LlmExecutor` | model, prompts, structured output, cost |
+| `delay` | `DelayExecutor` | durable — checkpoints and halts |
+| `approval` | `HumanApprovalExecutor` | approval as an explicit node |
+| `publish` | `PublishEventExecutor` | domain event out |
+| `wait-event` | `WaitForEventExecutor` | parks until a message matches |
+| `fan-in` | `FanInExecutor` | aggregates a barrier's inputs |
+| `custom` | a registered `IDslNodeFactory` | **the extension seam** |
+
+There is **no `delegate` kind**, and there never will be. Arbitrary code is precisely what a document
+must not carry.
+
+### `custom` is the whole scalability story
+
+```csharp
+services.AddDslNode("score-risk", new RiskScoringNodeFactory());
+```
+
+```json
+{ "id": "score", "kind": "custom", "node": "score-risk",
+ "with": { "model": "v3", "threshold": 0.82 } }
+```
+
+The factory receives the `with` object (validated against a schema the factory itself publishes) and
+returns a `HostExecutor`. An unregistered `node` name is a **startup**
+failure, not a run-time one.
+
+This is what keeps the DSL from having a ceiling: the answer to "the DSL cannot express this" is
+always *ship a node and name it*, never *embed a script*. Engineers extend the vocabulary; authors
+compose it.
+
+---
+
+## 6. Document shape
+
+```json
+{
+ "dsl": "abacus.workflow/1.0",
+ "name": "order-settlement",
+ "version": "1.2.0",
+ "description": "Prices an order, escalates large ones, settles.",
+
+ "context": { "type": "object", "required": ["orderId"], "properties": { … } },
+ "result": { "$ref": "#/$defs/Settlement" },
+
+ "start": "validate",
+ "output": ["complete"],
+
+ "nodes": [
+ { "id": "validate", "kind": "transform",
+ "set": { "total": "$ctx.lines[0].unitPrice * $ctx.lines[0].quantity" } },
+
+ { "id": "settle", "kind": "http",
+ "method": "POST",
+ "url": "https://ledger.internal/v1/settlements",
+ "allowedHosts": ["ledger.internal"],
+ "body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}",
+ "gate": {
+ "mode": "conditional",
+ "when": "$.total > 25000",
+ "reason": "RegulatedSettlement",
+ "assignTo": ["group:finance", "user:cfo"],
+ "requireApprovers": 2,
+ "expiresAfter": "PT8H",
+ "onExpiry": { "action": "escalate", "assignTo": ["group:exec"] },
+ "allowModification": true,
+ "requireSegregationOfDuties": true,
+ "locked": true
+ } },
+
+ { "id": "complete", "kind": "transform", "set": { "status": "'settled'" } }
+ ],
+
+ "edges": [
+ { "from": "validate", "to": "settle", "when": "$.total > 0" },
+ { "from": "settle", "to": "complete" }
+ ],
+
+ "triggers": [ { "topic": "orders.placed", "correlationKey": "$.orderId" } ],
+ "notifications": { "level": "standard", "stream": true, "emits": ["priced"] },
+ "onFailure": [ { "match": { "exception": "ApiCallFailureException", "status": "5xx" },
+ "disposition": "retry" } ],
+ "audit": { "sections": ["submission", "outcome"] },
+ "limits": { "maxAttempts": 5 }
+}
+```
+
+Two things about this shape are load-bearing.
+
+**`dsl` is a versioned media identifier, not decoration.** `abacus.workflow/1.0` selects the schema
+and the interpreter. A future `1.1` adds optional fields and stays readable by a `1.0` interpreter; a
+`2.0` does not, and the interpreter refuses it by major version rather than failing on a field it
+does not recognise.
+
+**Templates and expressions are different surfaces.** `{{ … }}` inside a string is the existing
+`TemplateEngine`, extended to evaluate AbEx and to render `ctx`/`data` roots. A bare string in
+`when`, `set` or `correlationKey` is AbEx directly. Mixing the two conventions in one field would be
+ambiguous, so no field accepts both.
+
+---
+
+## 7. Validation is two phases, because one is not enough
+
+**Phase 1 — JSON Schema (Draft 2020-12).** Validates *shape*: required properties, `kind`-
+discriminated variants via `if`/`then`, id patterns (`^[a-z][a-z0-9-]{0,63}$`), SemVer, ISO-8601
+durations, topic patterns, enum values. Published at `docs/schema/abacus-workflow-dsl-1.0.json` so an
+editor gives completion and inline errors before the document reaches the host.
+
+**Phase 2 — the semantic validator.** JSON Schema cannot express any of this, and every item is a
+real way to write a structurally valid document that is nonsense:
+
+| Check | Why it is not a schema concern |
+| --- | --- |
+| Node ids unique | Schema cannot compare array items |
+| Every edge endpoint exists | Cross-reference |
+| `start` and every `output` name a real node | Cross-reference |
+| No unreachable node | Graph traversal |
+| No cycle without a `delay` or `wait-event` on it | Graph traversal; a tight loop is a hot spin |
+| Every AbEx expression parses, with known functions | Sub-language |
+| No non-deterministic function in a condition or predicate | Sub-language + position |
+| Gate absent on non-gateable kinds | Cross-field |
+| `custom` node names a registered factory | Environment |
+| `with` matches the factory's schema | Environment |
+| Egress hosts present when the host enforces them | Environment |
+| Within `limits` — nodes, edges, expression depth, bytes | Policy |
+
+Every diagnostic carries a **JSON Pointer**, a stable code, and a severity:
+
+```
+DSL0412 error /nodes/3/gate/when Unknown function 'lookupCustomer'.
+DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. Did you mean 'settle'?
+DSL0631 warn /nodes/7 Node 'notify' is unreachable from 'start'.
+```
+
+The pointer is not a nicety. A DSL without precise error locations is a DSL people abandon after the
+third unhelpful failure, and retrofitting positions into a validator is far harder than building with
+them.
+
+Validation runs at **registration**, so a bad document fails startup — the same place a bad compiled
+workflow fails. It also runs on demand at `POST /v2/dsl/validate`, which is what an authoring tool
+calls and what makes the DSL usable without a deploy cycle.
+
+---
+
+## 8. Identity, immutability, and drift
+
+A DSL workflow registers as `(name, version)` exactly like a compiled one, and inherits the
+framework's existing rule: **a published version is immutable.** Instances pin their version, and a
+document edited under a version its instances are running would rewrite history mid-flight.
+
+Enforcement is a content hash. The interpreter computes `sha256` over the document's canonical form
+(RFC 8785 JCS), records it on the descriptor, and stamps it on every instance. Registering a document
+whose `(name, version)` is already known with a different hash is a **startup failure** naming both
+hashes. Editing a workflow means bumping the version — which is what the compiled path already
+demands, stated in a way a document author will actually encounter.
+
+The hash also answers the operational question directly: *is this instance running the document I am
+looking at?*
+
+---
+
+## 9. Where documents come from
+
+**In scope for v1:** files on disk and embedded resources, registered at startup.
+
+```csharp
+builder.Services.AddWorkflowHost(config)
+ .AddDslWorkflow("workflows/order-settlement.json")
+ .AddDslWorkflowsFromDirectory("workflows/", searchPattern: "*.workflow.json")
+ .AddDslNode("score-risk", new RiskScoringNodeFactory());
+```
+
+**Deliberately out of scope for v1:** a management API that accepts documents at run time.
+
+That is not caution for its own sake. Today `IWorkflowRegistry` is immutable, built once at startup,
+and *everything* leans on it — version resolution, dispatch, the catalog API, tenant gate policy.
+Making it mutable is a genuine piece of work touching all of those, plus authorization (who may
+publish a workflow?), tenancy (whose workflow is it — and definitions are global while instances are
+tenant-scoped), and the migration of in-flight instances. It deserves its own design, not a paragraph
+at the end of this one. Phase 6 of the plan names it.
+
+The file-based path still delivers the actual win: a workflow changes without a code change, and the
+document is reviewable in a pull request.
+
+---
+
+## 10. Safety
+
+A DSL is an untrusted-input surface the moment it is authored by anyone who is not the person who
+built the host. v1 loads from disk, but the design assumes it will not stay that way.
+
+- **No code.** No delegate kind, no scripting, no reflection by name into arbitrary types. Only
+ registered factories.
+- **Bounded evaluation.** Expression depth ≤ 32, no loops or recursion in the grammar, regex match
+ timeout 200 ms, document ≤ 1 MB, nodes ≤ 500, edges ≤ 2000. All configurable down, none up.
+- **Egress unchanged.** `http` nodes go through the same `EgressGuard`. A document cannot widen an
+ allow-list the host has fixed.
+- **Redaction unchanged.** Envelopes traverse the same middleware pipeline, so the same redaction
+ applies. `ctx` travelling in every message is exactly why this matters — the design deliberately
+ puts more data in flight, and it must not put more data in logs.
+- **Gates cannot be weakened.** `locked` behaves as it does for compiled workflows: tenants may
+ tighten, never loosen.
+- **Failure classification is a whitelist.** `onFailure` matches named framework exceptions and
+ status ranges; it cannot name arbitrary types.
+
+---
+
+## 11. What this does not attempt
+
+Stated plainly so review can disagree with the boundary rather than discover it:
+
+- **Loops and iteration.** No `foreach`. Fan-out over an array is the intended shape, and unbounded
+ iteration in a checkpointed engine has real semantics to work out. Deferred, not forgotten.
+- **Sub-workflows.** The engine supports `workflow.BindAsExecutor(id)`. Composing DSL documents needs
+ a resolution and versioning story of its own. Phase 6.
+- **A surface syntax.** JSON is the interchange format. A YAML front end or a visual editor sits
+ *above* this and produces these documents; neither belongs in the interpreter.
+- **Round-tripping compiled workflows.** A compiled definition cannot be exported as a document. The
+ DSL is not a serialization of C#; it is a different way in.
+
+---
+
+## 12. Summary
+
+One envelope type makes the graph uniformly typed. One closed expression language makes conditions
+expressible without making them dangerous. One named catalog with a registration seam makes the DSL
+extensible without making it a scripting host. Two-phase validation with pointer-accurate diagnostics
+makes it usable. A content hash makes it honest about versions.
+
+Everything else is the runtime that already exists.
diff --git a/docs/implementation/07-workflow-dsl-implementation-plan.md b/docs/implementation/07-workflow-dsl-implementation-plan.md
new file mode 100644
index 0000000..5665d68
--- /dev/null
+++ b/docs/implementation/07-workflow-dsl-implementation-plan.md
@@ -0,0 +1,458 @@
+# Workflow DSL — implementation plan
+
+Realizes [06-workflow-dsl-design.md](06-workflow-dsl-design.md). Nothing here changes how a compiled
+workflow behaves; the DSL is a second front end onto the runtime that already exists.
+
+**Status: in progress.**
+
+| # | Phase | Delivers | Depends on | Status |
+| - | ----- | -------- | ---------- | ------ |
+| 1 | Envelope and expression core | `DslMessage`, AbEx parser and evaluator | — | ✅ Done |
+| 2 | Document model and validation | Parser, JSON Schema, semantic validator, diagnostics | 1 | ✅ Done |
+| 3 | Interpreter | `DslWorkflowDefinition`, node factories, graph construction | 1, 2 | ✅ Done |
+| 4 | Host integration | Registration, `IContextValidatingWorkflow`, catalog and validate endpoints | 3 | ✅ Done |
+| 5 | Documentation and worked example | Wiki chapter, README, a shipped example document | 4 | ✅ Done |
+| 6 | Deferred | Runtime publication API, sub-workflows, iteration | 5 | ⬜ Out of scope |
+
+## Status
+
+Phases 1–5 landed, complete against the plan as written. Suites green: **723 unit** (unchanged),
+**361 DSL unit**, **232 integration** (+82), **7 chaos**.
+
+| Delivered | Where |
+| --------- | ----- |
+| `DslMessage` envelope, `$run` metadata, self-resolving templates | [Interpretation/DslMessage.cs](../../src/Abacus.Run.Dsl/Interpretation/DslMessage.cs) |
+| AbEx lexer, parser, AST, evaluator, static analysis, closed function set | [Expressions/](../../src/Abacus.Run.Dsl/Expressions/) |
+| Typed document model with a JSON Pointer on every element | [Model/](../../src/Abacus.Run.Dsl/Model/) |
+| Schema validation, 22-code semantic validator, canonical hash | [Validation/](../../src/Abacus.Run.Dsl/Validation/) |
+| Entry/exit nodes, per-kind factories, hosted-executor adapter | [Interpretation/DslBuiltInNodes.cs](../../src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs) |
+| Graph construction, gates, failure rules, notifications, triggers | [Interpretation/DslWorkflowDefinition.cs](../../src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs) |
+| Deferred registration, directory loading, custom node catalog | [Hosting/](../../src/Abacus.Run.Dsl/Hosting/) |
+| `IContextValidatingWorkflow`, consulted after the type bind | [Core/WorkflowRegistry.cs](../../src/Abacus.Run/Core/WorkflowRegistry.cs) |
+| `ITemplateBindingSource` | [Executors/TemplateEngine.cs](../../src/Abacus.Run/Executors/TemplateEngine.cs) |
+| `IDocumentAuthoredWorkflow`, so the catalog reports `source` and `documentHash` | [Abstractions/WorkflowDefinition.cs](../../src/Abacus.Run/Abstractions/WorkflowDefinition.cs), [Api/Endpoints.cs](../../src/Abacus.Run/Api/Endpoints.cs) |
+| `/dsl/schema`, `/dsl/nodes`, `/dsl/functions`, `/dsl/documents`, `/dsl/validate` | [Hosting/DslEndpoints.cs](../../src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs) |
+| Architecture boundary tests for the DSL project | [ArchitectureBoundaryTests.cs](../../tests/Abacus.Run.IntegrationTests/ArchitectureBoundaryTests.cs) |
+| Redaction and large-context coverage | [DslEnvelopeTests.cs](../../tests/Abacus.Run.IntegrationTests/DslEnvelopeTests.cs) |
+| Wiki chapter, README section, project-layout rows | [wiki.md](../wiki.md#authoring-with-the-dsl), [README.md](../../README.md) |
+| Shipped example document | [example-order.workflow.json](../../src/Abacus.Run.Service/Workflows/ExampleOrder/example-order.workflow.json) |
+
+### Deviations from the plan as written
+
+**The core change is three interfaces, not one.** `IContextValidatingWorkflow` was planned.
+
+`ITemplateBindingSource` was not: `TemplateBindings` resolves dotted paths by reflection over a
+single root object, which cannot address an envelope carrying both a context and a payload. It is
+additive and opt-in — a type that does not implement it resolves exactly as before — and it is what
+lets `{{ $ctx.orderId }}` work inside the existing `ApiCallExecutor` and `LlmExecutor` rather than
+forking either.
+
+`IDocumentAuthoredWorkflow` was not either, and is what §4.3's fourth row needed. The catalog cannot
+report `source: "dsl"` by naming the DSL, because `Abacus.Run` does not reference it and must not. So
+a definition answers for itself: `GET /v2/workflows/{name}` reports what the definition says, or
+`"compiled"` with a null hash for one that says nothing. Same probe-by-`is` pattern the runtime
+already uses for `INotifyingWorkflow`, and the framework still knows of no front end.
+
+**Two grammar changes.** Unary `!` and `-` bind tightest, rather than sitting between `&&` and
+comparison as first written — `!has($.x) && …` is the common shape and standard precedence is what
+an author expects. And bare-identifier path roots are gone: every path starts `$`, `$ctx` or `$run`,
+which removes a real ambiguity between a path and a function name.
+
+**The graph has an entry and an exit node.** Neither was planned. The runner sends the deserialized
+context as the first message, typed `JsonElement`, and the engine routes by type — so without a node
+typed to receive it the first DSL node never runs and the workflow completes having done nothing.
+The exit node exists for the mirror reason: `YieldOutputAsync` is checked against the executor's
+declared output type, so a DSL node cannot yield anything but an envelope, and the caller would
+otherwise get the start context back as though it were a result. Both use ids (`$entry`, `$exit`)
+that a declared node id cannot collide with.
+
+**Fan-in aggregates across invocations.** The plan assumed `AddFanInBarrierEdge` delivers a list.
+It does not: `FanInEdgeRunner` type-checks the target against the *individual* message and delivers
+the released messages separately. The DSL node therefore holds arrivals and emits once the last one
+lands, with the expected count read from the document. See the note below — the framework's own
+`FanInExecutor` has the same problem and does not work with a barrier edge.
+
+**The parity test is not `ExampleOrderWorkflow`.** The plan said to express the shipped example as a
+document and assert both produce the same result. It cannot be expressed: `ExampleOrderWorkflow` sums
+an array of order lines, and the DSL has no iteration — which is exactly the limitation §11 of the
+design records, found by trying to hit it. The parity pair is instead a purpose-built workflow
+authored both ways, which still makes the point it was there to make: same runtime, same answer, two
+front ends. It also pins decimal arithmetic across both, where a DSL quietly using binary floating
+point would diverge. The shipped `example-order.workflow.json` is a document the DSL *can* express,
+covered by tests asserting it validates and registers.
+
+**Trigger `correlationKey` is a literal, not an expression.** A trigger subscription is registered
+before any message exists, so there is nothing for a path to read. The validator now warns when one
+is written to look like an expression rather than silently evaluating or silently dropping it.
+`contextFrom` *is* an expression, and is wired to `DomainEventTrigger.ContextSelector`.
+
+### Defects found by these tests
+
+**`AbExValue.FromNode` misread numbers.** It probed CLR types in turn, and a `JsonValue` created
+from an `int` will not hand back a `decimal` — so `JsonValue.Create(200)` fell through to the string
+branch and an HTTP status of 200 compared as `"200"`, never equalling `200`. Now classified by
+`GetValueKind()` first. Regression-tested across int, long, double, decimal and float backing, and
+across a serialization round trip.
+
+**The semantic validator crashed on duplicate node ids** — one of the things it exists to report —
+because it built its kind lookup with `ToDictionary`. Now built tolerantly, with robustness tests
+over pathological documents.
+
+**A custom node of the wrong shape was retried.** A factory that hands back anything other than
+`HostExecutor` is refused during the build, which was correct, but the
+failure classified as retryable: the run burned its whole attempt budget re-deriving the same
+message before stopping. Interpretation failures now throw `DslInterpretationException` and classify
+as a dead stop ahead of the document's own `onFailure` rules — a document that cannot be interpreted
+will not interpret on the next attempt either, and an author does not get a say in that one. Found by
+writing the integration test for the shape check, which had been registered in the fixture but never
+exercised.
+
+### Pre-existing issues found, not fixed here
+
+**`FanInExecutor` cannot work with `AddFanInBarrierEdge`.** It is declared
+`HostExecutor, TOut>`, but `FanInEdgeRunner.ChaseEdgeAsync` filters released messages by
+`CanHandle(target, individualMessageType)` — a target declaring `List` matches nothing and the
+delivery is dropped as a type mismatch. Nothing in the repository exercises it, and the wiki's
+"the barrier delivers a list" is wrong. Out of scope for the DSL, which works around it, but it is a
+real defect in the compiled surface.
+
+**No `ITimerService` is registered anywhere in the host.** `DelayExecutor` requires one, so a `delay`
+node — and a compiled workflow using `DelayExecutor` — cannot run on a stock host. The DSL test
+fixture registers an in-memory implementation; a deployable host has nothing.
+
+Phases 1–2 are independently testable with no host involved and carry most of the risk. Phase 3 is
+mechanical once they land. Phase 4 is small — deliberately, because every core change it needs is an
+opt-in interface a definition may implement, and the three it added together come to a few dozen
+lines of framework code.
+
+---
+
+## Project layout
+
+A new project, `src/Abacus.Run.Dsl`, referencing `Abacus.Run` and referenced by the host.
+
+Keeping it out of `Abacus.Run` is not tidiness. The DSL pulls in a JSON Schema validator and an
+expression parser; a host that authors every workflow in C# should not carry either. The existing
+architecture boundary tests enforce the layering, and this project sits at the same level as the
+adapter projects: `Abstractions ← Core ← {Executors, …} ← Api`, with `Dsl` depending on the public
+surface only.
+
+```
+src/Abacus.Run.Dsl/
+ Model/ DslDocument, DslNode, DslEdge, … (the parsed document)
+ Expressions/ AbExLexer, AbExParser, AbExNode, AbExEvaluator, AbExValidator
+ Validation/ DslSchemaValidator, DslSemanticValidator, DslDiagnostic
+ Interpretation/ DslWorkflowDefinition, DslMessage, node factories
+ Hosting/ AddDslWorkflow, AddDslNode, IDslNodeFactory
+ Schema/ abacus-workflow-dsl-1.0.json (embedded resource)
+```
+
+The schema is authored at [docs/schema/abacus-workflow-dsl-1.0.json](../schema/abacus-workflow-dsl-1.0.json)
+and embedded from there — one copy, so the published schema and the enforced one cannot drift. A test
+asserts the embedded resource is byte-identical to the file.
+
+---
+
+## Phase 1 — Envelope and expression core
+
+No host, no document, no DI. Pure data and a parser, which is what makes this phase cheap to test
+exhaustively and worth doing first.
+
+### 1.1 `DslMessage`
+
+```csharp
+public sealed class DslMessage
+{
+ public JsonObject Ctx { get; } // frozen at start, copied through unchanged
+ public JsonNode? Data { get; } // the current value
+ public DslMeta Meta { get; } // node id, superstep, attempt
+
+ public DslMessage WithData(JsonNode? data);
+ public static DslMessage Start(JsonElement context);
+}
+```
+
+Immutable, so a message captured by a checkpoint cannot be mutated by a later node. `Ctx` is cloned
+once at start and never again — the copy-through is a reference copy, which is what keeps a large
+context from being duplicated per node.
+
+### 1.2 AbEx
+
+Hand-written recursive-descent lexer and parser producing an immutable AST. No parser generator: the
+grammar is a page long, and a hand-written parser is what gives the precise column positions the
+diagnostics in Phase 2 depend on.
+
+- `AbExParser.Parse(string) → AbExResult` — AST or a diagnostic with an offset. Never throws on bad
+ input; a malformed expression is data.
+- `AbExEvaluator.Evaluate(AbExNode, DslMessage, RunMetadata) → AbExValue` — total. Absence is a
+ value, never an exception.
+- `AbExValidator.Analyse(AbExNode) → ExpressionFacts` — unknown functions, arity errors, depth, and
+ **whether the expression is deterministic**. The determinism flag is what Phase 2's positional rule
+ reads.
+
+`AbExValue` is a small struct union over the JSON types plus *absent*. Arithmetic on numbers is
+`decimal`.
+
+**Tests (~120).** Precedence and associativity for every operator. Absence propagation through each
+function and operator. Strict boolean coercion — `0`, `""`, `null` and absent are all false. Ordinal
+string comparison. Decimal arithmetic including `0.1 + 0.2`. Division by zero → absent. Depth limit.
+`matches` timeout. Every parse error carries the right offset. Round-trip: parse → print → parse.
+
+### 1.3 Template integration
+
+`TemplateEngine` currently resolves dotted paths through `TemplateBindings`. Extend it with an AbEx
+binding source rather than replacing it — `{{ $ctx.orderId }}` and `{{ $.total * 1.2 }}` both work,
+and the existing `{{ context.Field }}` form continues to resolve unchanged so no compiled workflow
+using a template breaks.
+
+**Tests (~25).** New forms, old forms, mixed, unterminated placeholder, absent → empty string.
+
+---
+
+## Phase 2 — Document model and validation
+
+### 2.1 Model
+
+Records mirroring the schema: `DslDocument`, `DslNode` (a discriminated hierarchy by `kind`),
+`DslEdge`, `DslGate`, `DslTrigger`, `DslNotifications`, `DslFailureRule`, `DslAudit`, `DslLimits`.
+
+Parsing is `System.Text.Json` with a custom converter on `DslNode` reading `kind` first. The parser
+records a **JSON Pointer for every node it builds** — the diagnostics are only as good as the
+positions, and positions retrofitted into a validator are far more expensive than positions built in.
+
+### 2.2 Schema validation
+
+Draft 2020-12 via `JsonSchema.Net`. Structural errors map to `DslDiagnostic` with the pointer the
+validator reports.
+
+The published schema is already exercised: it checks as a legal Draft 2020-12 document, accepts the
+design's worked example, and rejects 17 hand-written malformed variants — bad `dsl` version,
+uppercase node id, unknown `kind`, `transform` without `set`, `http` without `url`, `conditional`
+gate without `when`, `escalate` expiry without assignees, malformed duration and principal, edge
+without `to`, `when` on a barrier edge, `select` with one target, an unlisted exception name, an
+unknown top-level property, a gate on a `fan-in` node, and a `custom` node without `node`. Those
+cases become the Phase 2 fixtures rather than being written again from scratch.
+
+### 2.3 Semantic validation
+
+Everything JSON Schema cannot express, from the design's table. Each check gets a stable code:
+
+| Code | Check |
+| --- | --- |
+| `DSL0101` | `dsl` major version is supported |
+| `DSL0102` | Document hash matches a previously registered `(name, version)` |
+| `DSL0201` | Node ids unique |
+| `DSL0202` | `start` names a real node |
+| `DSL0203` | Every `output` entry names a real node |
+| `DSL0207` | Every edge endpoint exists (with a nearest-match suggestion) |
+| `DSL0208` | No duplicate unconditional edge unless `idempotent` |
+| `DSL0301` | Every node reachable from `start` |
+| `DSL0302` | Every non-terminal node has an outgoing edge |
+| `DSL0303` | No cycle without a `delay` or `wait-event` on it |
+| `DSL0304` | Fan-in barrier sources all reach it |
+| `DSL0401` | Every expression parses |
+| `DSL0412` | Every function is known, with correct arity |
+| `DSL0413` | No non-deterministic function in an edge condition or gate predicate |
+| `DSL0414` | Expression depth within limits |
+| `DSL0501` | Gate absent on non-gateable kinds |
+| `DSL0502` | `conditional` mode has a `when` |
+| `DSL0503` | `escalate` expiry names escalation assignees |
+| `DSL0601` | `custom` node names a registered factory |
+| `DSL0602` | `with` satisfies the factory's schema |
+| `DSL0603` | `http` node declares allowed hosts when the host enforces egress |
+| `DSL0701` | Document within size, node, and edge limits |
+
+Codes `DSL06xx` need the host's registrations, so the validator takes an optional
+`DslEnvironment` — present at registration and at `POST /v2/dsl/validate`, absent for offline
+linting, which then reports those checks as skipped rather than passing. **Silently passing a check
+that never ran is worse than not running it**, so the result distinguishes the two.
+
+```csharp
+public sealed record DslDiagnostic(
+ string Code, DslSeverity Severity, string Pointer, string Message, string? Suggestion);
+
+public sealed record DslValidationResult(
+ bool IsValid,
+ IReadOnlyList Diagnostics,
+ IReadOnlyList SkippedChecks);
+```
+
+### 2.4 Canonical hash
+
+RFC 8785 JCS canonicalization then SHA-256. Used for the immutability rule in §8 of the design.
+
+**Tests (~150).** One valid-document fixture per node kind. One invalid fixture per diagnostic code,
+asserting **code, pointer, and severity** — a validator whose messages are untested drifts into
+uselessness. Hash stability across key reordering and whitespace. Skipped-check reporting with no
+environment.
+
+---
+
+## Phase 3 — Interpreter
+
+### 3.1 `DslWorkflowDefinition`
+
+```csharp
+public sealed class DslWorkflowDefinition
+ : IWorkflowDefinition,
+ IContextValidatingWorkflow
+{
+ public ValueTask BuildAsync(WorkflowBuildContext context, CancellationToken ct);
+ public FailureDisposition Classify(WorkflowFailure failure);
+}
+```
+
+Conditionally implements `IAuditedWorkflowDefinition`, `IEventTriggeredWorkflow` and
+`INotifyingWorkflow` when the document declares the corresponding block. The registry and runtime
+already probe for these with `is`, so a definition that implements one it does not need would declare
+an empty policy — hence three thin wrapper types selected at registration rather than one type that
+always implements everything.
+
+`BuildAsync` runs per attempt and must be cheap. The **parsed and validated document is cached at
+registration**; a build walks the model and constructs executors, and never re-parses or re-validates.
+Expression ASTs are parsed once at registration too, so a build binds already-parsed trees.
+
+### 3.2 Node factories
+
+`IDslNodeFactory` with a built-in implementation per `kind`, plus the registration seam:
+
+```csharp
+public interface IDslNodeFactory
+{
+ string Name { get; }
+ JsonNode? ParameterSchema { get; } // validated against 'with' at registration
+ IHostExecutor Create(DslNodeContext context);
+}
+```
+
+`DslNodeContext` carries the node model, the parsed expressions, and the `WorkflowBuildContext` — so
+a factory reaches `Services` and `Audit` the same way a compiled definition does.
+
+Each built-in factory wraps its existing executor in a `DslMessage`-shaped adapter that unwraps
+`data`, invokes, and rewraps. The adapters are the only genuinely new execution code in this phase,
+and each is a few lines.
+
+### 3.3 Graph construction
+
+Walk `edges`, mapping to `AddEdge` / `AddEdge(condition)` / `AddFanOutEdge` /
+`AddFanInBarrierEdge`. Conditions close over a pre-parsed AST. `WithOutputFrom` binds the `output`
+nodes; `Build(validateOrphans: true)` — the semantic validator has already established reachability,
+so this should never fire, and if it does that is a validator bug worth surfacing loudly.
+
+### 3.4 Failure classification
+
+Compile `onFailure` into a matcher chain, falling through to `DefaultFailureClassifier.Instance`.
+
+**Tests (~110 unit, ~30 integration).** Each node kind builds and runs end to end. Conditional
+routing, fan-out, fan-in, selector fan-out. A gated node parks and resumes on approval. A
+`wait-event` node parks, receives, and resumes. A `delay` node checkpoints and releases its lease.
+Failure rules classify. `custom` factory receives its `with`. Envelope `ctx` survives to the last
+node. Strict mode rejects a shape violation as dead-stop.
+
+---
+
+## Phase 4 — Host integration
+
+### 4.1 The one core change
+
+`IContextValidatingWorkflow` in `Abacus.Run/Abstractions`, consulted by
+[`WorkflowRegistry.ValidateContext`](../../src/Abacus.Run/Core/WorkflowRegistry.cs) **after** the
+existing type bind succeeds. Additive and opt-in: a definition that does not implement it behaves
+exactly as today.
+
+### 4.2 Registration
+
+```csharp
+builder.Services.AddWorkflowHost(config)
+ .AddDslWorkflow("workflows/order-settlement.json")
+ .AddDslWorkflowsFromDirectory("workflows/", "*.workflow.json")
+ .AddDslNode("score-risk", new RiskScoringNodeFactory());
+```
+
+Each registration parses, validates against the full environment, computes the hash, and registers an
+`IWorkflowDefinition`. **A document that fails validation fails startup**, with every diagnostic
+written to the log — the same place a bad compiled workflow fails, and for the same reason.
+
+### 4.3 Endpoints
+
+| Route | Purpose |
+| --- | --- |
+| `POST /v2/dsl/validate` | Validate a document without registering it. Returns diagnostics. What an authoring tool calls. |
+| `GET /v2/dsl/schema` | The published JSON Schema, for editor completion |
+| `GET /v2/dsl/nodes` | The registered node catalog with parameter schemas |
+| `GET /v2/workflows/{name}` | Extended with `source: "dsl" \| "compiled"` and, for DSL, `documentHash` |
+
+`POST /v2/dsl/validate` needs the same authorization as the catalog routes. It reflects the
+environment's registered node names back to the caller, which is information about the host — not
+secret, but not anonymous either.
+
+**Tests (~40 integration).** Startup fails on an invalid document, with diagnostics logged. Startup
+fails on a hash conflict for an existing `(name, version)`. A DSL workflow appears in the catalog and
+starts through the normal route. Context schema violations return 400 with field errors. The validate
+endpoint returns pointer-accurate diagnostics. Schema endpoint matches the file on disk.
+
+---
+
+## Phase 5 — Documentation and example
+
+- A new wiki chapter, **Authoring with the DSL**, placed beside *Authoring a workflow*, with the same
+ structure — document shape, node reference, expression reference, validation, limits — and a
+ parallel appendix mapping each existing A.1–A.10 variation to its DSL equivalent. The compiled and
+ DSL paths should be legible side by side, because the honest reason to pick one over the other is
+ what a reader most needs.
+- README: a short section, and the DSL named in the feature list.
+- `src/Abacus.Run.Service/Workflows/ExampleOrder/example-order.workflow.json` — the existing
+ `ExampleOrderWorkflow` expressed as a document, registered alongside the compiled one under a
+ different name. A test asserts both produce the same result for the same context, which is the
+ clearest possible statement that the DSL is a front end and not a fork.
+- The design doc's §11 boundaries restated in the wiki, so a reader hits the limits in the docs rather
+ than in an error message.
+
+---
+
+## Phase 6 — Deferred, and why
+
+Named rather than silently omitted; each is a design of its own.
+
+**Runtime publication API.** `IWorkflowRegistry` is immutable and built once at startup, and version
+resolution, dispatch, the catalog and tenant gate policy all lean on that. Making it mutable also
+raises authorization (who may publish?), tenancy (definitions are global while instances are
+tenant-scoped), and in-flight instance migration. The file-based path already delivers the core win —
+a workflow changes without a code change — so this is a genuine next step, not a missing piece.
+
+**Sub-workflows.** The engine supports `workflow.BindAsExecutor(id)`. Composing documents needs
+resolution, version pinning, and cycle detection across documents.
+
+**Iteration.** Unbounded loops in a checkpointed engine have real semantics to establish — the
+checkpoint's size, the superstep count, and what a retry means mid-iteration. Fan-out over an array
+covers the common case in v1.
+
+---
+
+## Risk
+
+| Risk | Mitigation |
+| --- | --- |
+| The expression language grows into a scripting host | The function set is closed and small; extension goes through `custom` nodes, not new syntax. Adding a function is a deliberate change to a documented list. |
+| Diagnostics are unhelpful and the DSL is abandoned | Pointer accuracy is a tested requirement from Phase 2, not a polish item. Every diagnostic code has a test asserting its pointer. |
+| The envelope's `ctx` inflates checkpoints | `Ctx` is a reference copy, cloned once at start. Measured: a 128 KB context through a four-hop document, asserting the checkpoint holds about one context rather than one per hop, and that the last superstep is no heavier than the first. |
+| Redaction gaps — more data is in flight per message | DSL nodes traverse the same middleware pipeline. Measured under a restrictive policy: the node reads the secret, no event in the run carries it, and the instance's own state still does — so the run can still resume. |
+| Schema drift between published and embedded | One file, embedded from `docs/schema/`; a test asserts byte equality. |
+| The DSL looks like it can do anything and cannot | §11 of the design and the wiki chapter both state the boundary. `custom` is presented as the answer, not as an escape hatch. |
+
+---
+
+## Verification summary
+
+| Suite | Added | Covers |
+| --- | --- | --- |
+| Unit | 361 | AbEx, model, validation, interpreter, factories, failure classification |
+| Integration | 82 | Startup, egress enforcement, catalog and provenance, endpoints and their authorization, end-to-end runs of every node kind, redaction, checkpoint size, parity |
+| Architecture | 4 | `Abacus.Run.Dsl` depends only on the public surface; no host or infrastructure reference; the framework does not reference the DSL; the schema ships as one embedded resource |
+
+The DSL suite is its own project, so those 361 are not part of the 723 the framework already had.
+Byte equality between the embedded schema and the published file is asserted in the DSL suite, where
+the resource is readable; the architecture test asserts there is exactly one such resource.
+
+`dotnet build Abacus.Run.slnx` then `dotnet test`, with the existing suites unchanged — the
+`IContextValidatingWorkflow` hook is the only core edit, and nothing implements it today.
diff --git a/docs/schema/abacus-workflow-dsl-1.0.json b/docs/schema/abacus-workflow-dsl-1.0.json
new file mode 100644
index 0000000..25151fb
--- /dev/null
+++ b/docs/schema/abacus-workflow-dsl-1.0.json
@@ -0,0 +1,444 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://abacus.run/schema/abacus-workflow-dsl-1.0.json",
+ "title": "Abacus workflow DSL",
+ "description": "Declarative workflow definition. Phase 1 (structural) validation only; graph reachability, expression parsing, and catalog resolution are the semantic validator's job.",
+ "type": "object",
+ "required": ["dsl", "name", "version", "start", "nodes"],
+ "additionalProperties": false,
+
+ "properties": {
+ "dsl": {
+ "description": "Media identifier selecting schema and interpreter. Major version must match.",
+ "const": "abacus.workflow/1.0"
+ },
+ "name": { "$ref": "#/$defs/workflowName" },
+ "version": { "$ref": "#/$defs/semver" },
+ "description": { "type": "string", "maxLength": 1024 },
+
+ "context": {
+ "description": "JSON Schema the start payload must satisfy. Enforced by IContextValidatingWorkflow.",
+ "$ref": "https://json-schema.org/draft/2020-12/schema"
+ },
+ "result": {
+ "description": "JSON Schema the workflow result is expected to satisfy. Advisory unless strict.",
+ "$ref": "https://json-schema.org/draft/2020-12/schema"
+ },
+ "strict": {
+ "description": "Enforce declared node input/output schemas at run time. A violation is dead-stop.",
+ "type": "boolean",
+ "default": false
+ },
+
+ "start": { "$ref": "#/$defs/nodeId" },
+ "output": {
+ "description": "Nodes whose result binds the workflow output. Defaults to terminal nodes.",
+ "type": "array",
+ "items": { "$ref": "#/$defs/nodeId" },
+ "minItems": 1,
+ "uniqueItems": true
+ },
+
+ "nodes": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 500,
+ "items": { "$ref": "#/$defs/node" }
+ },
+ "edges": {
+ "type": "array",
+ "maxItems": 2000,
+ "items": { "$ref": "#/$defs/edge" }
+ },
+
+ "triggers": { "type": "array", "items": { "$ref": "#/$defs/trigger" } },
+ "notifications": { "$ref": "#/$defs/notifications" },
+ "onFailure": { "type": "array", "items": { "$ref": "#/$defs/failureRule" } },
+ "audit": { "$ref": "#/$defs/audit" },
+ "limits": { "$ref": "#/$defs/limits" }
+ },
+
+ "$defs": {
+ "workflowName": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9-]{0,63}$",
+ "description": "Lowercase kebab-case. Matches the registry's resolution key."
+ },
+ "nodeId": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9-]{0,63}$",
+ "description": "Stable identity: gate policy, node state and per-node notification overrides all key off it. Renaming one in a published version orphans tenant policy."
+ },
+ "semver": {
+ "type": "string",
+ "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
+ },
+ "duration": {
+ "type": "string",
+ "pattern": "^P(?!$)(\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$",
+ "description": "ISO-8601 duration, e.g. PT8H."
+ },
+ "expression": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2048,
+ "description": "AbEx expression. Roots: $ (current data), $ctx (start context), $run (run metadata). Parsed by the semantic validator."
+ },
+ "template": {
+ "type": "string",
+ "maxLength": 65536,
+ "description": "String with {{ expression }} interpolation."
+ },
+ "topicPattern": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9_.*#-]+$",
+ "description": "Dot-segmented topic. '*' matches one segment; '#' matches the remainder and may appear only last."
+ },
+ "principal": {
+ "type": "string",
+ "pattern": "^(user|group|role):[A-Za-z0-9._@-]+$"
+ },
+
+ "gate": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["mode"],
+ "properties": {
+ "mode": { "enum": ["autonomous", "requireApproval", "conditional"] },
+ "when": { "$ref": "#/$defs/expression" },
+ "reason": { "type": "string", "maxLength": 256 },
+ "assignTo": { "type": "array", "items": { "$ref": "#/$defs/principal" } },
+ "requireApprovers": { "type": "integer", "minimum": 1, "default": 1 },
+ "expiresAfter": { "$ref": "#/$defs/duration", "default": "PT24H" },
+ "onExpiry": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["action"],
+ "properties": {
+ "action": { "enum": ["deadStop", "reject", "autoApprove", "escalate"] },
+ "assignTo": { "type": "array", "items": { "$ref": "#/$defs/principal" } }
+ }
+ },
+ "allowModification": { "type": "boolean", "default": false },
+ "requireSegregationOfDuties": { "type": "boolean", "default": false },
+ "locked": {
+ "type": "boolean",
+ "default": false,
+ "description": "Author's floor. Tenants may tighten, never loosen."
+ }
+ },
+ "allOf": [
+ {
+ "if": { "properties": { "mode": { "const": "conditional" } }, "required": ["mode"] },
+ "then": { "required": ["when"] }
+ },
+ {
+ "if": { "properties": { "onExpiry": { "properties": { "action": { "const": "escalate" } }, "required": ["action"] } }, "required": ["onExpiry"] },
+ "then": { "properties": { "onExpiry": { "required": ["assignTo"] } } }
+ }
+ ]
+ },
+
+ "nodeCommon": {
+ "type": "object",
+ "properties": {
+ "id": { "$ref": "#/$defs/nodeId" },
+ "kind": { "type": "string" },
+ "description": { "type": "string", "maxLength": 512 },
+ "input": { "$ref": "https://json-schema.org/draft/2020-12/schema" },
+ "output": { "$ref": "https://json-schema.org/draft/2020-12/schema" },
+ "gate": { "$ref": "#/$defs/gate" },
+ "notify": {
+ "description": "Workflow-defined notification emitted after this node succeeds. Name is prefixed 'custom.' by the framework.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name"],
+ "properties": {
+ "name": { "type": "string", "pattern": "^[A-Za-z0-9]+(\\.[A-Za-z0-9]+)*$" },
+ "payload": { "type": "object", "additionalProperties": { "$ref": "#/$defs/expression" } }
+ }
+ }
+ }
+ },
+
+ "node": {
+ "type": "object",
+ "required": ["id", "kind"],
+ "allOf": [
+ { "$ref": "#/$defs/nodeCommon" },
+ {
+ "properties": {
+ "kind": {
+ "enum": ["transform", "http", "llm", "delay", "approval", "publish", "wait-event", "fan-in", "custom"]
+ }
+ }
+ },
+
+ {
+ "if": { "properties": { "kind": { "const": "transform" } }, "required": ["kind"] },
+ "then": {
+ "required": ["set"],
+ "properties": {
+ "set": {
+ "description": "Target path within data -> AbEx expression. Applied to a copy; source paths read the pre-transform value.",
+ "type": "object",
+ "minProperties": 1,
+ "additionalProperties": { "$ref": "#/$defs/expression" }
+ },
+ "replace": {
+ "type": "boolean",
+ "default": false,
+ "description": "Replace data entirely rather than merging into it."
+ }
+ }
+ }
+ },
+
+ {
+ "if": { "properties": { "kind": { "const": "http" } }, "required": ["kind"] },
+ "then": {
+ "required": ["url"],
+ "properties": {
+ "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"], "default": "GET" },
+ "url": { "$ref": "#/$defs/template" },
+ "headers": { "type": "object", "additionalProperties": { "$ref": "#/$defs/template" } },
+ "body": { "$ref": "#/$defs/template" },
+ "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600, "default": 30 },
+ "successCodes": { "type": "array", "items": { "type": "integer", "minimum": 100, "maximum": 599 } },
+ "allowedHosts": { "type": "array", "items": { "type": "string", "format": "hostname" } },
+ "sendIdempotencyKey": { "type": "boolean", "default": true }
+ }
+ }
+ },
+
+ {
+ "if": { "properties": { "kind": { "const": "llm" } }, "required": ["kind"] },
+ "then": {
+ "required": ["model", "prompt"],
+ "properties": {
+ "model": { "type": "string", "minLength": 1 },
+ "system": { "$ref": "#/$defs/template" },
+ "prompt": { "$ref": "#/$defs/template" },
+ "promptVersion": { "type": "string", "maxLength": 64 },
+ "structuredOutput": { "$ref": "https://json-schema.org/draft/2020-12/schema" },
+ "temperature": { "type": "number", "minimum": 0, "maximum": 2 },
+ "maxTokens": { "type": "integer", "minimum": 1 },
+ "streamDeltas": { "type": "boolean", "default": false },
+ "emitCompletion": { "type": "boolean", "default": true }
+ }
+ }
+ },
+
+ {
+ "if": { "properties": { "kind": { "const": "delay" } }, "required": ["kind"] },
+ "then": {
+ "required": ["for"],
+ "properties": {
+ "for": { "$ref": "#/$defs/duration" }
+ }
+ }
+ },
+
+ {
+ "if": { "properties": { "kind": { "const": "publish" } }, "required": ["kind"] },
+ "then": {
+ "required": ["topic"],
+ "properties": {
+ "topic": { "$ref": "#/$defs/topicPattern" },
+ "payload": { "type": "object", "additionalProperties": { "$ref": "#/$defs/expression" } },
+ "correlationKey": { "$ref": "#/$defs/expression" },
+ "scope": { "enum": ["local", "distributed"], "default": "local" }
+ }
+ }
+ },
+
+ {
+ "if": { "properties": { "kind": { "const": "wait-event" } }, "required": ["kind"] },
+ "then": {
+ "required": ["topic"],
+ "properties": {
+ "topic": { "$ref": "#/$defs/topicPattern" },
+ "correlationKey": { "$ref": "#/$defs/expression" },
+ "timeout": { "$ref": "#/$defs/duration" },
+ "onExpiry": { "enum": ["deadStop", "resume"], "default": "deadStop" }
+ }
+ }
+ },
+
+ {
+ "if": { "properties": { "kind": { "const": "fan-in" } }, "required": ["kind"] },
+ "then": {
+ "properties": {
+ "into": {
+ "type": "string",
+ "default": "items",
+ "description": "Path within data receiving the aggregated array."
+ }
+ }
+ }
+ },
+
+ {
+ "if": { "properties": { "kind": { "const": "custom" } }, "required": ["kind"] },
+ "then": {
+ "required": ["node"],
+ "properties": {
+ "node": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9-]{0,63}$",
+ "description": "Name registered via AddDslNode. Resolved at startup; unknown names fail registration."
+ },
+ "with": {
+ "type": "object",
+ "description": "Validated against the factory's own published schema by the semantic validator."
+ }
+ }
+ }
+ },
+
+ {
+ "$comment": "A raw-equivalent node cannot be gated, mirroring RawNode in the compiled API.",
+ "if": { "properties": { "kind": { "const": "fan-in" } }, "required": ["kind"] },
+ "then": { "not": { "required": ["gate"] } }
+ }
+ ]
+ },
+
+ "edge": {
+ "type": "object",
+ "required": ["from", "to"],
+ "additionalProperties": false,
+ "properties": {
+ "from": {
+ "oneOf": [
+ { "$ref": "#/$defs/nodeId" },
+ { "type": "array", "items": { "$ref": "#/$defs/nodeId" }, "minItems": 2, "uniqueItems": true }
+ ],
+ "description": "An array means a fan-in barrier: the target runs once every source has delivered."
+ },
+ "to": {
+ "oneOf": [
+ { "$ref": "#/$defs/nodeId" },
+ { "type": "array", "items": { "$ref": "#/$defs/nodeId" }, "minItems": 2, "uniqueItems": true }
+ ],
+ "description": "An array means fan-out to every target, or to the subset 'select' picks."
+ },
+ "when": {
+ "$ref": "#/$defs/expression",
+ "description": "Edge is traversed only when this evaluates to boolean true. Must be deterministic."
+ },
+ "select": {
+ "$ref": "#/$defs/expression",
+ "description": "Fan-out only: yields the indices of targets to send to."
+ },
+ "label": { "type": "string", "maxLength": 64 },
+ "idempotent": { "type": "boolean", "default": false }
+ },
+ "allOf": [
+ {
+ "$comment": "A barrier has one target and no condition; conditions on a barrier are ambiguous.",
+ "if": { "properties": { "from": { "type": "array" } }, "required": ["from"] },
+ "then": {
+ "properties": { "to": { "$ref": "#/$defs/nodeId" } },
+ "not": { "anyOf": [{ "required": ["when"] }, { "required": ["select"] }] }
+ }
+ },
+ {
+ "$comment": "'select' only means something when there are several targets.",
+ "if": { "required": ["select"] },
+ "then": { "properties": { "to": { "type": "array" } } }
+ }
+ ]
+ },
+
+ "trigger": {
+ "type": "object",
+ "required": ["topic"],
+ "additionalProperties": false,
+ "properties": {
+ "topic": { "$ref": "#/$defs/topicPattern" },
+ "correlationKey": { "$ref": "#/$defs/expression" },
+ "contextFrom": {
+ "$ref": "#/$defs/expression",
+ "description": "Builds the start context from the triggering message. Defaults to the whole payload."
+ }
+ }
+ },
+
+ "notifications": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "level": { "enum": ["minimal", "lifecycle", "standard"], "default": "standard" },
+ "stream": {
+ "type": "boolean",
+ "default": true,
+ "description": "Live SSE fan-out. The durable event log is not optional and cannot be disabled."
+ },
+ "byNode": {
+ "type": "object",
+ "additionalProperties": { "enum": ["minimal", "lifecycle", "standard"] }
+ },
+ "emits": {
+ "type": "array",
+ "items": { "type": "string", "pattern": "^[A-Za-z0-9]+(\\.[A-Za-z0-9]+)*$" },
+ "description": "Workflow-defined names, declared without the 'custom.' prefix."
+ }
+ }
+ },
+
+ "failureRule": {
+ "type": "object",
+ "required": ["match", "disposition"],
+ "additionalProperties": false,
+ "properties": {
+ "match": {
+ "type": "object",
+ "minProperties": 1,
+ "additionalProperties": false,
+ "properties": {
+ "exception": {
+ "enum": [
+ "WorkflowDeadStopException",
+ "ApprovalRejectedException",
+ "WorkflowValidationException",
+ "StructuredOutputException",
+ "ApiCallFailureException",
+ "LlmRateLimitException",
+ "LlmOverloadedException",
+ "DslContractException"
+ ],
+ "description": "Whitelist. A document cannot name arbitrary types."
+ },
+ "status": { "type": "string", "pattern": "^([1-5]xx|[1-5]\\d{2})$" },
+ "node": { "$ref": "#/$defs/nodeId" }
+ }
+ },
+ "disposition": { "enum": ["retry", "deadStop", "escalate"] }
+ }
+ },
+
+ "audit": {
+ "type": "object",
+ "required": ["sections"],
+ "additionalProperties": false,
+ "properties": {
+ "key": { "$ref": "#/$defs/expression", "description": "Business key the record is opened under." },
+ "sections": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
+ }
+ }
+ },
+
+ "limits": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "maxAttempts": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 },
+ "maxLifetimeHours": { "type": "integer", "minimum": 1 }
+ }
+ }
+ }
+}
diff --git a/docs/wiki.md b/docs/wiki.md
index 435ada4..23b034d 100644
--- a/docs/wiki.md
+++ b/docs/wiki.md
@@ -11,12 +11,17 @@ This page is the repository-level technical wiki. It documents the implementatio
- [Architecture](#architecture)
- [Project structure](#project-structure)
- [Getting started](#getting-started)
-- [Authoring a workflow](#authoring-a-workflow)
+- [Authoring a workflow](#authoring-a-workflow) — **[two ways in](#two-ways-to-author-a-workflow)**
- [What a definition can declare](#what-a-definition-can-declare)
- [Nodes](#nodes) · [Built-in executors](#built-in-executors) · [Custom executors](#custom-executors)
- [Edges](#edges) · [Approval gates on a node](#approval-gates-on-a-node) · [Events on a node](#events-on-a-node)
- [Failure classification](#failure-classification) · [Engine context](#engine-context-inside-an-executor) · [Middleware](#middleware)
- [A definition using all of it](#a-definition-using-all-of-it) · [Versioning rules](#versioning-rules-that-bite)
+ - **[Full C# authoring guide](workflow-authoring-guide.md)**
+- [Authoring with the DSL](#authoring-with-the-dsl)
+ - [Which one to reach for](#which-one-to-reach-for) · [The envelope](#the-envelope) · [AbEx](#abex-the-expression-language)
+ - [Node kinds](#node-kinds) · [Custom nodes](#custom-nodes--the-extension-seam) · [Validation](#validation)
+ - **[Full DSL authoring guide](dsl-authoring-guide.md)**
- [Workflow audit records](#workflow-audit-records)
- [Registering workflows and middleware](#registering-workflows-and-middleware)
- [Instance lifecycle](#instance-lifecycle)
@@ -35,7 +40,7 @@ This page is the repository-level technical wiki. It documents the implementatio
- [Extension points](#extension-points)
- [Design constraints](#design-constraints)
- [Troubleshooting](#troubleshooting)
-- [Appendix: authoring variations](#appendix-authoring-variations)
+- [Appendix: authoring variations](#appendix-authoring-variations) — moved to the two guides
- [Related documents](#related-documents)
## At a glance
@@ -253,6 +258,38 @@ The actual port can be changed with standard ASP.NET Core configuration, for exa
## Authoring a workflow
+### Two ways to author a workflow
+
+A workflow reaches this runtime through one of two front ends. **Both produce an
+`IWorkflowDefinition`**: the same registry, the same catalog, the same start route, the same
+checkpoints, the same gates and events. Nothing downstream of registration knows which was used, and
+one host can run both at once.
+
+| | **1. In code (C#)** | **2. As a document (the DSL)** |
+| --- | --- | --- |
+| You write | A class implementing `IWorkflowDefinition` | A JSON document validated against a published schema |
+| A node is | Any C# you can write, in a `HostExecutor` | A declared `kind`, or a registered custom node named in the document |
+| Changing it needs | A build and a deploy | A file, validated at startup or through `POST /dsl/validate` |
+| Reaches | Everything on this page | Everything except arbitrary code, raw/agent/sub-workflow bindings and iteration |
+| Read next | **[Authoring workflows in C#](workflow-authoring-guide.md)** — the complete reference: every built-in executor, edge, gate, notification, trigger, audit hook and middleware seam, with worked variations and an options reference | **[Authoring workflows with the Abacus DSL](dsl-authoring-guide.md)** — the complete reference: document anatomy, every node kind, the expression language, diagnostics and limits |
+
+The honest split: **code computes, documents compose.** Reach for C# when a node has real logic —
+iterating a collection, aggregating, arithmetic over a domain model — and for the DSL when the change
+is a threshold, a topic, an edge or a prompt, and you would rather not rebuild to make it. They mix:
+domain logic in custom nodes written once, composed by a document that anyone can edit.
+[Which one to reach for](#which-one-to-reach-for) compares them properly.
+
+The rest of this chapter is the **orientation for path 1** — enough to write a definition and know
+what the surface is. The [C# authoring guide](workflow-authoring-guide.md) is the reference behind it,
+and [Authoring with the DSL](#authoring-with-the-dsl) below is the orientation for path 2.
+
+---
+
+> **Reference:** what follows is the orientation. For the complete reference — every built-in
+> executor and its options, the full gate, notification, trigger and audit surfaces, the middleware
+> seams, worked variations and an options appendix — see
+> **[Authoring workflows in C#](workflow-authoring-guide.md)**.
+
A workflow definition supplies a stable name, a semantic version, a typed context/result contract, and a method that builds an Agent Framework workflow graph.
The generic contract is:
@@ -672,6 +709,310 @@ public sealed class OrderWorkflow
- Two definitions registered with the same name and version fail startup rather than one silently
winning.
+
+## Authoring with the DSL
+
+> **Reference:** this chapter is the orientation. For the complete field-by-field reference — every
+> node kind, the full expression language, all diagnostic codes, and a framework coverage map —
+> see **[Authoring workflows with the Abacus DSL](dsl-authoring-guide.md)**.
+
+Everything above authors a workflow in C#. This authors one as a **JSON document**: validated against
+a published schema, interpreted at build time, and registered exactly like a compiled definition.
+Nothing about the runtime changes — same graph, same executors, same gates, same events.
+
+> **The governing rule: the DSL composes, it never computes.**
+>
+> A document declares *which* nodes exist, *how* they connect, and *when* an edge is taken. It never
+> carries behaviour. Every unit of work a DSL workflow performs is a capability the host already
+> shipped — a built-in node kind, or a custom node registered by name.
+
+That is what makes a document safe to accept from outside the build and honest about its ceiling.
+The answer to "the DSL cannot express this" is always *register a node*, never *embed a script*.
+
+### Which one to reach for
+
+They are peers, not a replacement. A realistic system uses both: engineers ship nodes, and workflows
+wire them together.
+
+| | Compiled definition | DSL document |
+| --- | --- | --- |
+| **Authored by** | An engineer with a build pipeline | Anyone with the schema |
+| **Expresses** | Arbitrary behaviour | Composition of registered behaviour |
+| **Typing** | Compile-time, generic | Runtime, JSON Schema per node |
+| **Changed by** | A release | An edited document |
+| **Ceiling** | The language | The registered node catalog |
+| **Best for** | Domain logic, novel executors | Orchestration, per-tenant variation, fast iteration |
+
+### A document end to end
+
+```json
+{
+ "dsl": "abacus.workflow/1.0",
+ "name": "order-settlement",
+ "version": "1.2.0",
+
+ "context": {
+ "type": "object",
+ "required": ["orderId", "amount"],
+ "properties": { "orderId": { "type": "string" }, "amount": { "type": "number" } }
+ },
+
+ "start": "price",
+ "output": ["settle"],
+
+ "nodes": [
+ { "id": "price", "kind": "transform",
+ "set": { "total": "$ctx.amount * 1.2" },
+ "notify": { "name": "priced", "payload": { "total": "$.total" } } },
+
+ { "id": "settle", "kind": "http",
+ "method": "POST",
+ "url": "https://ledger.internal/v1/settlements",
+ "allowedHosts": ["ledger.internal"],
+ "body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}",
+ "gate": {
+ "mode": "conditional",
+ "when": "$.total > 25000",
+ "reason": "RegulatedSettlement",
+ "assignTo": ["group:finance"],
+ "expiresAfter": "PT8H",
+ "onExpiry": { "action": "escalate", "assignTo": ["group:exec"] },
+ "locked": true
+ } }
+ ],
+
+ "edges": [ { "from": "price", "to": "settle", "when": "$.total > 0" } ],
+
+ "triggers": [ { "topic": "orders.placed" } ],
+ "notifications": { "level": "standard", "stream": true },
+ "onFailure": [ { "match": { "exception": "ApiCallFailureException", "status": "5xx" },
+ "disposition": "retry" } ],
+ "audit": { "sections": ["submission", "outcome"] },
+ "limits": { "maxAttempts": 5 }
+}
+```
+
+`dsl` is a versioned media identifier, not decoration: it selects the schema and the interpreter, and
+a major version this interpreter does not read is refused rather than half-understood.
+
+### The envelope
+
+Every DSL node sends and receives one message type, so every edge type-checks by construction:
+
+```json
+{ "ctx": { "orderId": "ORD-1", "amount": 100 },
+ "data": { "total": 120 },
+ "meta": { "node": "price", "superstep": 1 } }
+```
+
+- **`ctx`** — the start context, frozen at the beginning and copied through unchanged. This is why an
+ expression eleven nodes deep can still read `$ctx.orderId`. A compiled node closes over whatever
+ C# scope it likes; a document has no scope, so the envelope carries one.
+- **`data`** — the current value: what a node reads and what it replaces.
+- **`meta`** — provenance the interpreter maintains.
+
+The workflow's **result is `data`**, not the envelope. The context is machinery, not an answer.
+
+### AbEx, the expression language
+
+Conditions, guards, correlation keys and projections all need some computation. The grammar is
+closed: total (no exceptions), pure (no I/O), and statically checkable, so a typo fails a document
+review rather than a production run.
+
+**Roots.** `$` is the current `data`, `$ctx` the frozen start context, `$run` the run's identity
+(`instanceId`, `tenantId`, `workflow`, `version`, `attempt`, `superstep`, `now`).
+
+There is deliberately no `$node.`. The engine is message-passing, so a prior node's output is not
+ambiently available and a root that pretended otherwise would be a lie. Carry values forward in
+`data` — that is what a `transform` node is for.
+
+**Operators**, loosest to tightest: `||`, `&&`, comparison, `+ -`, `* / %`, unary `!` and `-`.
+Comparison is non-associative: `a < b < c` is refused rather than silently comparing a boolean to a
+number.
+
+**Functions** — the whole list, and an unknown name is a validation error with a nearest-match
+suggestion:
+
+| Function | Result |
+| --- | --- |
+| `len(x)` | Length of a string, array or object; `0` otherwise |
+| `has(path)` | Whether the path resolved to anything at all |
+| `lower(s)` / `upper(s)` | Case folding, invariant culture |
+| `contains(s, sub)`, `startsWith(s, p)`, `endsWith(s, p)` | Ordinal string tests |
+| `matches(s, pattern)` | Regex. The pattern must be a **string literal**, and matching times out at 200 ms |
+| `coalesce(a, b, …)` | First argument that is neither absent nor null |
+| `number(x)`, `string(x)`, `bool(x)` | Explicit coercion |
+
+**Semantics worth knowing before you are surprised by them:**
+
+- **Absence is a value.** A path that does not resolve yields *absent*, which never throws.
+- **Absence makes every comparison false — including `!=`.** Asking whether a field you never set
+ differs from a value should not be answered "yes". Use `has()` to ask about presence.
+- **Conditions are strictly boolean.** Only `true` is true. Absent, `null`, `0` and `""` are all
+ false. There is no truthiness ladder.
+- **Comparison is JSON-typed.** Number-to-number is numeric, string-to-string is ordinal, anything
+ cross-type is false. No coercion ladder.
+- **Arithmetic is decimal, and numbers only.** These documents price orders, so binary floating point
+ is the wrong default — `0.1 + 0.2` is `0.3`. `+` does not concatenate strings; that is what
+ templates are for.
+- **Division by zero yields absent**, not an error.
+
+**Determinism.** `$run.now` is **forbidden in edge conditions and gate predicates**, and permitted in
+templates. `BuildAsync` runs once per attempt, and a resumed instance must retrace the routing its
+checkpoint recorded; a condition reading the clock could take a different branch, which is silent,
+intermittent and close to undebuggable. The validator refuses it by static inspection.
+
+**Templates.** A `{{ … }}` placeholder in a URL, header, body or prompt evaluates a full AbEx
+expression: `{{ $ctx.orderId }}`, `{{ $.total * 1.2 }}`. An absent placeholder renders empty. A bare
+string in `when`, `set` or `correlationKey` is AbEx directly — no field accepts both conventions.
+
+### Node kinds
+
+| `kind` | Maps to | Produces in `data` |
+| --- | --- | --- |
+| `transform` | `TransformExecutor` | The `set` map merged into `data` (or replacing it) |
+| `http` | `ApiCallExecutor` | `{ status, body }` |
+| `llm` | `LlmExecutor` | `{ text, value, model, inputTokens, outputTokens, costUsd, finishReason, elapsedMs }` |
+| `delay` | `DelayExecutor` | Unchanged — a delay is about *when*, not *what* |
+| `approval` | `HumanApprovalExecutor` | Unchanged; the node exists to be the place a human decides |
+| `publish` | `PublishDomainEventExecutor` | Unchanged; publishing is a side effect on the way past |
+| `wait-event` | `WaitForDomainEventExecutor` | The delivered payload |
+| `fan-in` | Barrier aggregation | `{ : [ …each branch's data… ] }` |
+| `custom` | A registered `IDslNodeFactory` | Whatever the factory's executor produces |
+
+There is **no `delegate` kind**, and there never will be. Arbitrary code is precisely what a document
+must not carry.
+
+`http` and `llm` are the framework's own executors, hosted inside the DSL node — the egress
+allow-list, the `Idempotency-Key`, structured output, cost accounting and `llm.completed` all behave
+exactly as they do for a compiled workflow.
+
+### Custom nodes — the extension seam
+
+```csharp
+public sealed class RiskScoringNodeFactory : IDslNodeFactory
+{
+ public string Name => "score-risk";
+
+ public JsonNode? ParameterSchema => JsonNode.Parse("""
+ { "type": "object", "required": ["threshold"],
+ "properties": { "threshold": { "type": "number" } } }
+ """);
+
+ public IHostExecutor Create(DslNodeContext context)
+ => new RiskScorer(context.Node.Id, context.Parameters["threshold"]!.GetValue());
+}
+```
+
+```json
+{ "id": "score", "kind": "custom", "node": "score-risk", "with": { "threshold": 0.82 } }
+```
+
+The executor must be a `HostExecutor` and must use the id the document
+declared — gate policy and node state key off it. `ParameterSchema` is validated against `with` at
+**registration**, so a bad parameter fails startup rather than surprising a run.
+
+### Registration
+
+```csharp
+builder.Services.AddWorkflowHost(config)
+ .AddWorkflow() // compiled, unchanged
+ .UseDsl() // routes work before any document exists
+ .AddDslNode(new RiskScoringNodeFactory())
+ .AddDslWorkflow("workflows/order-settlement.json")
+ .AddDslWorkflowsFromDirectory("workflows/", "*.workflow.json");
+
+app.MapWorkflowApi();
+app.MapDslApi();
+```
+
+Order does not matter: documents are validated once the container is built, against the *complete*
+node catalog. A document that fails validation **fails startup**, with every diagnostic logged — the
+same place a bad compiled workflow fails.
+
+### Validation
+
+Two phases, because one cannot do the job.
+
+**JSON Schema** checks shape — required properties, `kind`-discriminated variants, id and SemVer
+patterns. Published at `docs/schema/abacus-workflow-dsl-1.0.json` and served from `GET /dsl/schema`,
+so an editor gives completion and inline errors before the document reaches a host.
+
+**The semantic validator** checks everything a schema cannot express, each with a stable code:
+
+| Codes | Cover |
+| --- | --- |
+| `DSL01xx` | DSL version, malformed JSON, schema violations, hash conflicts |
+| `DSL02xx` | Duplicate ids, unknown `start`/`output`/edge endpoints, duplicate edges |
+| `DSL03xx` | Unreachable nodes, dead ends, cycles with nothing that yields, unreachable barrier sources |
+| `DSL04xx` | Expression parsing, unknown functions, non-deterministic conditions, depth |
+| `DSL05xx` | Gates on non-gateable kinds, conditional gates with no predicate, escalation with no assignees |
+| `DSL06xx` | Unregistered custom nodes, bad `with` parameters, missing egress hosts |
+| `DSL07xx` | Document, node, edge and expression limits |
+
+Every diagnostic carries a JSON Pointer:
+
+```
+DSL0412 error /nodes/3/gate/when Unknown function 'lookupCustomer'. Did you mean 'coalesce'?
+DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. Did you mean 'settle'?
+DSL0301 warn /nodes/7 Node 'notify' is unreachable from 'price'.
+```
+
+A cycle is refused only when nothing on it yields — polling and wait-and-recheck are legitimate, but
+a cycle of pure compute nodes is a hot spin. Put a `delay`, `wait-event` or `approval` node on it.
+
+Environment-dependent checks report as **skipped** rather than passed when there is no host to check
+against, because a check that silently did not run is worse than one that openly did not.
+
+### Versions are immutable
+
+A document registers as `(name, version)` like any workflow, and the framework's rule applies:
+**a published version is immutable.** Identity is a canonical SHA-256 (RFC 8785) of the document —
+reformatting and property reordering do not change it, one byte of behaviour does. Registering a
+document whose `(name, version)` is known with a different hash is a startup failure naming both
+hashes. Editing a workflow means bumping the version.
+
+It also answers the operational question directly: *is this instance running the document I am
+looking at?* `GET /dsl/documents` reports each registered document's hash.
+
+### Routes
+
+| Route | Purpose |
+| --- | --- |
+| `GET /dsl/schema` | The published JSON Schema, for editor completion |
+| `GET /dsl/nodes` | Built-in kinds and every registered custom node, with parameter schemas |
+| `GET /dsl/functions` | The closed expression vocabulary, with arities |
+| `GET /dsl/documents` | Registered documents and their hashes |
+| `POST /dsl/validate` | Validate without registering — what an authoring tool calls |
+
+`POST /dsl/validate` reflects the host's registered node names back to the caller, so it takes the
+same authorization as the catalog routes.
+
+The ordinary catalog answers the same question from the other direction: `GET /workflows/{name}`
+reports `source` — `"dsl"` or `"compiled"` — and, for a document, the `documentHash`. A compiled
+definition reports `"compiled"` with a null hash, so an operator reading one catalog can tell which
+front end authored each version without knowing in advance that the DSL is installed.
+
+### Limits
+
+Document ≤ 1 MB, nodes ≤ 500, edges ≤ 2000, expression depth ≤ 32, regex match ≤ 200 ms. All
+configurable down through `ConfigureDsl`, none up.
+
+### What the DSL does not do
+
+Stated plainly, so you meet the boundary here rather than in an error message:
+
+- **No loops or iteration.** There is no `foreach`, and no way to sum an array. Fan-out over branches
+ is the intended shape. Unbounded iteration in a checkpointed engine has real semantics to work out
+ first.
+- **No sub-workflows.** The engine supports composing workflows; resolving and version-pinning one
+ document from another needs its own design.
+- **No runtime publication.** Documents load from disk at startup. A management API that accepts them
+ at run time changes the registry from immutable to mutable, which touches version resolution,
+ dispatch, authorization and tenancy.
+- **No export from C#.** A compiled definition cannot be emitted as a document. The DSL is a
+ different way in, not a serialization of the compiled path.
+
## Workflow audit records
Events answer "what did the runtime do". An audit record answers "why is this result defensible" —
@@ -1933,375 +2274,22 @@ Check the URL scheme, whether the target resolves to an internal address, and wh
## Appendix: authoring variations
-Each recipe is a complete `BuildAsync` (or the declaration that matters), showing one shape in
-isolation. They compose — the [worked definition](#a-definition-using-all-of-it) above combines
-several.
-
-### A.1 Linear
-
-The default shape. One node after another, output from the last.
-
-```csharp
-public ValueTask BuildAsync(WorkflowBuildContext context, CancellationToken ct)
-{
- ExecutorBinding validate = context.Node(new Validate("validate"));
- ExecutorBinding enrich = context.Node(new Enrich("enrich"));
- ExecutorBinding submit = context.Node(new Submit("submit"));
-
- return new ValueTask(new WorkflowBuilder(validate)
- .AddEdge(validate, enrich)
- .AddEdge(enrich, submit)
- .WithOutputFrom(submit)
- .WithName(Name)
- .Build());
-}
-```
-
-### A.2 Branch
-
-Two conditional edges out of one node. There is no switch construct; this is the branch.
-
-```csharp
-ExecutorBinding triage = context.Node(new Triage("triage"));
-ExecutorBinding fast = context.Node(new FastPath("fast-path"));
-ExecutorBinding manual = context.Node(new ManualPath("manual-path"));
-
-return new ValueTask(new WorkflowBuilder(triage)
- .AddEdge(triage, fast, condition: o => o is { Amount: <= 10_000m })
- .AddEdge(triage, manual, condition: o => o is { Amount: > 10_000m })
- .WithOutputFrom(fast, manual) // whichever branch ran supplies the result
- .WithName(Name)
- .Build());
-```
-
-The condition's parameter is `T?`, so a pattern (`o is { … }`) reads better than a null-forgiving
-dereference and handles the null case explicitly.
-
-Make the predicates exhaustive. A message matching neither edge stops there, and the run completes
-with no output rather than failing — which looks like success and is the hardest branch bug to spot.
-
-### A.3 Fan-out and fan-in
-
-```csharp
-ExecutorBinding split = context.Node(new Split("split"));
-ExecutorBinding credit = context.Node(new CheckCredit("check-credit"));
-ExecutorBinding stock = context.Node(new CheckStock("check-stock"));
-ExecutorBinding fraud = context.Node(new CheckFraud("check-fraud"));
-ExecutorBinding decide = context.Node(new FanInExecutor(
- "decide", checks => new Decision(checks.All(c => c.Passed))));
-
-return new ValueTask(new WorkflowBuilder(split)
- .AddFanOutEdge(split, [credit, stock, fraud])
- .AddFanInBarrierEdge([credit, stock, fraud], decide) // waits for all three
- .WithOutputFrom(decide)
- .WithName(Name)
- .Build());
-```
-
-Selective fan-out picks targets by index instead of sending to all:
-
-```csharp
-.AddFanOutEdge(split, [credit, stock, fraud],
- targetSelector: (order, count) => order!.SkipFraudCheck ? [0, 1] : [0, 1, 2])
-```
-
-A wide fan-out is the usual reason to set `NotificationLevel.Lifecycle` — see [A.10](#a10-quiet-a-chatty-workflow).
-
-### A.4 Approval gates
-
-Three ways to gate, from blunt to conditional:
-
-```csharp
-// Always requires a decision.
-context.Node(new Publish("publish"), gate => gate
- .Mode(ExecutionMode.RequireApproval)
- .AssignTo("group:ops")
- .ExpiresAfter(TimeSpan.FromHours(4)));
-
-// Only above a threshold. `When` implies Conditional mode.
-context.Node(new Settle("settle"), gate => gate
- .When(order => order.Amount > 25_000m)
- .Reason("AmountAboveThreshold")
- .RequireApprovers(2)
- .AllowModification());
-
-// A floor a tenant may tighten but never weaken.
-context.Node(new Payout("payout"), gate => gate
- .Mode(ExecutionMode.RequireApproval)
- .AssignTo("group:finance")
- .RequireSegregationOfDuties()
- .OnExpiry(ExpiryAction.DeadStop)
- .Locked());
-```
-
-`HumanApprovalExecutor` does the same job as a node rather than as configuration, when the
-approval is part of the workflow's own logic and should be visible in the graph:
-
-```csharp
-ExecutorBinding signOff = context.Node(new HumanApprovalExecutor("sign-off"));
-```
-
-### A.5 Durable delay
-
-`DelayExecutor` checkpoints and halts rather than blocking a thread or holding a lease, so a long
-delay costs no execution capacity.
-
-```csharp
-var timers = context.Services!.GetRequiredService();
-
-ExecutorBinding cooloff = context.Node(
- new DelayExecutor("cool-off", TimeSpan.FromHours(24), timers));
-
-return new ValueTask(new WorkflowBuilder(submit)
- .AddEdge(submit, cooloff)
- .AddEdge(cooloff, settle)
- .WithOutputFrom(settle)
- .Build());
-```
-
-### A.6 HTTP call
-
-```csharp
-ExecutorBinding fetch = context.Node(new ApiCallExecutor("fetch-invoice",
- new ApiCallOptions
- {
- Method = HttpMethod.Get,
- UrlTemplate = "https://erp.internal/invoices/{{ context.InvoiceId }}",
- Headers = { ["Accept"] = "application/json" },
- TimeoutSeconds = 15,
- SuccessCodes = [200, 204],
- ResponseAs = typeof(InvoiceDto),
- AllowedHosts = ["erp.internal"],
- EnforceEgress = true, // refuse anything not on the allow-list
- SendIdempotencyKey = true // safe to retry
- },
- () => context.Services!.GetRequiredService()
- .CreateClient(ApiCallOptions.HttpClientName)));
-```
-
-A non-success status arrives as a typed `ApiCallFailure` carrying status, body excerpt and
-`Retry-After`, so [`Classify`](#a12-custom-failure-classification) can act on it rather than parsing
-a message.
-
-### A.7 LLM node
-
-```csharp
-ExecutorBinding classify = context.Node(new LlmExecutor("classify",
- new LlmOptions
- {
- Model = "claude-sonnet-5",
- SystemPrompt = "Classify the invoice.",
- PromptVersion = "v3", // tags the drift baseline
- UserTemplate = "{{ context.DocumentText }}",
- StructuredOutput = typeof(Classification),
- Temperature = 0.0f,
- MaxTokens = 2048,
- StreamDeltas = true, // llm.delta frames, live only
- EmitCompletion = true // one llm.completed per call (default)
- },
- model => context.Services!.GetRequiredService(),
- context.Services!.GetService())); // enables costUsd and cost drift
-```
-
-Pass the pricing service or `costUsd` is `null` — absent, not zero. See
-[LLM telemetry](#llm-telemetry).
-
-### A.8 Started by an event
-
-```csharp
-public sealed class ShipOrderWorkflow
- : IWorkflowDefinition, IDomainEventTriggeredWorkflow
-{
- public string Name => "ship-order";
- public string Version => "1.0.0";
-
- public IReadOnlyList Triggers =>
- [
- new DomainEventTrigger { TopicFilter = "orders.placed" },
- new DomainEventTrigger
- {
- TopicFilter = "orders.*.expedited",
- ContextSelector = m => m.PayloadJson // remap if the payload is not the context
- }
- ];
-
- // BuildAsync as usual; the message payload arrives as the context.
-}
-```
-
-The message payload becomes the instance context, and its correlation key becomes the instance's
-correlation id. Redelivery is absorbed by the launcher's idempotency key, so a message cannot start
-the same workflow twice.
-
-### A.9 Publish and wait
-
-A two-workflow pipeline. The first publishes; the second parks until the reply arrives.
-
-```csharp
-// Producer — publishing is a side effect on the way past, so the node drops into an existing edge.
-var broker = context.Services!.GetRequiredService();
-
-ExecutorBinding publish = context.Node(new PublishDomainEventExecutor(
- "publish-order-placed", broker,
- topic: "orders.placed",
- correlationKey: o => o.OrderId));
-
-// Consumer — parks, releases its lease, and resumes with the payload.
-var subscriptions = context.Services!.GetRequiredService();
-
-ExecutorBinding awaitPayment = context.Node(
- new WaitForDomainEventExecutor(
- "await-settlement", subscriptions,
- topicFilter: "payment.settled",
- correlationKey: o => o.OrderId,
- timeout: TimeSpan.FromDays(3),
- onExpiry: WaitExpiryAction.DeadStop)); // or Resume, to take a timeout branch
-```
-
-Publishing across a service boundary is a scope on the message, not a different call — see
-[Local by default, global by declaration](#local-by-default-global-by-declaration).
-
-### A.10 Quiet a chatty workflow
-
-```csharp
-public NotificationPolicy Notifications { get; } = new()
-{
- Level = NotificationLevel.Lifecycle, // supersteps, no per-node chatter
- ByNode = new Dictionary(StringComparer.Ordinal)
- {
- ["reconcile"] = NotificationLevel.Standard // except this one
- }
-};
-```
-
-### A.11 Log without streaming
-
-```csharp
-public NotificationPolicy Notifications { get; } = new()
-{
- StreamEvents = false // full event log; no SSE
-};
-```
-
-The log is unconditional either way. `GET /instances/{id}/events` then returns `409` naming
-`GET /v2/workflows/{name}/instances/{id}/events`. See
-[Turning SSE off for a workflow](#turning-sse-off-for-a-workflow).
-
-### A.12 Custom notifications from a node
-
-```csharp
-public NotificationPolicy Notifications { get; } = new()
-{
- Emits = ["documents.scanned"] // advertised on GET /workflows/{name}
-};
-```
-
-```csharp
-protected override async ValueTask ExecuteCoreAsync(
- ScanContext input, IWorkflowContext context, CancellationToken ct)
-{
- if (Runtime.Notify is { } notify)
- {
- await notify.NotifyAsync("documents.scanned", new { count = input.Documents.Count }, ct);
- }
- // → event: custom.documents.scanned
-}
-```
-
-### A.13 Audit record
-
-```csharp
-public AuditRecordDefinition AuditRecord { get; } = new(
- "order", "One order, as processed.",
- [
- new AuditSectionDefinition("submission", "What was submitted.", Multiple: false),
- new AuditSectionDefinition("step", "One processing step."),
- new AuditSectionDefinition("outcome", "How the run settled.", Multiple: false)
- ]);
-```
-
-```csharp
-if (Runtime.Audit is { } audit)
-{
- await audit.OpenAsync(input.OrderId, attributes: null, ct);
- await audit.RecordAsync("step", key: input.LineId, new { accepted = true }, ct);
- await audit.CloseAsync(AuditRecordStatus.Completed, ct);
-}
-```
-
-Keying an entry means a retried executor corrects its record rather than doubling it. See
-[Workflow audit records](#workflow-audit-records).
-
-### A.14 Custom failure classification
-
-```csharp
-public FailureDisposition Classify(WorkflowFailure failure) => failure.Exception switch
-{
- InsufficientFundsException => FailureDisposition.DeadStop, // retrying cannot help
- ThirdPartyThrottleException => FailureDisposition.Retry,
- ReconciliationBreakException => FailureDisposition.Escalate, // terminal, flag for an operator
- _ => DefaultFailureClassifier.Instance.Classify(failure)
-};
-```
-
-Classify per node when the same exception means different things in different places — the failure
-carries `ExecutorId` and the executor's `Metadata`:
-
-```csharp
-public FailureDisposition Classify(WorkflowFailure failure)
- => failure is { ExecutorId: "optional-enrichment", Exception: HttpRequestException }
- ? FailureDisposition.DeadStop // this node is best-effort; do not burn attempts
- : DefaultFailureClassifier.Instance.Classify(failure);
-```
-
-### A.15 Raw nodes, agents and sub-workflows
-
-Raw nodes join the graph but run outside the executor middleware pipeline and cannot be
-approval-gated — passing a gate block throws.
-
-```csharp
-// An AIAgent as a node.
-ExecutorBinding triage = context.RawNode(someAgent.BindAsExecutor("triage-agent"));
-
-// Another workflow as a node.
-Workflow enrichment = BuildEnrichmentGraph();
-ExecutorBinding enrich = context.RawNode(enrichment.BindAsExecutor("enrich"));
-
-// A bare handler, with no executor class at all.
-Func logHandler =
- (order, _, _) => { Log(order); return ValueTask.CompletedTask; };
-ExecutorBinding log = context.RawNode(logHandler.BindAsExecutor("log"));
-
-ExecutorBinding record = context.Node(new Record("record")); // gated, audited, with middleware
-
-return new ValueTask(new WorkflowBuilder(triage)
- .AddEdge(triage, enrich)
- .AddEdge(enrich, log)
- .AddEdge(log, record)
- .WithOutputFrom(record)
- .Build());
-```
-
-A sub-workflow node runs the child graph inline. It is not a child *instance* — there is no separate
-instance row, lease or event stream for it, and its nodes are not separately gateable or
-configurable. Use `SubWorkflow` for composition of graph shape; use an event trigger
-([A.8](#a8-started-by-an-event)) when you want a genuinely independent run.
-
-### A.16 Registering what you built
-
-```csharp
-builder.Services
- .AddAbacus(builder.Configuration) // or AddWorkflowHost + AddBuiltInMiddleware + AddBackgroundServices
- .AddWorkflow() // resolved from DI
- .AddWorkflow(new ShipOrderWorkflow()) // or supplied directly
- .AddExecutorMiddleware();
-```
+The worked variations now live with the reference for each front end, so a recipe sits beside the
+field reference it uses rather than a chapter away from it:
-Without `AddBackgroundServices()` instances are created and stay `Pending` — nothing executes them.
-See [Registering workflows and middleware](#registering-workflows-and-middleware).
+- **[C# — Appendix A](workflow-authoring-guide.md#appendix-a--worked-variations)** — A.1 linear, A.2
+ branch, A.3 fan-out and fan-in, A.4 approval gates, A.5 durable delay, A.6 HTTP call, A.7 LLM node,
+ A.8 started by an event, A.9 publish and wait, A.10 quiet a chatty workflow, A.11 log without
+ streaming, A.12 custom notifications, A.13 audit record, A.14 custom failure classification,
+ A.15 raw nodes, agents and sub-workflows, A.16 registering what you built.
+- **[DSL — Appendix A](dsl-authoring-guide.md#appendix-a--worked-variations)** — the same shapes as
+ documents, so the two read side by side.
## Related documents
+- [`workflow-authoring-guide.md`](workflow-authoring-guide.md) - Complete reference for authoring workflows in C#
+- [`dsl-authoring-guide.md`](dsl-authoring-guide.md) - Complete reference for authoring workflows as JSON documents
+- [`schema/abacus-workflow-dsl-1.0.json`](schema/abacus-workflow-dsl-1.0.json) - The DSL's normative JSON Schema
- [`README.md`](../README.md) - Short setup and API overview
- [`PRD-Abacus-Run.md`](PRD-Abacus-Run.md) - Product requirements and target architecture
- [`TDD-Abacus-Run.md`](TDD-Abacus-Run.md) - Technical design and framework grounding
diff --git a/docs/workflow-authoring-guide.md b/docs/workflow-authoring-guide.md
new file mode 100644
index 0000000..ef49c4f
--- /dev/null
+++ b/docs/workflow-authoring-guide.md
@@ -0,0 +1,1484 @@
+# Authoring workflows in C#
+
+A complete reference for building a workflow definition in code, covering every capability the
+framework offers a definition and how to reach it.
+
+Companion to the [wiki](wiki.md): that is the orientation and the operational manual, this is the
+authoring reference. Its mirror is
+[Authoring workflows with the Abacus DSL](dsl-authoring-guide.md) — the same runtime, reached from
+JSON instead of C#. If you are deciding between the two, read
+[§19](#19-choosing-between-c-and-the-dsl) first.
+
+---
+
+## Contents
+
+- [1. The model](#1-the-model)
+- [2. Definition anatomy](#2-definition-anatomy)
+- [3. Context and result contracts](#3-context-and-result-contracts)
+- [4. Nodes and bindings](#4-nodes-and-bindings)
+- [5. Built-in executors](#5-built-in-executors)
+- [6. Custom executors](#6-custom-executors)
+- [7. Templates](#7-templates)
+- [8. Edges](#8-edges)
+- [9. Approval gates](#9-approval-gates)
+- [10. Notifications and events](#10-notifications-and-events)
+- [11. Domain events: publishing, waiting, triggering](#11-domain-events-publishing-waiting-triggering)
+- [12. Failure, retry and limits](#12-failure-retry-and-limits)
+- [13. Audit records](#13-audit-records)
+- [14. Engine context inside an executor](#14-engine-context-inside-an-executor)
+- [15. Middleware](#15-middleware)
+- [16. Registration and hosting](#16-registration-and-hosting)
+- [17. Versions, identity and drift](#17-versions-identity-and-drift)
+- [18. What a definition gets for free](#18-what-a-definition-gets-for-free)
+- [19. Choosing between C# and the DSL](#19-choosing-between-c-and-the-dsl)
+- [20. Sharp edges](#20-sharp-edges)
+- [Appendix A — worked variations](#appendix-a--worked-variations)
+- [Appendix B — options reference](#appendix-b--options-reference)
+
+---
+
+## 1. The model
+
+> **A definition declares a graph; the framework runs it durably.**
+>
+> An author supplies nodes, the edges between them, and the policy around them — gates, failure
+> dispositions, what the run emits, what it audits. Everything about *making that survive* — leases,
+> checkpoints, retries, resumption, tenancy — belongs to the host and is not the definition's
+> business.
+
+Three consequences follow, and they explain most of what the rest of this document describes:
+
+1. **A definition is data plus behaviour, and the behaviour is ordinary C#.** Unlike a DSL document
+ there is no ceiling: a node can do anything a method can do. What you give up is the safety that
+ comes from not being able to.
+2. **The graph is built per attempt, not per process.** `BuildAsync` runs on every run and every
+ resume, so it must be cheap and deterministic — the same instance rebuilding a *different* graph
+ on resume will not match its own checkpoint.
+3. **Nothing is required except the graph.** Audit records, event triggers, notification policy and
+ context validation are separate opt-in interfaces. A definition that says nothing about them pays
+ nothing for them.
+
+A definition is registered once at startup, keyed by `(Name, Version)`, and is immutable thereafter.
+
+---
+
+## 2. Definition anatomy
+
+```csharp
+public interface IWorkflowDefinition : IWorkflowDefinition
+ where TContext : notnull
+{
+ string Name { get; }
+ string Version { get; }
+
+ ValueTask BuildAsync(WorkflowBuildContext context, CancellationToken cancellationToken);
+
+ FailureDisposition Classify(WorkflowFailure failure);
+}
+```
+
+`ContextType`, `ResultType` and `Classify` all have default implementations on the generic interface,
+so the smallest useful definition is a name, a version and a `BuildAsync`:
+
+```csharp
+public sealed record GreetingContext(string Name);
+public sealed record GreetingResult(string Message);
+
+public sealed class GreetingWorkflow : IWorkflowDefinition
+{
+ public string Name => "greeting";
+ public string Version => "1.0.0";
+
+ public ValueTask BuildAsync(WorkflowBuildContext context, CancellationToken ct)
+ {
+ ExecutorBinding greet = context.Node(new GreetingExecutor("greet"));
+
+ return new ValueTask(new WorkflowBuilder(greet)
+ .WithOutputFrom(greet)
+ .WithName(Name)
+ .Build());
+ }
+}
+```
+
+### The opt-in interfaces
+
+| Interface | Declares | Section |
+| --- | --- | --- |
+| `IWorkflowDefinition` | Name, version, context/result types, the graph | this section |
+| `IAuditedWorkflowDefinition` | The shape of the workflow's own audit record | [§13](#13-audit-records) |
+| `IDomainEventTriggeredWorkflow` | Topics that start an instance | [§11](#11-domain-events-publishing-waiting-triggering) |
+| `INotifyingWorkflow` | Emission level, per-node overrides, SSE on/off, custom event names | [§10](#10-notifications-and-events) |
+| `IContextValidatingWorkflow` | Validation of the start payload beyond the type bind | [§3](#3-context-and-result-contracts) |
+| `IDocumentAuthoredWorkflow` | Provenance for the catalog — implemented by the DSL, rarely by hand | [§17](#17-versions-identity-and-drift) |
+
+The runtime probes for each with `is`, so implementing one you do not need means declaring an empty
+policy — which is not the same as declaring nothing. Approval gates are deliberately **not** an
+interface: they are per node, declared inline where the node is attached.
+
+### `WorkflowBuildContext`
+
+What `BuildAsync` receives. It carries the run's identity as well as the attachment methods, so a
+node can close over either.
+
+| Member | Purpose |
+| --- | --- |
+| `InstanceId`, `TenantId` | This run's identity |
+| `WorkflowName`, `WorkflowVersion` | What the registry resolved |
+| `Attempt` | 1 on the first run, higher after a retry |
+| `Services` | The host's `IServiceProvider` — resolve brokers, clients and stores from it |
+| `Audit` | The recorder, present when the definition declares an audit record |
+| `Node(executor, gate?)` | Attach a host executor, optionally gated |
+| `RawNode(binding, gate?)` | Attach a raw framework or agent binding; a gate here throws |
+| `Gates`, `Nodes` | What this build declared; read by the runtime and the catalog API |
+| `ForInspection(...)` | A build context with no runtime attachment, for rendering a graph |
+
+`Services` is nullable because a definition can be built outside a host — that is what
+`ForInspection` is for, and what makes a definition unit-testable without DI. Inside a real run it is
+always present, which is why `context.Services!` is the idiom in the examples here.
+
+---
+
+## 3. Context and result contracts
+
+`TContext` is what a caller posts to start a run; `TResult` is what the terminal node yields.
+
+```
+POST /workflows/{name}/instances { "context": { … } }
+```
+
+The registry binds the posted JSON to `TContext` before anything runs, and a bind failure is a `400`
+with field errors rather than a failed instance. `TContext` must be non-null; a parameterless
+constructor lets a workflow start with no payload at all.
+
+Serialization is `System.Text.Json` with the host's options (`JsonOptions.Default`), so records with
+positional parameters bind as you would expect and casing is web-standard.
+
+### Validating beyond the type
+
+A type bind establishes shape, not sense. `IContextValidatingWorkflow` runs **after** the bind
+succeeds, and its errors surface the same way:
+
+```csharp
+public sealed class TransferWorkflow
+ : IWorkflowDefinition, IContextValidatingWorkflow
+{
+ public ContextValidationResult ValidateContext(JsonElement context)
+ => context.TryGetProperty("amount", out JsonElement amount) && amount.GetDecimal() > 0
+ ? ContextValidationResult.Valid
+ : ContextValidationResult.Invalid("amount", "Must be greater than zero.");
+}
+```
+
+Errors are keyed by field so a form can put each message beside the input it is about. Throwing
+`WorkflowValidationException` from inside an executor is the other half of this: it dead-stops by
+default rather than retrying, because a payload that failed validation will fail it again.
+
+### When the context is not a POCO
+
+`TContext` may be `JsonElement`, which is what the DSL uses: the workflow accepts arbitrary JSON and
+answers for its own validation. Worth knowing because it changes routing — the engine dispatches the
+start message **by type**, so the first node must accept exactly the declared context type or nothing
+runs and the workflow completes having done nothing.
+
+---
+
+## 4. Nodes and bindings
+
+`context.Node(...)` is the attachment point that makes a node a *host* node: it wires the middleware
+pipeline, the approval gate, the audit recorder and the per-instance runtime, then returns the
+`ExecutorBinding` the graph is built from.
+
+```csharp
+ExecutorBinding validate = context.Node(new Validate("validate"));
+```
+
+The **executor id** is the identity everything else hangs off:
+
+- gate policies are stored per `(workflow, version, executorId)`;
+- node state is projected by it;
+- per-node notification overrides name it;
+- the graph and node endpoints report it.
+
+Renaming a node in a published version silently orphans any tenant policy written against the old id.
+Change the version instead.
+
+### Raw bindings
+
+`RawNode(...)` attaches something the host did not create. Raw nodes join the graph but run **outside
+the executor middleware pipeline** and cannot be approval-gated — passing a gate throws rather than
+ignoring it, because a gate that quietly did nothing would be worse than one that was refused.
+
+`ExecutorBinding` has implicit conversions from `Executor`, `AIAgent`, `RequestPort` and `string`,
+and the framework supplies several ways to make one:
+
+| Binding | From |
+| --- | --- |
+| `executor.BindExecutor()` | A raw framework `Executor` |
+| `agent.BindAsExecutor(id)` | An `AIAgent` — the agent becomes a node |
+| `workflow.BindAsExecutor(id)` | Another `Workflow`, as a **sub-workflow** node |
+| `handler.BindAsExecutor(id)` | A bare `Func` |
+
+A sub-workflow node runs the child graph **inline**. There is no separate instance row, lease or event
+stream for it, and its nodes are not separately gateable or configurable. Use a sub-workflow to
+compose graph *shape*; use an event trigger ([§11](#11-domain-events-publishing-waiting-triggering))
+when you want a genuinely independent run.
+
+Prefer `Node(...)` with a `HostExecutor` whenever middleware, gates, audit or notifications
+are wanted. A raw node gets none of them.
+
+---
+
+## 5. Built-in executors
+
+| Executor | Shape | Purpose |
+| --- | --- | --- |
+| `TransformExecutor` | `(id, Func)` | Pure mapping |
+| `DelegateExecutor` | `(id, handler)` | General-purpose async work |
+| `ApiCallExecutor` | `(id, ApiCallOptions, Func)` | Templated HTTP call with egress control and idempotency key |
+| `LlmExecutor` | `(id, LlmOptions, Func, IModelPricing?)` | Chat model call with structured output, streaming and cost |
+| `DelayExecutor` | `(id, TimeSpan, ITimerService)` | Durable wait — checkpoints and halts |
+| `HumanApprovalExecutor` | `(id)` | Marks the place a human decides |
+| `FanInExecutor` | `(id, aggregate)` | Aggregates a list of items into one message |
+| `PublishDomainEventExecutor` | `(id, broker, topic, …)` | Publishes a domain message, passing input through |
+| `WaitForDomainEventExecutor` | `(id, subscriptions, topicFilter, …)` | Parks until a matching message arrives |
+
+Each sets a `node.kind` in its `Metadata`, which is what the catalog and graph endpoints report.
+
+### `TransformExecutor` and `DelegateExecutor`
+
+The two general-purpose nodes. `TransformExecutor` takes a pure function; `DelegateExecutor` takes an
+async handler with the engine context, and is the right answer for most one-off work that does not
+deserve a class:
+
+```csharp
+ExecutorBinding total = context.Node(new TransformExecutor(
+ "total", order => new Priced(order.Id, order.Lines.Sum(l => l.Quantity * l.UnitPrice))));
+
+ExecutorBinding load = context.Node(new DelegateExecutor(
+ "load", async (priced, ctx, ct) => new Enriched(priced, await _customers.GetAsync(priced.Id, ct))));
+```
+
+### `ApiCallExecutor`
+
+Declarative outbound HTTP. Templates resolve against the message the node received
+([§7](#7-templates)), the URL is checked against the allow-list before the request is made, and a
+non-success status arrives as a typed `ApiCallFailureException` carrying the status, a body excerpt
+and `Retry-After` — so the classifier can act on it without parsing a message.
+
+```csharp
+ExecutorBinding fetch = context.Node(new ApiCallExecutor("fetch-invoice",
+ new ApiCallOptions
+ {
+ Method = HttpMethod.Get,
+ UrlTemplate = "https://erp.internal/invoices/{{ context.InvoiceId }}",
+ Headers = { ["Accept"] = "application/json" },
+ TimeoutSeconds = 15,
+ SuccessCodes = [200, 204],
+ ResponseAs = typeof(InvoiceDto),
+ AllowedHosts = ["erp.internal"],
+ EnforceEgress = true,
+ SendIdempotencyKey = true
+ },
+ () => context.Services!.GetRequiredService()
+ .CreateClient(ApiCallOptions.HttpClientName)));
+```
+
+It returns `ApiCallResult(StatusCode, Body, RawBody)` — `Body` is deserialized as `ResponseAs` when
+set, and `RawBody` is always the text, so a non-JSON response is still readable.
+
+The `Idempotency-Key` is `{instanceId}:{executorId}:{attempt}`: deterministic within an attempt so a
+replayed superstep re-sends the same key, distinct across attempts so a retry is a new request. Use
+the named client (`ApiCallOptions.HttpClientName`) and outbound logging, redaction and the egress
+guard all apply without the node knowing.
+
+### `LlmExecutor`
+
+```csharp
+ExecutorBinding classify = context.Node(new LlmExecutor("classify",
+ new LlmOptions
+ {
+ Model = "claude-sonnet-5",
+ SystemPrompt = "Classify the invoice.",
+ PromptVersion = "v3", // tags the drift baseline
+ UserTemplate = "{{ context.DocumentText }}",
+ StructuredOutput = typeof(Classification),
+ Temperature = 0.0f,
+ MaxTokens = 2048,
+ StreamDeltas = true,
+ EmitCompletion = true,
+ MaxReparseAttempts = 2
+ },
+ model => context.Services!.GetRequiredService(),
+ context.Services!.GetService()));
+```
+
+Returns `LlmResult(Value, Text, InputTokens, OutputTokens, ModelId, FinishReason)` with `CostUsd` and
+`Elapsed`. Three things are easy to get wrong:
+
+- **`StructuredOutput` is enforced.** The model's output is parsed into the declared type, retried up
+ to `MaxReparseAttempts`, and then raised as `StructuredOutputException` — which dead-stops, because
+ a model that cannot produce the shape twice will not produce it on a third attempt either.
+- **`CostUsd` is null without pricing.** Pass `IModelPricing` or cost is *absent*, not zero. Prices
+ bind from `Abacus:Llm:Pricing:`.
+- **`StreamDeltas` is live-only.** `llm.delta` frames reach SSE subscribers and are not written to the
+ durable log; `llm.completed` is.
+
+The client resolver is `Func` so a host with several providers selects by model
+id. With one registered `IChatClient`, `Model` is passed through as the model id but selects nothing.
+
+### `DelayExecutor`
+
+Does not sleep. It writes a timer row, checkpoints and halts, so the instance releases its lease: a
+24-hour delay costs no execution capacity and survives a restart. Returns
+`TimerElapsed(ExecutorId, WakeAt)`.
+
+```csharp
+var timers = context.Services!.GetRequiredService();
+ExecutorBinding cooloff = context.Node(new DelayExecutor("cool-off", TimeSpan.FromHours(24), timers));
+```
+
+> **`ITimerService` is not registered by the framework or the shipped host.** A `delay` node needs an
+> implementation; register one before using this executor. The DSL test fixture has an in-memory one
+> worth copying for local work.
+
+### `HumanApprovalExecutor`
+
+Identity work — it returns its input unchanged. **The pause comes from the gate, not from the class**:
+the executor exists so the place a human decides is a node in the graph rather than configuration on
+some other node. Attach it with a gate:
+
+```csharp
+ExecutorBinding signOff = context.Node(
+ new HumanApprovalExecutor("sign-off"),
+ gate => gate.Mode(ExecutionMode.RequireApproval).AssignTo("group:ops"));
+```
+
+### `FanInExecutor`
+
+Aggregates a list into one message:
+
+```csharp
+ExecutorBinding decide = context.Node(new FanInExecutor(
+ "decide", checks => new Decision(checks.All(c => c.Passed))));
+```
+
+> **It does not work as the target of `AddFanInBarrierEdge`.** The executor declares
+> `HostExecutor, TOut>`, but the barrier's edge runner type-checks each released message
+> **individually** — a target declaring `List` matches nothing and the delivery is dropped as a
+> type mismatch. Aggregate in a custom executor that holds arrivals and emits on the last one, as the
+> DSL's `fan-in` node does. See [§20](#20-sharp-edges).
+
+### The domain-event executors
+
+Covered with topics, scopes and triggers in
+[§11](#11-domain-events-publishing-waiting-triggering).
+
+---
+
+## 6. Custom executors
+
+Derive from `HostExecutor` and implement `ExecuteCoreAsync`. `HandleAsync` is **sealed**:
+gate evaluation and the middleware pipeline live there and must not be overridden away.
+
+```csharp
+public sealed class PriceOrder(string id) : HostExecutor(id)
+{
+ public override IReadOnlyDictionary Metadata =>
+ new Dictionary { ["node.kind"] = "pricing" };
+
+ protected override async ValueTask ExecuteCoreAsync(
+ Order input, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ if (Runtime.Audit is { } audit)
+ {
+ await audit.RecordAsync("step", input.Id, new { lines = input.Lines.Count }, cancellationToken);
+ }
+
+ return new Priced(input.Id, input.Lines.Sum(l => l.Quantity * l.UnitPrice));
+ }
+}
+```
+
+`TOut` is constrained to a reference type because the pause path returns `null`, and the engine only
+auto-sends non-null handler results. That is precisely what lets a gated or waiting executor park
+without emitting a bogus message downstream.
+
+### `Runtime` — what the host adds
+
+`Runtime` is injected after construction, because executors are built before the instance id is
+known. Outside a host it is `HostExecutorRuntime.Unattached`, which is what makes an executor
+directly unit-testable.
+
+| Member | Purpose |
+| --- | --- |
+| `InstanceId`, `TenantId` | This run's identity |
+| `Attempt`, `CurrentSuperstep` | Where in the run this invocation is |
+| `Descriptor` | Executor id, type, workflow name/version, execution mode, metadata |
+| `Services` | The host's provider, when attached |
+| `Audit` | The recorder, when the definition declares a record — **null otherwise, so guard it** |
+| `Notify` | Event emission, when attached — likewise nullable |
+| `Gates`, `Approvals`, `Pipeline` | Framework wiring; an author reads these, never sets them |
+
+Override `Metadata` to describe the node for the graph endpoint and for classifiers: `WorkflowFailure`
+carries it, so a classifier can decide by node *kind* without hard-coding ids.
+
+### Parking deliberately
+
+The park pattern is `RequestHaltAsync()` then a null result:
+
+```csharp
+protected override async ValueTask ExecuteCoreAsync(
+ Order input, IWorkflowContext context, CancellationToken ct)
+{
+ if (await AlreadyDelivered(ct) is { } payload) return payload; // second pass
+
+ await Register(input, ct);
+ await context.RequestHaltAsync();
+ return null!; // never auto-sent
+}
+```
+
+An executor written this way **runs twice**, so everything before the halt must be idempotent. This
+is exactly how gates and `WaitForDomainEventExecutor` work; reach for it when a node waits on
+something the framework does not model.
+
+---
+
+## 7. Templates
+
+`ApiCallExecutor` and `LlmExecutor` bind `{{ path }}` placeholders through the shared
+`TemplateEngine`. Deliberately not an expression language: templates appear in URLs and request
+bodies, so the surface is kept small enough to reason about.
+
+| Rule | Behaviour |
+| --- | --- |
+| `{{ Field }}`, `{{ Nested.Field }}` | Dotted path over the message the node received |
+| `{{ context.Field }}`, `{{ input.Field }}` | `context` and `input` are aliases for the root |
+| Source kinds | POCO properties (case-insensitive), `IDictionary`, `JsonElement` |
+| Absent path | Renders as empty. A template never fails a run over a missing field |
+| Unterminated `{{` | Emitted verbatim rather than silently truncating a URL |
+| Formatting | `IFormattable` values render with `InvariantCulture` |
+
+A message type can take over resolution entirely by implementing `ITemplateBindingSource`:
+
+```csharp
+public interface ITemplateBindingSource
+{
+ string? Resolve(string expression); // null renders as empty
+}
+```
+
+Checked before the dotted-path walk, so a self-resolving message keeps full control of its own
+placeholder syntax. This is what lets the DSL's envelope answer `{{ $ctx.orderId }}` and
+`{{ $.total * 1.2 }}` inside the same `ApiCallExecutor` a compiled workflow uses — and it is the seam
+to reuse for any message carrying more than one addressable object.
+
+---
+
+## 8. Edges
+
+Edges come from the Agent Framework's `WorkflowBuilder`. The constructor takes the start node, and
+`WithOutputFrom` names the node (or nodes) whose result becomes the workflow's result.
+
+```csharp
+Workflow workflow = new WorkflowBuilder(validate)
+ .AddEdge(validate, enrich)
+ .AddEdge(enrich, escalate, condition: o => o is { Amount: > 10_000m })
+ .AddEdge(enrich, settle, condition: o => o is { Amount: <= 10_000m })
+ .AddFanOutEdge(settle, [notifyOps, notifyCustomer])
+ .AddFanInBarrierEdge([notifyOps, notifyCustomer], complete)
+ .WithOutputFrom(complete)
+ .WithName(Name)
+ .Build();
+```
+
+| Method | Behaviour |
+| --- | --- |
+| `AddEdge(source, target)` | Unconditional |
+| `AddEdge(source, target, condition)` | Traversed only when the predicate holds for the message |
+| `AddEdge(source, target, label, idempotent)` | Labelled for the graph view; `idempotent` permits re-adding the same edge |
+| `AddFanOutEdge(source, targets)` | Sends to every target |
+| `AddFanOutEdge(source, targets, targetSelector)` | Sends to the subset the selector picks by index |
+| `AddFanInBarrierEdge(sources, target)` | Target runs once every source has delivered |
+| `WithOutputFrom(executor, …)` | Binds the workflow result; accepts several nodes |
+| `Build(validateOrphans: true)` | Throws on a node no edge reaches — a typo, not a design |
+
+Three rules worth internalising:
+
+- **Two conditional edges out of one node is the branch.** There is no switch construct. Make the
+ predicates exhaustive: a message matching neither simply stops there and the run completes with **no
+ output**, which looks like success and is the hardest branch bug to find.
+- **The condition parameter is `T?`.** A pattern (`o is { … }`) reads better than a null-forgiving
+ dereference and handles the null case explicitly.
+- **Routing is by type as well as by edge.** A target whose `TIn` does not match the message is not
+ an error at build time; the delivery is dropped at run time. Mismatched node shapes are the usual
+ cause of "the graph ran and nothing happened".
+
+---
+
+## 9. Approval gates
+
+A node attached with no gate runs autonomously. A gate is declared where the node is attached, and
+nowhere else:
+
+```csharp
+ExecutorBinding settle = context.Node(new Settle("settle"), gate => gate
+ .Mode(ExecutionMode.RequireApproval)
+ .When(order => order.Amount > 25_000m) // implies Conditional
+ .Reason("RegulatedSettlement")
+ .AssignTo("group:finance", "user:cfo")
+ .RequireApprovers(2)
+ .ExpiresAfter(TimeSpan.FromHours(8))
+ .OnExpiry(ExpiryAction.Escalate, "group:exec")
+ .AllowModification()
+ .RequireSegregationOfDuties()
+ .Locked());
+```
+
+| Builder call | Default | Notes |
+| --- | --- | --- |
+| `Mode(ExecutionMode)` | `RequireApproval` when a gate block is present | `Autonomous`, `RequireApproval`, `Conditional` |
+| `When(predicate)` / `WhenAsync(predicate)` | — | Sets `Conditional`. A message of another type never trips the gate |
+| `Reason(string)` | none | Surfaced on the approval request |
+| `AssignTo(params string[])` | empty — anyone may decide | `group:` and `user:` principals |
+| `RequireApprovers(int)` | 1 | Quorum; the instance stays parked until it is met |
+| `ExpiresAfter(TimeSpan)` | 24 hours | |
+| `OnExpiry(action, escalateTo)` | `DeadStop` | `DeadStop`, `Reject`, `AutoApprove`, `Escalate` |
+| `AllowModification(bool)` | false | Lets a decider amend the input the node will receive |
+| `RequireSegregationOfDuties(bool)` | false | The initiator may not be the decider |
+| `Locked(bool)` | false | Tenants may tighten, never loosen |
+
+**Every gated node is reconfigurable per tenant at run time unless the author calls `.Locked()`.** A
+locked gate is the author's floor: the API rejects a write that would loosen it, and the evaluator
+re-tightens anything that reached the policy store by another route.
+
+The pause is safe by construction: gate evaluation happens in `HandleAsync` **before** the pipeline
+runs, so when an instance parks nothing downstream of that point has executed and no side effect has
+occurred. A rejected decision arrives as `ApprovalRejectedException` through the classifier, so a
+workflow can treat rejection as a branch rather than as a crash.
+
+See [Human approval gates](wiki.md#human-approval-gates) for the decision flow and
+[Tenant executor configuration](wiki.md#tenant-executor-configuration) for precedence.
+
+---
+
+## 10. Notifications and events
+
+Every run emits events to a durable log; some also stream over SSE. A definition that says nothing
+gets `NotificationPolicy.Default` — everything, logged and streamed.
+
+```csharp
+public NotificationPolicy Notifications { get; } = new()
+{
+ Level = NotificationLevel.Lifecycle,
+ StreamEvents = true,
+ ByNode = new Dictionary(StringComparer.Ordinal)
+ {
+ ["settle"] = NotificationLevel.Standard
+ },
+ Emits = ["order.repriced"]
+};
+```
+
+| Level | Emits |
+| --- | --- |
+| `Minimal` | Start, output and terminal only |
+| `Lifecycle` | Adds superstep boundaries — progress without per-node chatter |
+| `Standard` | Adds `executor.*`, `llm.*` and workflow-defined events. The default |
+
+Two properties of the model matter more than the levels:
+
+- **The durable log is not optional.** `StreamEvents = false` turns off the *live stream* only; every
+ event is still written and the run is always reconstructable. The SSE endpoint then returns `409`
+ naming the history route, because a silently empty stream is indistinguishable from a stalled run.
+- **Some events are never suppressed.** Terminal events, approvals, control actions and broker
+ deliveries are facts about the system rather than run chatter, and a workflow has no business
+ hiding any of them. Suppression is also decided *before* a sequence number is taken, so a quiet
+ workflow leaves no holes for `Last-Event-ID` catch-up to wait on.
+
+### Emitting your own
+
+`Emits` advertises names on `GET /workflows/{name}`; the node does the emitting:
+
+```csharp
+if (Runtime.Notify is { } notify)
+{
+ await notify.NotifyAsync("documents.scanned", new { count = input.Documents.Count }, ct);
+}
+// → event type: custom.documents.scanned
+```
+
+The `custom.` prefix is applied by the framework and cannot be opted out of, so a workflow can never
+shadow a framework event however it names its own. A malformed name (empty, whitespace, empty dotted
+segment) throws rather than emitting, because an event with a broken type is indistinguishable from
+one that was never sent.
+
+---
+
+## 11. Domain events: publishing, waiting, triggering
+
+Three separate capabilities that happen to share a topic space.
+
+### Publishing
+
+```csharp
+var broker = context.Services!.GetRequiredService();
+
+ExecutorBinding publish = context.Node(new PublishDomainEventExecutor(
+ "publish-order-placed", broker,
+ topic: "orders.placed",
+ payload: o => new { o.OrderId, o.Amount }, // defaults to the input
+ correlationKey: o => o.OrderId,
+ scope: DeliveryScope.Local));
+```
+
+The node **passes its input through unchanged** — publishing is a side effect on the way past, so it
+drops into an existing edge without rewiring the graph. The message carries the run's tenant and
+source instance id automatically.
+
+`DeliveryScope.Distributed` on a broker that cannot deliver it throws at **construction**, not at
+publish time: that is a composition mistake, and finding it on the first message would mean finding
+it in production. The topic overload validates the pattern at construction too.
+
+### Waiting
+
+```csharp
+var subscriptions = context.Services!.GetRequiredService();
+
+ExecutorBinding awaitPayment = context.Node(
+ new WaitForDomainEventExecutor(
+ "await-settlement", subscriptions,
+ topicFilter: "payment.settled",
+ correlationKey: o => o.OrderId,
+ timeout: TimeSpan.FromDays(3),
+ onExpiry: WaitExpiryAction.DeadStop)); // or Resume, to take a timeout branch
+```
+
+It **runs twice**: the first pass registers a durable subscription and parks; after delivery the
+runner replays to this executor, which finds the payload and returns it. Anything it does before
+parking therefore happens twice — keep it to registering the wait. The instance releases its lease
+while parked, so waiting for days costs nothing.
+
+### Triggering
+
+```csharp
+public IReadOnlyList Triggers =>
+[
+ new DomainEventTrigger { TopicFilter = "orders.placed" },
+ new DomainEventTrigger
+ {
+ TopicFilter = "orders.*.expedited",
+ CorrelationKey = "premium", // only messages with this key
+ WorkflowVersion = "2.0.0", // pin, rather than resolving latest
+ ContextSelector = m => m.PayloadJson // remap when the payload is not the context
+ }
+];
+```
+
+The payload becomes the instance context and the message's correlation key becomes the instance's
+correlation id. Redelivery is absorbed by the launcher's idempotency key, so a message cannot start
+the same workflow twice.
+
+Topics are dot-segmented with `*` (one segment) and `#` (remainder) wildcards; `TopicPattern`
+validates both patterns and topics, and publishing an invalid topic fails the node rather than
+emitting something unroutable. Scope is a property of the *message*, not a different call — see
+[Local by default, global by declaration](wiki.md#local-by-default-global-by-declaration).
+
+---
+
+## 12. Failure, retry and limits
+
+`Classify` decides what a thrown exception means for the instance.
+
+| Disposition | Effect |
+| --- | --- |
+| `Retry` | Backoff and try again, until `MaxAttempts` or `MaxLifetimeHours` |
+| `DeadStop` | Terminal. Retrying cannot help, so do not burn attempts discovering that |
+| `Escalate` | Terminal, and flagged for operator attention |
+
+`WorkflowFailure` carries `ExecutorId`, `Exception`, `AttemptCount`, `Superstep` and the executor's
+`Metadata`, so a classifier can decide differently per node without inspecting message text.
+
+```csharp
+public FailureDisposition Classify(WorkflowFailure failure) => failure.Exception switch
+{
+ InsufficientFundsException => FailureDisposition.DeadStop,
+ ReconciliationBreakException => FailureDisposition.Escalate,
+ _ => DefaultFailureClassifier.Instance.Classify(failure)
+};
+```
+
+**Always delegate the default case.** The framework already knows a rate limit is worth retrying and
+a validation error is not; a definition should only state where its own domain disagrees.
+
+### What the default classifier already does
+
+| Exception | Disposition |
+| --- | --- |
+| `WorkflowDeadStopException`, `ApprovalRejectedException` | `DeadStop` |
+| `WorkflowValidationException`, `StructuredOutputException`, `JsonException` | `DeadStop` |
+| `LlmRateLimitException`, `LlmOverloadedException` | `Retry` |
+| `ApiCallFailureException` 408, 429, ≥ 500 | `Retry` |
+| `ApiCallFailureException` other 4xx | `DeadStop` |
+| `TimeoutException`, `TaskCanceledException`, `HttpRequestException` | `Retry` |
+| Anything else | `Retry` |
+
+Throwing `WorkflowDeadStopException` from inside an executor is the direct way to say "this run is
+over" without routing the decision through the classifier at all.
+
+### Retry mechanics
+
+Retries are the host's, configured rather than authored — `WorkflowHost:Retry` sets `MaxAttempts`
+(5), exponential backoff from `BackoffBaseSeconds` (2) capped at `BackoffCapSeconds` (300), full
+jitter, and `MaxLifetimeHours` (24) as the wall-clock ceiling. An attempt resumes **from the last
+checkpoint**, not from the beginning, so a retry re-runs the failed superstep rather than the run.
+
+That is why side effects inside an executor must be idempotent, and why `ApiCallExecutor` sends an
+idempotency key by default.
+
+---
+
+## 13. Audit records
+
+An audit record is a workflow's own account of what it did — separate from the event log, which is
+the framework's account of what happened. Declaring one is opt-in, and the shape is the workflow's
+because only the workflow knows what is audit-significant about its run.
+
+```csharp
+public AuditRecordDefinition AuditRecord { get; } = new(
+ "order", "One order, as processed.",
+ [
+ new AuditSectionDefinition("submission", "What was submitted.", Multiple: false),
+ new AuditSectionDefinition("step", "One processing step."),
+ new AuditSectionDefinition("outcome", "How the run settled.", Multiple: false)
+ ]);
+```
+
+Executors then write to the recorder the runtime hands them:
+
+```csharp
+if (Runtime.Audit is { } audit)
+{
+ await audit.OpenAsync(input.OrderId, new Dictionary { ["lines"] = n }, ct);
+ await audit.RecordAsync("step", key: input.LineId, new { accepted = true }, ct);
+ await audit.CloseAsync(AuditRecordStatus.Completed, ct);
+}
+```
+
+| Call | Contract |
+| --- | --- |
+| `OpenAsync(rootKey, attributes, ct)` | Opens or re-opens the root. Idempotent per instance, so a retried attempt reuses its record rather than starting a second one |
+| `RecordAsync(sectionKind, key, payload, ct)` | Appends an entry. `sectionKind` must be declared; `key` groups entries within a kind |
+| `CloseAsync(status, ct)` | Settles the record. `AuditRecordStatus.Completed` / `Failed`, or a status of your own |
+
+Two properties to rely on:
+
+- **Keying makes a retry correct.** An entry with the same `(instance, kind, key)` replaces the
+ earlier one, so a re-run executor corrects its record instead of appending a second, contradictory
+ entry.
+- **Recording never fails the work it describes.** Storage failures are swallowed and logged by
+ contract. An audit write is not a place to put a precondition.
+
+Storage is workflow-agnostic (`IAuditRecordStore`: a root, string-typed sections, JSON payloads), so a
+new workflow needs no schema change. The record surfaces on the instance state route, and
+[the shipped example](../src/Abacus.Run.Service/Workflows/ExampleOrder/ExampleOrderWorkflow.cs) is
+written to be read as the answer to "what does a definition have to do to get one".
+
+---
+
+## 14. Engine context inside an executor
+
+`ExecuteCoreAsync` receives the Agent Framework's `IWorkflowContext`, which is separate from
+`Runtime`: `Runtime` is what the host adds, `IWorkflowContext` is what the engine offers.
+
+| Member | Purpose |
+| --- | --- |
+| `QueueStateUpdateAsync(key, value)` | Writes state that survives into the next checkpoint |
+| `ReadStateAsync(key)` / `ReadOrInitStateAsync` | Reads it back after a resume |
+| `RequestHaltAsync()` | Parks the run — the mechanism behind gates and event waits |
+| `YieldOutputAsync(output)` | Emits a workflow output without being the terminal node |
+| `SendMessageAsync(message, targetId)` | Sends to a specific node, bypassing edge routing |
+| `AddEventAsync(workflowEvent)` | Raises an engine event, ordered with executor events |
+
+**Use `QueueStateUpdateAsync` rather than executor fields for anything that must survive a restart.**
+An executor instance is rebuilt by `BuildAsync` on resume, and a field is gone with it. This is the
+single most common source of "it works until it resumes".
+
+`YieldOutputAsync` is checked against the executor's declared output type, so a node cannot yield a
+shape it did not declare.
+
+---
+
+## 15. Middleware
+
+Two seams, both registered at composition rather than declared by a workflow — cross-cutting concerns
+are the host's business, not an author's. Lower `Order` runs earlier in the outer pipeline.
+
+```csharp
+public sealed class TimingMiddleware : IExecutorMiddleware
+{
+ public int Order => 10;
+
+ public bool AppliesTo(ExecutorDescriptor descriptor) => descriptor.ExecutorId != "noisy";
+
+ public async ValueTask InvokeAsync(
+ ExecutorInvocationContext context, ExecutorDelegate next, CancellationToken ct)
+ {
+ long start = Stopwatch.GetTimestamp();
+ await next(context, ct);
+ Record(context.Descriptor.ExecutorId, Stopwatch.GetElapsedTime(start), context.Succeeded);
+ }
+}
+```
+
+| Seam | Context | Wraps |
+| --- | --- | --- |
+| `IExecutorMiddleware` | `ExecutorInvocationContext` | Each executor invocation |
+| `IWorkflowMiddleware` | `WorkflowInvocationContext` | A whole run |
+
+`ExecutorInvocationContext.Exception` is **settable**, so middleware can observe, replace or swallow a
+failure as the pipeline unwinds — which is how retry-shaping and drift detection work without the
+workflow knowing. `Input` and `Output` are settable too; `Items` carries per-invocation state, with
+`MiddlewareContextKeys` naming the framework's own entries (`outbound.call`, `llm.usage`,
+`llm.prompt_version`).
+
+`IOutboundCallHandle` is the interesting one: middleware can read the outbound `HttpRequestMessage`
+of a built-in executor and **short-circuit it** with `SetSyntheticResponse`, which is how a call is
+stubbed or replayed without the node knowing it happened.
+
+`AddBuiltInMiddleware()` supplies OpenTelemetry spans for runs and executors, request/response
+logging with redaction, and LLM drift monitoring.
+
+---
+
+## 16. Registration and hosting
+
+```csharp
+builder.Services
+ .AddAbacus(builder.Configuration) // framework + infrastructure selection + control plane
+ .AddWorkflow() // resolved from DI
+ .AddWorkflow(new ShipOrderWorkflow()) // or supplied directly
+ .AddExecutorMiddleware()
+ .AddWorkflowMiddleware();
+```
+
+`AddAbacus` is this host's composition; the framework's own entry point is
+`AddWorkflowHost(configuration)`, which registers every store behind an interface with in-memory
+defaults, plus `AddBuiltInMiddleware()` and `AddBackgroundServices()`.
+
+| Call | Registers |
+| --- | --- |
+| `AddWorkflowHost(config, configure?)` | Runtime, stores, checkpoints, events, broker, options |
+| `AddWorkflow()` / `AddWorkflow(instance)` | A definition, as `IWorkflowDefinition` |
+| `AddExecutorMiddleware()` / `(instance)` | An executor-level seam |
+| `AddWorkflowMiddleware()` | A run-level seam |
+| `AddBuiltInMiddleware()` | Telemetry, logging, drift |
+| `AddBackgroundServices()` | Dispatcher, expiry sweepers, broker router |
+
+**Without `AddBackgroundServices()` instances are created and stay `Pending`** — nothing executes
+them. Two definitions with the same name and version fail startup rather than one silently winning.
+
+### What a definition may need from the host
+
+| Dependency | Needed by | Registered by default? |
+| --- | --- | --- |
+| `ITimerService` | `DelayExecutor` | **No** — supply one |
+| `IChatClient` (or a resolver) | `LlmExecutor` | No — supply one |
+| `IModelPricing` | `costUsd` on LLM events | Bound from `Abacus:Llm:Pricing:*`; absent means unknown, not free |
+| `IHttpClientFactory` | `ApiCallExecutor` | Standard ASP.NET registration; use `ApiCallOptions.HttpClientName` |
+| `IDomainEventBroker`, `IDomainEventSubscriptionStore` | Publish / wait / trigger | Yes — in-process and in-memory |
+
+Egress is enforced by default (`WorkflowHost:Egress:Enforce`), and an `ApiCallExecutor` whose URL is
+not on an allow-list is refused before the request is made.
+
+---
+
+## 17. Versions, identity and drift
+
+- **Executor ids are the key for tenant gate policies**, stored per workflow *version*. A tenant's
+ configuration does not carry forward to a new version, so a version bump starts from the author's
+ declared gates again.
+- **An in-flight instance keeps the version it started on.** The registry resolves by the instance's
+ recorded version, so redeploying never changes the shape of a run already underway.
+- **`POST /instances/{id}/rerun` in restart mode creates the new instance at the *current* version** —
+ the one case where a rerun can behave differently from the original.
+- **A published `(name, version)` should be treated as immutable.** The framework enforces uniqueness
+ at startup; it cannot tell that you changed the body of a node and kept the version. The DSL *can*
+ and does, by hashing the document — which is one honest reason to prefer it for frequently-edited
+ workflows.
+
+The catalog reports where a version was authored: `GET /workflows/{name}` carries `source` —
+`"compiled"` for a C# definition, `"dsl"` for a document — and a `documentHash` for the latter. A
+definition may implement `IDocumentAuthoredWorkflow` to report its own provenance, which is how the
+DSL does it without the framework knowing any front end exists.
+
+---
+
+## 18. What a definition gets for free
+
+None of this is declared by a workflow, because none of it is a workflow's business.
+
+| Capability | How it applies |
+| --- | --- |
+| **Checkpointing and resume** | Every superstep checkpoints; a parked instance releases its lease and resumes on any replica |
+| **At-least-once execution** | Lease-based dispatch, retry with backoff, resume from the last checkpoint |
+| **Executor and workflow middleware** | Every `Node(...)` runs the full pipeline |
+| **Redaction** | Applied at write time, so the history API cannot leak what the live stream withheld |
+| **Egress control** | Built-in HTTP nodes go through `EgressGuard` |
+| **Multi-tenancy** | Definitions are global; instances are tenant-scoped |
+| **Instance controls** | `cancel`, `suspend`, `resume`, `retry`, `rerun` |
+| **Observability** | Event history, SSE with `Last-Event-ID` catch-up, instance logs, the graph endpoint |
+| **Tenant gate configuration** | Every gated node, unless `.Locked()` |
+| **Approval flow** | Quorum, expiry, escalation, segregation of duties, modification |
+
+---
+
+## 19. Choosing between C# and the DSL
+
+Both front ends produce an `IWorkflowDefinition`, register in the same catalog, and run on the same
+runtime. The choice is about *who* changes the workflow and *what* it needs to do.
+
+| Reach for C# when | Reach for the DSL when |
+| --- | --- |
+| The work is computation — iterating a collection, aggregating, arithmetic over a domain model | The work is composition of nodes the host already ships |
+| The graph needs raw bindings, agents or sub-workflows | The document only needs the built-in kinds and registered custom nodes |
+| Nodes carry real logic worth unit-testing as code | The change is a threshold, a topic, an edge, a prompt |
+| The definition should be reviewed as code, with the domain types it uses | The definition should be editable without a build, and validated against a schema |
+
+The DSL's own [framework coverage map](dsl-authoring-guide.md#17-framework-coverage-map) is the
+authoritative statement of what a document can and cannot reach. The short version: everything in
+this guide except `DelegateExecutor`, raw/agent/sub-workflow bindings, `IWorkflowContext` access, and
+iteration — each of which is available to a document through a **custom node**, which is a small C#
+class registered by name.
+
+They mix freely in one host. A common shape is domain logic in custom nodes written once, with the
+graph that composes them authored as a document.
+
+---
+
+## 20. Sharp edges
+
+Real defects and traps, stated rather than left to be discovered.
+
+**`FanInExecutor` cannot be the target of `AddFanInBarrierEdge`.** It declares
+`HostExecutor, TOut>`, but the barrier's edge runner type-checks each released message
+individually, so a `List` target matches nothing and deliveries are dropped. Aggregate in a
+custom executor that holds arrivals and emits when the expected count lands.
+
+**No `ITimerService` is registered anywhere.** `DelayExecutor` requires one, so a stock host cannot
+run a delay node at all.
+
+**`BuildAsync` must be deterministic.** It runs per attempt, including on resume. A graph that varies
+by wall-clock time, random choice or a mutable service will not match its own checkpoint.
+
+**Executor fields do not survive a resume.** Use `QueueStateUpdateAsync`.
+
+**A raw node has no gate, no middleware, no audit and no notifier.** That is the trade, and it is
+enforced: passing a gate to `RawNode` throws.
+
+**Non-exhaustive edge predicates complete a run with no output.** The run reports success. Prefer a
+final unconditional edge, or assert on the result.
+
+**`WaitForDomainEventExecutor` and any hand-written park run twice.** Everything before the halt must
+be idempotent.
+
+**`costUsd` is null, not zero, without `IModelPricing`.** Do not sum it and report a total.
+
+---
+
+## Appendix A — worked variations
+
+Each recipe is a complete `BuildAsync` (or the declaration that matters), showing one shape in
+isolation. They compose — see the wiki's
+[worked definition](wiki.md#a-definition-using-all-of-it) for several at once.
+
+### A.1 Linear
+
+The default shape. One node after another, output from the last.
+
+```csharp
+public ValueTask BuildAsync(WorkflowBuildContext context, CancellationToken ct)
+{
+ ExecutorBinding validate = context.Node(new Validate("validate"));
+ ExecutorBinding enrich = context.Node(new Enrich("enrich"));
+ ExecutorBinding submit = context.Node(new Submit("submit"));
+
+ return new ValueTask(new WorkflowBuilder(validate)
+ .AddEdge(validate, enrich)
+ .AddEdge(enrich, submit)
+ .WithOutputFrom(submit)
+ .WithName(Name)
+ .Build());
+}
+```
+
+### A.2 Branch
+
+Two conditional edges out of one node. There is no switch construct; this is the branch.
+
+```csharp
+ExecutorBinding triage = context.Node(new Triage("triage"));
+ExecutorBinding fast = context.Node(new FastPath("fast-path"));
+ExecutorBinding manual = context.Node(new ManualPath("manual-path"));
+
+return new ValueTask(new WorkflowBuilder(triage)
+ .AddEdge(triage, fast, condition: o => o is { Amount: <= 10_000m })
+ .AddEdge(triage, manual, condition: o => o is { Amount: > 10_000m })
+ .WithOutputFrom(fast, manual) // whichever branch ran supplies the result
+ .WithName(Name)
+ .Build());
+```
+
+The condition's parameter is `T?`, so a pattern (`o is { … }`) reads better than a null-forgiving
+dereference and handles the null case explicitly.
+
+Make the predicates exhaustive. A message matching neither edge stops there, and the run completes
+with no output rather than failing — which looks like success and is the hardest branch bug to spot.
+
+### A.3 Fan-out and fan-in
+
+```csharp
+ExecutorBinding split = context.Node(new Split("split"));
+ExecutorBinding credit = context.Node(new CheckCredit("check-credit"));
+ExecutorBinding stock = context.Node(new CheckStock("check-stock"));
+ExecutorBinding fraud = context.Node(new CheckFraud("check-fraud"));
+ExecutorBinding decide = context.Node(new CollectChecks("decide", expected: 3));
+
+return new ValueTask(new WorkflowBuilder(split)
+ .AddFanOutEdge(split, [credit, stock, fraud])
+ .AddFanInBarrierEdge([credit, stock, fraud], decide) // waits for all three
+ .WithOutputFrom(decide)
+ .WithName(Name)
+ .Build());
+```
+
+Selective fan-out picks targets by index instead of sending to all:
+
+```csharp
+.AddFanOutEdge(split, [credit, stock, fraud],
+ targetSelector: (order, count) => order!.SkipFraudCheck ? [0, 1] : [0, 1, 2])
+```
+
+The barrier target takes the **individual** message type and counts arrivals itself — see
+[§20](#20-sharp-edges) for why `FanInExecutor` does not work here:
+
+```csharp
+private sealed class CollectChecks(string id, int expected) : HostExecutor(id)
+{
+ private readonly List _arrived = [];
+
+ protected override ValueTask ExecuteCoreAsync(
+ CheckResult input, IWorkflowContext context, CancellationToken ct)
+ {
+ _arrived.Add(input);
+
+ if (_arrived.Count < expected)
+ {
+ return ValueTask.FromResult(null!); // not yet: emit nothing
+ }
+
+ var decision = new Decision(_arrived.All(c => c.Passed));
+ _arrived.Clear(); // ready for a second barrier release
+ return ValueTask.FromResult(decision);
+ }
+}
+```
+
+The arrivals are held for the duration of one attempt, which is all a barrier release spans — the
+executor is rebuilt on resume, so nothing is expected to survive it. This is exactly what the DSL's
+`fan-in` node does, with the expected count read from the document instead of passed in.
+
+A wide fan-out is the usual reason to set `NotificationLevel.Lifecycle` — see
+[A.10](#a10-quiet-a-chatty-workflow).
+
+### A.4 Approval gates
+
+Three ways to gate, from blunt to conditional:
+
+```csharp
+// Always requires a decision.
+context.Node(new Publish("publish"), gate => gate
+ .Mode(ExecutionMode.RequireApproval)
+ .AssignTo("group:ops")
+ .ExpiresAfter(TimeSpan.FromHours(4)));
+
+// Only above a threshold. `When` implies Conditional mode.
+context.Node(new Settle("settle"), gate => gate
+ .When(order => order.Amount > 25_000m)
+ .Reason("AmountAboveThreshold")
+ .RequireApprovers(2)
+ .AllowModification());
+
+// A floor a tenant may tighten but never weaken.
+context.Node(new Payout("payout"), gate => gate
+ .Mode(ExecutionMode.RequireApproval)
+ .AssignTo("group:finance")
+ .RequireSegregationOfDuties()
+ .OnExpiry(ExpiryAction.DeadStop)
+ .Locked());
+```
+
+`HumanApprovalExecutor` puts the decision in the graph as a node rather than as configuration on a
+node that also does work. The executor is identity work; the gate is what pauses:
+
+```csharp
+ExecutorBinding signOff = context.Node(
+ new HumanApprovalExecutor("sign-off"),
+ gate => gate.Mode(ExecutionMode.RequireApproval).AssignTo("group:ops"));
+```
+
+### A.5 Durable delay
+
+`DelayExecutor` checkpoints and halts rather than blocking a thread or holding a lease, so a long
+delay costs no execution capacity.
+
+```csharp
+var timers = context.Services!.GetRequiredService();
+
+ExecutorBinding cooloff = context.Node(
+ new DelayExecutor("cool-off", TimeSpan.FromHours(24), timers));
+
+return new ValueTask(new WorkflowBuilder(submit)
+ .AddEdge(submit, cooloff)
+ .AddEdge(cooloff, settle)
+ .WithOutputFrom(settle)
+ .Build());
+```
+
+### A.6 HTTP call
+
+```csharp
+ExecutorBinding fetch = context.Node(new ApiCallExecutor("fetch-invoice",
+ new ApiCallOptions
+ {
+ Method = HttpMethod.Get,
+ UrlTemplate = "https://erp.internal/invoices/{{ context.InvoiceId }}",
+ Headers = { ["Accept"] = "application/json" },
+ TimeoutSeconds = 15,
+ SuccessCodes = [200, 204],
+ ResponseAs = typeof(InvoiceDto),
+ AllowedHosts = ["erp.internal"],
+ EnforceEgress = true, // refuse anything not on the allow-list
+ SendIdempotencyKey = true // safe to retry
+ },
+ () => context.Services!.GetRequiredService()
+ .CreateClient(ApiCallOptions.HttpClientName)));
+```
+
+A non-success status arrives as a typed `ApiCallFailureException` carrying status, body excerpt and
+`Retry-After`, so [`Classify`](#a14-custom-failure-classification) can act on it rather than parsing
+a message.
+
+### A.7 LLM node
+
+```csharp
+ExecutorBinding classify = context.Node(new LlmExecutor("classify",
+ new LlmOptions
+ {
+ Model = "claude-sonnet-5",
+ SystemPrompt = "Classify the invoice.",
+ PromptVersion = "v3", // tags the drift baseline
+ UserTemplate = "{{ context.DocumentText }}",
+ StructuredOutput = typeof(Classification),
+ Temperature = 0.0f,
+ MaxTokens = 2048,
+ StreamDeltas = true, // llm.delta frames, live only
+ EmitCompletion = true // one llm.completed per call (default)
+ },
+ model => context.Services!.GetRequiredService(),
+ context.Services!.GetService())); // enables costUsd and cost drift
+```
+
+Pass the pricing service or `costUsd` is `null` — absent, not zero. See
+[LLM telemetry](wiki.md#llm-telemetry).
+
+### A.8 Started by an event
+
+```csharp
+public sealed class ShipOrderWorkflow
+ : IWorkflowDefinition, IDomainEventTriggeredWorkflow
+{
+ public string Name => "ship-order";
+ public string Version => "1.0.0";
+
+ public IReadOnlyList Triggers =>
+ [
+ new DomainEventTrigger { TopicFilter = "orders.placed" },
+ new DomainEventTrigger
+ {
+ TopicFilter = "orders.*.expedited",
+ ContextSelector = m => m.PayloadJson // remap if the payload is not the context
+ }
+ ];
+
+ // BuildAsync as usual; the message payload arrives as the context.
+}
+```
+
+The message payload becomes the instance context, and its correlation key becomes the instance's
+correlation id. Redelivery is absorbed by the launcher's idempotency key, so a message cannot start
+the same workflow twice.
+
+### A.9 Publish and wait
+
+A two-workflow pipeline. The first publishes; the second parks until the reply arrives.
+
+```csharp
+// Producer — publishing is a side effect on the way past, so the node drops into an existing edge.
+var broker = context.Services!.GetRequiredService();
+
+ExecutorBinding publish = context.Node(new PublishDomainEventExecutor(
+ "publish-order-placed", broker,
+ topic: "orders.placed",
+ correlationKey: o => o.OrderId));
+
+// Consumer — parks, releases its lease, and resumes with the payload.
+var subscriptions = context.Services!.GetRequiredService();
+
+ExecutorBinding awaitPayment = context.Node(
+ new WaitForDomainEventExecutor(
+ "await-settlement", subscriptions,
+ topicFilter: "payment.settled",
+ correlationKey: o => o.OrderId,
+ timeout: TimeSpan.FromDays(3),
+ onExpiry: WaitExpiryAction.DeadStop)); // or Resume, to take a timeout branch
+```
+
+Publishing across a service boundary is a scope on the message, not a different call — see
+[Local by default, global by declaration](wiki.md#local-by-default-global-by-declaration).
+
+### A.10 Quiet a chatty workflow
+
+```csharp
+public NotificationPolicy Notifications { get; } = new()
+{
+ Level = NotificationLevel.Lifecycle, // supersteps, no per-node chatter
+ ByNode = new Dictionary(StringComparer.Ordinal)
+ {
+ ["reconcile"] = NotificationLevel.Standard // except this one
+ }
+};
+```
+
+### A.11 Log without streaming
+
+```csharp
+public NotificationPolicy Notifications { get; } = new()
+{
+ StreamEvents = false // full event log; no SSE
+};
+```
+
+The log is unconditional either way. `GET /instances/{id}/events` then returns `409` naming
+`GET /v2/workflows/{name}/instances/{id}/events`. See
+[Turning SSE off for a workflow](wiki.md#turning-sse-off-for-a-workflow).
+
+### A.12 Custom notifications from a node
+
+```csharp
+public NotificationPolicy Notifications { get; } = new()
+{
+ Emits = ["documents.scanned"] // advertised on GET /workflows/{name}
+};
+```
+
+```csharp
+protected override async ValueTask ExecuteCoreAsync(
+ ScanContext input, IWorkflowContext context, CancellationToken ct)
+{
+ if (Runtime.Notify is { } notify)
+ {
+ await notify.NotifyAsync("documents.scanned", new { count = input.Documents.Count }, ct);
+ }
+ // → event: custom.documents.scanned
+}
+```
+
+### A.13 Audit record
+
+```csharp
+public AuditRecordDefinition AuditRecord { get; } = new(
+ "order", "One order, as processed.",
+ [
+ new AuditSectionDefinition("submission", "What was submitted.", Multiple: false),
+ new AuditSectionDefinition("step", "One processing step."),
+ new AuditSectionDefinition("outcome", "How the run settled.", Multiple: false)
+ ]);
+```
+
+```csharp
+if (Runtime.Audit is { } audit)
+{
+ await audit.OpenAsync(input.OrderId, attributes: null, ct);
+ await audit.RecordAsync("step", key: input.LineId, new { accepted = true }, ct);
+ await audit.CloseAsync(AuditRecordStatus.Completed, ct);
+}
+```
+
+Keying an entry means a retried executor corrects its record rather than doubling it. See
+[Workflow audit records](wiki.md#workflow-audit-records).
+
+### A.14 Custom failure classification
+
+```csharp
+public FailureDisposition Classify(WorkflowFailure failure) => failure.Exception switch
+{
+ InsufficientFundsException => FailureDisposition.DeadStop, // retrying cannot help
+ ThirdPartyThrottleException => FailureDisposition.Retry,
+ ReconciliationBreakException => FailureDisposition.Escalate, // terminal, flag for an operator
+ _ => DefaultFailureClassifier.Instance.Classify(failure)
+};
+```
+
+Classify per node when the same exception means different things in different places — the failure
+carries `ExecutorId` and the executor's `Metadata`:
+
+```csharp
+public FailureDisposition Classify(WorkflowFailure failure)
+ => failure is { ExecutorId: "optional-enrichment", Exception: HttpRequestException }
+ ? FailureDisposition.DeadStop // this node is best-effort; do not burn attempts
+ : DefaultFailureClassifier.Instance.Classify(failure);
+```
+
+### A.15 Raw nodes, agents and sub-workflows
+
+Raw nodes join the graph but run outside the executor middleware pipeline and cannot be
+approval-gated — passing a gate block throws.
+
+```csharp
+// An AIAgent as a node.
+ExecutorBinding triage = context.RawNode(someAgent.BindAsExecutor("triage-agent"));
+
+// Another workflow as a node.
+Workflow enrichment = BuildEnrichmentGraph();
+ExecutorBinding enrich = context.RawNode(enrichment.BindAsExecutor("enrich"));
+
+// A bare handler, with no executor class at all.
+Func logHandler =
+ (order, _, _) => { Log(order); return ValueTask.CompletedTask; };
+ExecutorBinding log = context.RawNode(logHandler.BindAsExecutor("log"));
+
+ExecutorBinding record = context.Node(new Record("record")); // gated, audited, with middleware
+
+return new ValueTask(new WorkflowBuilder(triage)
+ .AddEdge(triage, enrich)
+ .AddEdge(enrich, log)
+ .AddEdge(log, record)
+ .WithOutputFrom(record)
+ .Build());
+```
+
+A sub-workflow node runs the child graph inline. It is not a child *instance* — there is no separate
+instance row, lease or event stream for it, and its nodes are not separately gateable or
+configurable. Bind a workflow as an executor to compose graph shape; use an event trigger
+([A.8](#a8-started-by-an-event)) when you want a genuinely independent run.
+
+### A.16 Registering what you built
+
+```csharp
+builder.Services
+ .AddAbacus(builder.Configuration) // or AddWorkflowHost + AddBuiltInMiddleware + AddBackgroundServices
+ .AddWorkflow() // resolved from DI
+ .AddWorkflow(new ShipOrderWorkflow()) // or supplied directly
+ .AddExecutorMiddleware();
+```
+
+Without `AddBackgroundServices()` instances are created and stay `Pending` — nothing executes them.
+See [Registering workflows and middleware](wiki.md#registering-workflows-and-middleware).
+
+---
+
+## Appendix B — options reference
+
+Defaults are what you get by omitting the property.
+
+### `ApiCallOptions`
+
+| Property | Default | Notes |
+| --- | --- | --- |
+| `Method` | `GET` | |
+| `UrlTemplate` | required | `{{ }}` placeholders resolved against the input |
+| `Headers` | empty | Values are templated too |
+| `BodyTemplate` | none | Sent as `application/json` |
+| `TimeoutSeconds` | 30 | |
+| `SuccessCodes` | 200, 201, 202, 204 | Anything else raises `ApiCallFailureException` |
+| `ResponseAs` | none | Deserializes `Body`; `RawBody` is always the text |
+| `AllowedHosts` | empty | Checked before the request |
+| `EnforceEgress` | true | |
+| `SendIdempotencyKey` | true | `{instance}:{executor}:{attempt}` |
+| `HttpClientName` | `abacus.run.apicall` | Const, for `IHttpClientFactory` |
+
+### `LlmOptions`
+
+| Property | Default | Notes |
+| --- | --- | --- |
+| `Model` | required | Passed to the client resolver and as `ModelId` |
+| `SystemPrompt` | none | |
+| `UserTemplate` | required | Templated |
+| `PromptVersion` | none | Tags the drift baseline |
+| `StructuredOutput` | none | Enforced; failure is `StructuredOutputException` |
+| `Temperature`, `MaxTokens` | provider defaults | |
+| `StreamDeltas` | false | `llm.delta`, live only |
+| `MaxReparseAttempts` | 2 | |
+| `EmitCompletion` | true | One `llm.completed` per call |
+
+### `ApprovalGate`
+
+| Property | Default |
+| --- | --- |
+| `Mode` | `Autonomous` (`RequireApproval` when built by the builder) |
+| `Predicate` | none — never serialized |
+| `Reason` | none |
+| `Assignees` | empty |
+| `RequiredApprovers` | 1 |
+| `Expiry` | 24 hours |
+| `OnExpiry` | `DeadStop` |
+| `EscalationAssignees` | empty |
+| `AllowModification`, `RequireSegregationOfDuties`, `Locked` | false |
+
+### `NotificationPolicy`
+
+| Property | Default |
+| --- | --- |
+| `Level` | `Standard` |
+| `StreamEvents` | true |
+| `ByNode` | empty |
+| `Emits` | empty |
+
+### `DomainEventTrigger`
+
+| Property | Default | Notes |
+| --- | --- | --- |
+| `TopicFilter` | required | `*` one segment, `#` remainder |
+| `CorrelationKey` | none | Only messages carrying this key start a run |
+| `WorkflowVersion` | latest | Pin to a version |
+| `ContextSelector` | payload as-is | Remap the message into the context |
+
+### Host options a definition feels
+
+Configured under `WorkflowHost`, not by the definition, but they decide how a workflow behaves under
+failure and load.
+
+| Setting | Default |
+| --- | --- |
+| `Retry:MaxAttempts` | 5 |
+| `Retry:BackoffBaseSeconds` / `BackoffCapSeconds` | 2 / 300 |
+| `Retry:Jitter` | `Full` |
+| `Retry:MaxLifetimeHours` | 24 |
+| `Lease:DurationSeconds` / `RenewalSeconds` | 60 / 20 |
+| `Checkpoint:Cadence` | `SuperStep` |
+| `Checkpoint:InlineThresholdBytes` | 262144 — larger checkpoints overflow to blob storage |
+| `Approvals:DefaultExpiryHours` | 24 |
+| `Egress:Enforce` | true |
+| `MaxConcurrentInstances` | 100 |
+
+---
+
+## Related
+
+- [`wiki.md`](wiki.md) — orientation, operations, HTTP API, configuration
+- [`dsl-authoring-guide.md`](dsl-authoring-guide.md) — the same runtime, authored as JSON
+- [`ExampleOrderWorkflow.cs`](../src/Abacus.Run.Service/Workflows/ExampleOrder/ExampleOrderWorkflow.cs) — the shipped worked example
+- [`PRD-Abacus-Run.md`](PRD-Abacus-Run.md), [`TDD-Abacus-Run.md`](TDD-Abacus-Run.md) — requirements and design
diff --git a/src/Abacus.Data.Service/Properties/launchSettings.json b/src/Abacus.Data.Service/Properties/launchSettings.json
new file mode 100644
index 0000000..4b4b156
--- /dev/null
+++ b/src/Abacus.Data.Service/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "Abacus.Data.Service": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:59192;http://localhost:59193"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj b/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj
new file mode 100644
index 0000000..0f1ba6c
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj
@@ -0,0 +1,51 @@
+
+
+ Abacus.Run.Dsl
+
+
+
+ true
+ Abacus.Run.Dsl
+ 1.0.0
+ Abacus Run DSL - declarative workflow authoring
+ Najaf Shaikh
+ CodeShayk
+ Abacus Run
+
+ Authors an Abacus Run workflow as a JSON document instead of C#. Validates against a published
+ JSON Schema, reports pointer-accurate diagnostics, and interprets the document onto the same
+ runtime a compiled definition uses. Separate from Abacus.Run so a host that authors every
+ workflow in code carries neither the schema validator nor the expression parser.
+
+ workflow;dsl;json-schema;declarative;orchestration;dotnet
+ MIT
+ https://github.com/CodeShayk/Abacus-Run
+ https://github.com/CodeShayk/Abacus-Run
+ git
+ false
+ Copyright (c) 2025 Najaf Shaikh
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Abacus.Run.Dsl/Expressions/AbExEvaluator.cs b/src/Abacus.Run.Dsl/Expressions/AbExEvaluator.cs
new file mode 100644
index 0000000..f1d62bb
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Expressions/AbExEvaluator.cs
@@ -0,0 +1,344 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace Abacus.Run.Dsl.Expressions;
+
+/// The three roots an expression may read. Nothing else is in scope.
+public sealed record AbExContext(JsonNode? Data, JsonNode? Context, JsonObject Run)
+{
+ public static AbExContext Empty { get; } = new(null, null, []);
+}
+
+///
+/// Evaluates an AbEx tree.
+///
+///
+///
+/// Total by construction: every operation over every value produces a value, and
+/// is what stands in for "no answer". Nothing here throws, because a
+/// workflow must not fail on an expression over a document shape the author did not anticipate — it
+/// must take the other branch.
+///
+///
+/// Pure by construction: there is no I/O, no state, and no way to reach either from the grammar.
+///
+///
+public static class AbExEvaluator
+{
+ /// Bounds a pathological pattern. A timed-out match is a non-match, never a fault.
+ public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(200);
+
+ private static readonly ConcurrentDictionary RegexCache = new(StringComparer.Ordinal);
+
+ public static AbExValue Evaluate(AbExNode node, AbExContext context)
+ {
+ ArgumentNullException.ThrowIfNull(node);
+ ArgumentNullException.ThrowIfNull(context);
+
+ return node switch
+ {
+ AbExLiteral literal => literal.Value,
+ AbExPath path => ResolvePath(path, context),
+ AbExUnary unary => EvaluateUnary(unary, context),
+ AbExBinary binary => EvaluateBinary(binary, context),
+ AbExCall call => EvaluateCall(call, context),
+ _ => AbExValue.Absent
+ };
+ }
+
+ ///
+ /// Strict boolean coercion: only true is true. Absent, null, 0 and "" are
+ /// all false, and there is no truthiness ladder to remember.
+ ///
+ public static bool EvaluateCondition(AbExNode node, AbExContext context)
+ => Evaluate(node, context).IsTruthy;
+
+ private static AbExValue ResolvePath(AbExPath path, AbExContext context)
+ {
+ JsonNode? current = path.Root switch
+ {
+ AbExRoot.Data => context.Data,
+ AbExRoot.Context => context.Context,
+ _ => context.Run
+ };
+
+ // A root that is itself missing is absent, not null: '$ .x' over a null data payload has no
+ // answer, and reporting null would let '== null' succeed against a value that is not there.
+ if (current is null && path.Segments.Count > 0)
+ {
+ return AbExValue.Absent;
+ }
+
+ foreach (AbExSegment segment in path.Segments)
+ {
+ if (segment.IsIndex)
+ {
+ if (current is not JsonArray array || segment.Index < 0 || segment.Index >= array.Count)
+ {
+ return AbExValue.Absent;
+ }
+
+ current = array[segment.Index];
+ continue;
+ }
+
+ if (current is not JsonObject obj || !obj.TryGetPropertyValue(segment.Name!, out JsonNode? child))
+ {
+ return AbExValue.Absent;
+ }
+
+ current = child;
+ }
+
+ return path.Segments.Count == 0 && current is null
+ ? AbExValue.Absent
+ : AbExValue.FromNode(current);
+ }
+
+ private static AbExValue EvaluateUnary(AbExUnary unary, AbExContext context)
+ {
+ AbExValue operand = Evaluate(unary.Operand, context);
+
+ return unary.Operator switch
+ {
+ "!" => operand.Kind == AbExValueKind.Boolean
+ ? AbExValue.Bool(!operand.AsBoolean)
+ : AbExValue.Absent,
+
+ "-" => operand.Kind == AbExValueKind.Number
+ ? AbExValue.Number(-operand.AsNumber)
+ : AbExValue.Absent,
+
+ _ => AbExValue.Absent
+ };
+ }
+
+ private static AbExValue EvaluateBinary(AbExBinary binary, AbExContext context)
+ {
+ // Short-circuit before evaluating the right operand, so a guard like
+ // 'has($.order) && $.order.total > 0' costs nothing when the guard fails.
+ if (binary.Operator is "&&" or "||")
+ {
+ AbExValue left = Evaluate(binary.Left, context);
+ if (left.Kind != AbExValueKind.Boolean)
+ {
+ return AbExValue.Absent;
+ }
+
+ if (binary.Operator == "&&" && !left.AsBoolean) return AbExValue.False;
+ if (binary.Operator == "||" && left.AsBoolean) return AbExValue.True;
+
+ AbExValue right = Evaluate(binary.Right, context);
+ return right.Kind == AbExValueKind.Boolean ? right : AbExValue.Absent;
+ }
+
+ AbExValue a = Evaluate(binary.Left, context);
+ AbExValue b = Evaluate(binary.Right, context);
+
+ return binary.Operator switch
+ {
+ "==" or "!=" or "<" or "<=" or ">" or ">=" => Compare(binary.Operator, a, b),
+ _ => Arithmetic(binary.Operator, a, b)
+ };
+ }
+
+ ///
+ /// Absence makes every comparison false, including !=. That is deliberate: a document
+ /// asking whether a field it never set differs from a value should not be told "yes". Use
+ /// has() to ask about presence.
+ ///
+ private static AbExValue Compare(string op, AbExValue a, AbExValue b)
+ {
+ if (a.IsAbsent || b.IsAbsent)
+ {
+ return AbExValue.False;
+ }
+
+ if (op is "==" or "!=")
+ {
+ bool equal = a.Equals(b);
+ return AbExValue.Bool(op == "==" ? equal : !equal);
+ }
+
+ int comparison;
+ if (a.Kind == AbExValueKind.Number && b.Kind == AbExValueKind.Number)
+ {
+ comparison = decimal.Compare(a.AsNumber, b.AsNumber);
+ }
+ else if (a.Kind == AbExValueKind.String && b.Kind == AbExValueKind.String)
+ {
+ comparison = string.CompareOrdinal(a.AsString, b.AsString);
+ }
+ else
+ {
+ // No coercion ladder: ordering two different JSON types has no defensible answer.
+ return AbExValue.False;
+ }
+
+ return AbExValue.Bool(op switch
+ {
+ "<" => comparison < 0,
+ "<=" => comparison <= 0,
+ ">" => comparison > 0,
+ _ => comparison >= 0
+ });
+ }
+
+ ///
+ /// Decimal, and numbers only. These documents price orders, so binary floating point is the
+ /// wrong default. + does not concatenate strings — that is what templates are for, and a
+ /// + that sometimes adds and sometimes joins is the single most reliable source of bugs
+ /// in languages that allow it.
+ ///
+ private static AbExValue Arithmetic(string op, AbExValue a, AbExValue b)
+ {
+ if (a.Kind != AbExValueKind.Number || b.Kind != AbExValueKind.Number)
+ {
+ return AbExValue.Absent;
+ }
+
+ decimal x = a.AsNumber;
+ decimal y = b.AsNumber;
+
+ switch (op)
+ {
+ case "+": return AbExValue.Number(x + y);
+ case "-": return AbExValue.Number(x - y);
+ case "*": return AbExValue.Number(x * y);
+
+ case "/":
+ case "%":
+ if (y == 0m)
+ {
+ return AbExValue.Absent;
+ }
+
+ return AbExValue.Number(op == "/" ? x / y : x % y);
+
+ default:
+ return AbExValue.Absent;
+ }
+ }
+
+ private static AbExValue EvaluateCall(AbExCall call, AbExContext context)
+ {
+ // An unknown or mis-arity call cannot reach here through a validated document; if it does,
+ // absent keeps the evaluator total rather than surfacing a validator bug as a run failure.
+ if (!AbExFunctions.TryGet(call.Name, out AbExFunction function) ||
+ !function.AcceptsArity(call.Arguments.Count))
+ {
+ return AbExValue.Absent;
+ }
+
+ switch (call.Name)
+ {
+ case "has":
+ return AbExValue.Bool(!Evaluate(call.Arguments[0], context).IsAbsent);
+
+ case "coalesce":
+ foreach (AbExNode argument in call.Arguments)
+ {
+ AbExValue candidate = Evaluate(argument, context);
+ if (candidate.Kind is not (AbExValueKind.Absent or AbExValueKind.Null))
+ {
+ return candidate;
+ }
+ }
+
+ return AbExValue.Absent;
+ }
+
+ AbExValue first = Evaluate(call.Arguments[0], context);
+
+ switch (call.Name)
+ {
+ case "len":
+ return AbExValue.Number(first.Length);
+
+ case "lower":
+ return first.Kind == AbExValueKind.String
+ ? AbExValue.String(first.AsString.ToLowerInvariant())
+ : AbExValue.Absent;
+
+ case "upper":
+ return first.Kind == AbExValueKind.String
+ ? AbExValue.String(first.AsString.ToUpperInvariant())
+ : AbExValue.Absent;
+
+ case "number":
+ return first.Kind switch
+ {
+ AbExValueKind.Number => first,
+ AbExValueKind.String when decimal.TryParse(
+ first.AsString, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal parsed)
+ => AbExValue.Number(parsed),
+ _ => AbExValue.Absent
+ };
+
+ case "string":
+ return first.IsAbsent ? AbExValue.Absent : AbExValue.String(first.ToText());
+
+ case "bool":
+ return first.Kind switch
+ {
+ AbExValueKind.Boolean => first,
+ AbExValueKind.String when bool.TryParse(first.AsString, out bool parsed)
+ => AbExValue.Bool(parsed),
+ _ => AbExValue.Absent
+ };
+ }
+
+ AbExValue second = Evaluate(call.Arguments[1], context);
+
+ if (first.Kind != AbExValueKind.String || second.Kind != AbExValueKind.String)
+ {
+ return AbExValue.Absent;
+ }
+
+ string subject = first.AsString;
+ string operand = second.AsString;
+
+ return call.Name switch
+ {
+ "contains" => AbExValue.Bool(subject.Contains(operand, StringComparison.Ordinal)),
+ "startsWith" => AbExValue.Bool(subject.StartsWith(operand, StringComparison.Ordinal)),
+ "endsWith" => AbExValue.Bool(subject.EndsWith(operand, StringComparison.Ordinal)),
+ "matches" => Matches(subject, operand),
+ _ => AbExValue.Absent
+ };
+ }
+
+ private static AbExValue Matches(string subject, string pattern)
+ {
+ Regex? regex = RegexCache.GetOrAdd(pattern, static p =>
+ {
+ try
+ {
+ return new Regex(p, RegexOptions.CultureInvariant, RegexTimeout);
+ }
+ catch (ArgumentException)
+ {
+ // Cached as null so a bad pattern is compiled once, not once per evaluation.
+ return null;
+ }
+ });
+
+ if (regex is null)
+ {
+ return AbExValue.Absent;
+ }
+
+ try
+ {
+ return AbExValue.Bool(regex.IsMatch(subject));
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ // A pattern that cannot decide in 200 ms has not matched. Failing the run instead would
+ // hand any author a way to stall a dispatcher.
+ return AbExValue.False;
+ }
+ }
+}
diff --git a/src/Abacus.Run.Dsl/Expressions/AbExFunctions.cs b/src/Abacus.Run.Dsl/Expressions/AbExFunctions.cs
new file mode 100644
index 0000000..b64fb65
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Expressions/AbExFunctions.cs
@@ -0,0 +1,103 @@
+namespace Abacus.Run.Dsl.Expressions;
+
+/// Arity and evaluation rules for one built-in function.
+public sealed record AbExFunction(string Name, int MinArguments, int MaxArguments, string Summary)
+{
+ /// Unbounded arity, used by coalesce.
+ public const int Variadic = int.MaxValue;
+
+ public bool AcceptsArity(int count) => count >= MinArguments && count <= MaxArguments;
+
+ public string DescribeArity() => MaxArguments switch
+ {
+ Variadic => $"at least {MinArguments}",
+ _ when MinArguments == MaxArguments => MinArguments.ToString(),
+ _ => $"{MinArguments} to {MaxArguments}"
+ };
+}
+
+///
+/// The closed function set. Closed is the point: an unknown name is a validation error the author
+/// sees while editing, and growing this list is a deliberate, reviewable change rather than
+/// something a document can do to itself.
+///
+public static class AbExFunctions
+{
+ private static readonly Dictionary Registry =
+ new(StringComparer.Ordinal)
+ {
+ ["len"] = new("len", 1, 1, "Length of a string, array or object; 0 for anything else."),
+ ["has"] = new("has", 1, 1, "Whether the argument resolved to anything at all."),
+ ["lower"] = new("lower", 1, 1, "Lowercases a string, invariant culture."),
+ ["upper"] = new("upper", 1, 1, "Uppercases a string, invariant culture."),
+ ["contains"] = new("contains", 2, 2, "Ordinal substring test."),
+ ["startsWith"] = new("startsWith", 2, 2, "Ordinal prefix test."),
+ ["endsWith"] = new("endsWith", 2, 2, "Ordinal suffix test."),
+ ["matches"] = new("matches", 2, 2, "Regex test. The pattern must be a string literal."),
+ ["coalesce"] = new("coalesce", 1, AbExFunction.Variadic, "First argument that is neither absent nor null."),
+ ["number"] = new("number", 1, 1, "Coerces to a number, or absent if it cannot."),
+ ["string"] = new("string", 1, 1, "Coerces to a string."),
+ ["bool"] = new("bool", 1, 1, "Coerces to a boolean, or absent if it cannot.")
+ };
+
+ public static IReadOnlyCollection Names => Registry.Keys;
+
+ public static bool TryGet(string name, out AbExFunction function) => Registry.TryGetValue(name, out function!);
+
+ public static bool Exists(string name) => Registry.ContainsKey(name);
+
+ ///
+ /// Nearest known name by edit distance, for "did you mean". Only offered when the candidate is
+ /// close enough that the suggestion is likely right rather than merely the least-wrong entry.
+ ///
+ public static string? Suggest(string name)
+ {
+ string? best = null;
+ int bestDistance = int.MaxValue;
+
+ foreach (string candidate in Registry.Keys)
+ {
+ int distance = EditDistance(name, candidate);
+ if (distance < bestDistance)
+ {
+ bestDistance = distance;
+ best = candidate;
+ }
+ }
+
+ int threshold = Math.Max(2, name.Length / 3);
+ return bestDistance <= threshold ? best : null;
+ }
+
+ ///
+ /// Case-insensitive Levenshtein distance. Public because the semantic validator suggests
+ /// nearest node ids the same way this suggests nearest function names.
+ ///
+ public static int EditDistance(string a, string b)
+ {
+ if (a.Length == 0) return b.Length;
+ if (b.Length == 0) return a.Length;
+
+ int[] previous = new int[b.Length + 1];
+ int[] current = new int[b.Length + 1];
+
+ for (int j = 0; j <= b.Length; j++)
+ {
+ previous[j] = j;
+ }
+
+ for (int i = 1; i <= a.Length; i++)
+ {
+ current[0] = i;
+ for (int j = 1; j <= b.Length; j++)
+ {
+ int cost = char.ToLowerInvariant(a[i - 1]) == char.ToLowerInvariant(b[j - 1]) ? 0 : 1;
+ current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);
+ }
+
+ (previous, current) = (current, previous);
+ }
+
+ return previous[b.Length];
+ }
+}
diff --git a/src/Abacus.Run.Dsl/Expressions/AbExLexer.cs b/src/Abacus.Run.Dsl/Expressions/AbExLexer.cs
new file mode 100644
index 0000000..0be2ea4
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Expressions/AbExLexer.cs
@@ -0,0 +1,218 @@
+using System.Globalization;
+using System.Text;
+
+namespace Abacus.Run.Dsl.Expressions;
+
+internal enum AbExTokenKind
+{
+ Root, // $ | $ctx | $run
+ Identifier,
+ Number,
+ String,
+ True,
+ False,
+ NullLiteral,
+ Dot,
+ LBracket,
+ RBracket,
+ LParen,
+ RParen,
+ Comma,
+ Operator,
+ End,
+ Invalid
+}
+
+internal readonly record struct AbExToken(AbExTokenKind Kind, string Text, int Offset)
+{
+ public override string ToString() => Kind == AbExTokenKind.End ? "end of expression" : $"'{Text}'";
+}
+
+///
+/// Hand-written lexer. Every token carries its source offset, because the diagnostics the DSL lives
+/// or dies by are only as precise as the positions they are built from.
+///
+internal sealed class AbExLexer
+{
+ private readonly string _source;
+ private int _index;
+
+ internal AbExLexer(string source) => _source = source;
+
+ /// Set when a token comes back .
+ internal string? Error { get; private set; }
+
+ internal AbExToken Next()
+ {
+ SkipWhitespace();
+
+ if (_index >= _source.Length)
+ {
+ return new AbExToken(AbExTokenKind.End, string.Empty, _index);
+ }
+
+ int start = _index;
+ char c = _source[_index];
+
+ if (c == '$')
+ {
+ _index++;
+ while (_index < _source.Length && (char.IsLetterOrDigit(_source[_index]) || _source[_index] == '_'))
+ {
+ _index++;
+ }
+
+ return new AbExToken(AbExTokenKind.Root, _source[start.._index], start);
+ }
+
+ if (char.IsLetter(c) || c == '_')
+ {
+ while (_index < _source.Length && (char.IsLetterOrDigit(_source[_index]) || _source[_index] == '_'))
+ {
+ _index++;
+ }
+
+ string word = _source[start.._index];
+ return word switch
+ {
+ "true" => new AbExToken(AbExTokenKind.True, word, start),
+ "false" => new AbExToken(AbExTokenKind.False, word, start),
+ "null" => new AbExToken(AbExTokenKind.NullLiteral, word, start),
+ _ => new AbExToken(AbExTokenKind.Identifier, word, start)
+ };
+ }
+
+ if (char.IsDigit(c))
+ {
+ return ReadNumber(start);
+ }
+
+ if (c is '\'' or '"')
+ {
+ return ReadString(start, c);
+ }
+
+ return ReadPunctuation(start, c);
+ }
+
+ private void SkipWhitespace()
+ {
+ while (_index < _source.Length && char.IsWhiteSpace(_source[_index]))
+ {
+ _index++;
+ }
+ }
+
+ private AbExToken ReadNumber(int start)
+ {
+ while (_index < _source.Length && char.IsDigit(_source[_index]))
+ {
+ _index++;
+ }
+
+ if (_index < _source.Length && _source[_index] == '.' &&
+ _index + 1 < _source.Length && char.IsDigit(_source[_index + 1]))
+ {
+ _index++;
+ while (_index < _source.Length && char.IsDigit(_source[_index]))
+ {
+ _index++;
+ }
+ }
+
+ string text = _source[start.._index];
+
+ // Rejected here rather than at evaluation: a literal too large for decimal is a mistake in
+ // the document, and the author should hear about it while editing it.
+ if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out _))
+ {
+ Error = $"Number '{text}' is out of range.";
+ return new AbExToken(AbExTokenKind.Invalid, text, start);
+ }
+
+ return new AbExToken(AbExTokenKind.Number, text, start);
+ }
+
+ private AbExToken ReadString(int start, char quote)
+ {
+ _index++; // opening quote
+ var builder = new StringBuilder();
+
+ while (_index < _source.Length)
+ {
+ char c = _source[_index];
+
+ if (c == '\\')
+ {
+ if (_index + 1 >= _source.Length)
+ {
+ break;
+ }
+
+ char escaped = _source[_index + 1];
+ builder.Append(escaped switch
+ {
+ 'n' => '\n',
+ 't' => '\t',
+ 'r' => '\r',
+ '\\' => '\\',
+ '\'' => '\'',
+ '"' => '"',
+ _ => escaped
+ });
+ _index += 2;
+ continue;
+ }
+
+ if (c == quote)
+ {
+ _index++;
+ return new AbExToken(AbExTokenKind.String, builder.ToString(), start);
+ }
+
+ builder.Append(c);
+ _index++;
+ }
+
+ Error = "Unterminated string literal.";
+ return new AbExToken(AbExTokenKind.Invalid, _source[start..], start);
+ }
+
+ private AbExToken ReadPunctuation(int start, char c)
+ {
+ switch (c)
+ {
+ case '.': _index++; return new AbExToken(AbExTokenKind.Dot, ".", start);
+ case '[': _index++; return new AbExToken(AbExTokenKind.LBracket, "[", start);
+ case ']': _index++; return new AbExToken(AbExTokenKind.RBracket, "]", start);
+ case '(': _index++; return new AbExToken(AbExTokenKind.LParen, "(", start);
+ case ')': _index++; return new AbExToken(AbExTokenKind.RParen, ")", start);
+ case ',': _index++; return new AbExToken(AbExTokenKind.Comma, ",", start);
+ }
+
+ // Two-character operators first, so '==' never lexes as two '=' tokens.
+ if (_index + 1 < _source.Length)
+ {
+ string pair = _source.Substring(_index, 2);
+ if (pair is "==" or "!=" or "<=" or ">=" or "&&" or "||")
+ {
+ _index += 2;
+ return new AbExToken(AbExTokenKind.Operator, pair, start);
+ }
+ }
+
+ if (c is '<' or '>' or '!' or '+' or '-' or '*' or '/' or '%')
+ {
+ _index++;
+ return new AbExToken(AbExTokenKind.Operator, c.ToString(), start);
+ }
+
+ // A bare '=' is the classic slip for '=='; naming it is worth more than "unexpected character".
+ Error = c == '='
+ ? "'=' is not an operator. Use '==' to compare."
+ : $"Unexpected character '{c}'.";
+
+ _index++;
+ return new AbExToken(AbExTokenKind.Invalid, c.ToString(), start);
+ }
+}
diff --git a/src/Abacus.Run.Dsl/Expressions/AbExNode.cs b/src/Abacus.Run.Dsl/Expressions/AbExNode.cs
new file mode 100644
index 0000000..c0a5976
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Expressions/AbExNode.cs
@@ -0,0 +1,72 @@
+namespace Abacus.Run.Dsl.Expressions;
+
+/// Which object a path is rooted in.
+public enum AbExRoot
+{
+ /// $ — the current message's data.
+ Data,
+
+ /// $ctx — the frozen start context, reachable from every node.
+ Context,
+
+ /// $run — instance, tenant, attempt, superstep, workflow, version, now.
+ Run
+}
+
+/// One step of a path: a property name or an array index.
+public readonly record struct AbExSegment(string? Name, int Index)
+{
+ public bool IsIndex => Name is null;
+
+ public static AbExSegment Property(string name) => new(name, -1);
+ public static AbExSegment At(int index) => new(null, index);
+
+ public override string ToString() => IsIndex ? $"[{Index}]" : $".{Name}";
+}
+
+///
+/// An immutable AbEx syntax tree. Parsed once at registration and evaluated many times, so nothing
+/// here holds evaluation state.
+///
+public abstract record AbExNode
+{
+ /// Nesting depth, used to enforce the configured limit at validation time.
+ public abstract int Depth { get; }
+
+ /// Source offset of the construct's first token, for diagnostics.
+ public int Offset { get; init; }
+}
+
+public sealed record AbExLiteral(AbExValue Value) : AbExNode
+{
+ public override int Depth => 1;
+}
+
+public sealed record AbExPath(AbExRoot Root, IReadOnlyList Segments) : AbExNode
+{
+ public override int Depth => 1;
+
+ /// True for $run.now, the one value that differs between two evaluations.
+ public bool IsNonDeterministic =>
+ Root == AbExRoot.Run && Segments.Count > 0 &&
+ string.Equals(Segments[0].Name, "now", StringComparison.Ordinal);
+
+ public override string ToString() =>
+ (Root switch { AbExRoot.Data => "$", AbExRoot.Context => "$ctx", _ => "$run" }) +
+ string.Concat(Segments);
+}
+
+public sealed record AbExUnary(string Operator, AbExNode Operand) : AbExNode
+{
+ public override int Depth => Operand.Depth + 1;
+}
+
+public sealed record AbExBinary(string Operator, AbExNode Left, AbExNode Right) : AbExNode
+{
+ public override int Depth => Math.Max(Left.Depth, Right.Depth) + 1;
+}
+
+public sealed record AbExCall(string Name, IReadOnlyList Arguments) : AbExNode
+{
+ public override int Depth => Arguments.Count == 0 ? 1 : Arguments.Max(a => a.Depth) + 1;
+}
diff --git a/src/Abacus.Run.Dsl/Expressions/AbExParser.cs b/src/Abacus.Run.Dsl/Expressions/AbExParser.cs
new file mode 100644
index 0000000..f381c83
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Expressions/AbExParser.cs
@@ -0,0 +1,406 @@
+using System.Globalization;
+
+namespace Abacus.Run.Dsl.Expressions;
+
+/// Where a parse failed, and why.
+public sealed record AbExError(string Message, int Offset)
+{
+ public override string ToString() => $"{Message} (at offset {Offset})";
+}
+
+/// An AST or an error. Never both, and never an exception.
+public sealed record AbExResult(AbExNode? Node, AbExError? Error)
+{
+ public bool IsSuccess => Node is not null;
+
+ public static AbExResult Ok(AbExNode node) => new(node, null);
+ public static AbExResult Fail(string message, int offset) => new(null, new AbExError(message, offset));
+}
+
+///
+/// Recursive-descent parser for AbEx.
+///
+///
+///
+/// Hand-written rather than generated: the grammar is a page long, and a hand-written parser is what
+/// gives every construct the exact source offset the diagnostics depend on.
+///
+///
+/// Parsing never throws. A malformed expression is data — it arrives in a document from outside the
+/// build, and the only useful response is a diagnostic pointing at it.
+///
+///
+public static class AbExParser
+{
+ /// Guards against a pathological document; the semantic validator enforces its own limit too.
+ public const int MaxDepth = 32;
+
+ public static AbExResult Parse(string? expression)
+ {
+ if (string.IsNullOrWhiteSpace(expression))
+ {
+ return AbExResult.Fail("An expression is required.", 0);
+ }
+
+ var parser = new Parser(expression);
+ return parser.ParseAll();
+ }
+
+ /// Parses or throws. For framework-internal call sites that have already validated.
+ public static AbExNode ParseOrThrow(string expression)
+ {
+ AbExResult result = Parse(expression);
+ return result.Node ?? throw new FormatException(
+ $"Invalid AbEx expression '{expression}'. {result.Error!.Message}");
+ }
+
+ private sealed class Parser
+ {
+ private readonly string _source;
+ private readonly AbExLexer _lexer;
+ private AbExToken _current;
+ private AbExError? _error;
+
+ internal Parser(string source)
+ {
+ _source = source;
+ _lexer = new AbExLexer(source);
+ _current = _lexer.Next();
+ CaptureLexError();
+ }
+
+ internal AbExResult ParseAll()
+ {
+ if (_error is not null)
+ {
+ return new AbExResult(null, _error);
+ }
+
+ AbExNode? node = ParseOr();
+
+ if (_error is not null)
+ {
+ return new AbExResult(null, _error);
+ }
+
+ if (_current.Kind != AbExTokenKind.End)
+ {
+ return AbExResult.Fail(
+ $"Unexpected {_current} after a complete expression.", _current.Offset);
+ }
+
+ if (node!.Depth > MaxDepth)
+ {
+ return AbExResult.Fail(
+ $"Expression nests {node.Depth} deep; the limit is {MaxDepth}.", 0);
+ }
+
+ return AbExResult.Ok(node);
+ }
+
+ private void Advance()
+ {
+ _current = _lexer.Next();
+ CaptureLexError();
+ }
+
+ private void CaptureLexError()
+ {
+ if (_current.Kind == AbExTokenKind.Invalid && _error is null)
+ {
+ _error = new AbExError(_lexer.Error ?? "Invalid token.", _current.Offset);
+ }
+ }
+
+ private void Fail(string message, int offset) => _error ??= new AbExError(message, offset);
+
+ private bool IsOperator(params string[] operators) =>
+ _current.Kind == AbExTokenKind.Operator && Array.IndexOf(operators, _current.Text) >= 0;
+
+ private AbExNode? ParseOr()
+ {
+ AbExNode? left = ParseAnd();
+ while (_error is null && IsOperator("||"))
+ {
+ int offset = _current.Offset;
+ Advance();
+ AbExNode? right = ParseAnd();
+ if (_error is not null) return null;
+ left = new AbExBinary("||", left!, right!) { Offset = offset };
+ }
+
+ return left;
+ }
+
+ private AbExNode? ParseAnd()
+ {
+ AbExNode? left = ParseComparison();
+ while (_error is null && IsOperator("&&"))
+ {
+ int offset = _current.Offset;
+ Advance();
+ AbExNode? right = ParseComparison();
+ if (_error is not null) return null;
+ left = new AbExBinary("&&", left!, right!) { Offset = offset };
+ }
+
+ return left;
+ }
+
+ ///
+ /// Non-associative: a < b < c is a mistake in every language that quietly allows
+ /// it, so it is refused here rather than silently comparing a boolean to a number.
+ ///
+ private AbExNode? ParseComparison()
+ {
+ AbExNode? left = ParseAdditive();
+ if (_error is not null || !IsOperator("==", "!=", "<", "<=", ">", ">="))
+ {
+ return left;
+ }
+
+ string op = _current.Text;
+ int offset = _current.Offset;
+ Advance();
+
+ AbExNode? right = ParseAdditive();
+ if (_error is not null) return null;
+
+ if (IsOperator("==", "!=", "<", "<=", ">", ">="))
+ {
+ Fail($"Chained comparison '{_current.Text}'. Combine two comparisons with '&&' instead.",
+ _current.Offset);
+ return null;
+ }
+
+ return new AbExBinary(op, left!, right!) { Offset = offset };
+ }
+
+ private AbExNode? ParseAdditive()
+ {
+ AbExNode? left = ParseMultiplicative();
+ while (_error is null && IsOperator("+", "-"))
+ {
+ string op = _current.Text;
+ int offset = _current.Offset;
+ Advance();
+ AbExNode? right = ParseMultiplicative();
+ if (_error is not null) return null;
+ left = new AbExBinary(op, left!, right!) { Offset = offset };
+ }
+
+ return left;
+ }
+
+ private AbExNode? ParseMultiplicative()
+ {
+ AbExNode? left = ParseUnary();
+ while (_error is null && IsOperator("*", "/", "%"))
+ {
+ string op = _current.Text;
+ int offset = _current.Offset;
+ Advance();
+ AbExNode? right = ParseUnary();
+ if (_error is not null) return null;
+ left = new AbExBinary(op, left!, right!) { Offset = offset };
+ }
+
+ return left;
+ }
+
+ private AbExNode? ParseUnary()
+ {
+ if (IsOperator("!", "-"))
+ {
+ string op = _current.Text;
+ int offset = _current.Offset;
+ Advance();
+ AbExNode? operand = ParseUnary();
+ if (_error is not null) return null;
+ return new AbExUnary(op, operand!) { Offset = offset };
+ }
+
+ return ParsePrimary();
+ }
+
+ private AbExNode? ParsePrimary()
+ {
+ int offset = _current.Offset;
+
+ switch (_current.Kind)
+ {
+ case AbExTokenKind.Number:
+ {
+ decimal value = decimal.Parse(_current.Text, NumberStyles.Number, CultureInfo.InvariantCulture);
+ Advance();
+ return new AbExLiteral(AbExValue.Number(value)) { Offset = offset };
+ }
+
+ case AbExTokenKind.String:
+ {
+ string text = _current.Text;
+ Advance();
+ return new AbExLiteral(AbExValue.String(text)) { Offset = offset };
+ }
+
+ case AbExTokenKind.True:
+ Advance();
+ return new AbExLiteral(AbExValue.True) { Offset = offset };
+
+ case AbExTokenKind.False:
+ Advance();
+ return new AbExLiteral(AbExValue.False) { Offset = offset };
+
+ case AbExTokenKind.NullLiteral:
+ Advance();
+ return new AbExLiteral(AbExValue.Null) { Offset = offset };
+
+ case AbExTokenKind.LParen:
+ {
+ Advance();
+ AbExNode? inner = ParseOr();
+ if (_error is not null) return null;
+
+ if (_current.Kind != AbExTokenKind.RParen)
+ {
+ Fail($"Expected ')' but found {_current}.", _current.Offset);
+ return null;
+ }
+
+ Advance();
+ return inner;
+ }
+
+ case AbExTokenKind.Identifier:
+ return ParseCall();
+
+ case AbExTokenKind.Root:
+ return ParsePath();
+
+ case AbExTokenKind.End:
+ Fail("Expression ends where a value was expected.", offset);
+ return null;
+
+ default:
+ Fail($"Expected a value but found {_current}.", offset);
+ return null;
+ }
+ }
+
+ private AbExNode? ParseCall()
+ {
+ string name = _current.Text;
+ int offset = _current.Offset;
+ Advance();
+
+ if (_current.Kind != AbExTokenKind.LParen)
+ {
+ // The only bare identifiers AbEx has are function names. A path must start with a
+ // root, and saying so is far more useful than "unexpected token".
+ Fail($"'{name}' is not a value. Paths start with '$', '$ctx' or '$run'; " +
+ $"functions are called as {name}(...).", offset);
+ return null;
+ }
+
+ Advance();
+ var arguments = new List();
+
+ if (_current.Kind != AbExTokenKind.RParen)
+ {
+ while (true)
+ {
+ AbExNode? argument = ParseOr();
+ if (_error is not null) return null;
+ arguments.Add(argument!);
+
+ if (_current.Kind == AbExTokenKind.Comma)
+ {
+ Advance();
+ continue;
+ }
+
+ break;
+ }
+ }
+
+ if (_current.Kind != AbExTokenKind.RParen)
+ {
+ Fail($"Expected ')' to close {name}(...) but found {_current}.", _current.Offset);
+ return null;
+ }
+
+ Advance();
+ return new AbExCall(name, arguments) { Offset = offset };
+ }
+
+ private AbExNode? ParsePath()
+ {
+ int offset = _current.Offset;
+ string rootText = _current.Text;
+
+ AbExRoot root;
+ switch (rootText)
+ {
+ case "$": root = AbExRoot.Data; break;
+ case "$ctx": root = AbExRoot.Context; break;
+ case "$run": root = AbExRoot.Run; break;
+ default:
+ Fail($"Unknown root '{rootText}'. Use '$', '$ctx' or '$run'.", offset);
+ return null;
+ }
+
+ Advance();
+ var segments = new List();
+
+ while (true)
+ {
+ if (_current.Kind == AbExTokenKind.Dot)
+ {
+ Advance();
+ if (_current.Kind is not (AbExTokenKind.Identifier or AbExTokenKind.True
+ or AbExTokenKind.False or AbExTokenKind.NullLiteral))
+ {
+ Fail($"Expected a property name after '.' but found {_current}.", _current.Offset);
+ return null;
+ }
+
+ segments.Add(AbExSegment.Property(_current.Text));
+ Advance();
+ continue;
+ }
+
+ if (_current.Kind == AbExTokenKind.LBracket)
+ {
+ Advance();
+ if (_current.Kind != AbExTokenKind.Number)
+ {
+ Fail($"Array index must be a literal integer but found {_current}.", _current.Offset);
+ return null;
+ }
+
+ if (!int.TryParse(_current.Text, NumberStyles.None, CultureInfo.InvariantCulture, out int index))
+ {
+ Fail($"'{_current.Text}' is not a valid array index.", _current.Offset);
+ return null;
+ }
+
+ segments.Add(AbExSegment.At(index));
+ Advance();
+
+ if (_current.Kind != AbExTokenKind.RBracket)
+ {
+ Fail($"Expected ']' but found {_current}.", _current.Offset);
+ return null;
+ }
+
+ Advance();
+ continue;
+ }
+
+ break;
+ }
+
+ return new AbExPath(root, segments) { Offset = offset };
+ }
+ }
+}
diff --git a/src/Abacus.Run.Dsl/Expressions/AbExValidator.cs b/src/Abacus.Run.Dsl/Expressions/AbExValidator.cs
new file mode 100644
index 0000000..4430182
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Expressions/AbExValidator.cs
@@ -0,0 +1,126 @@
+namespace Abacus.Run.Dsl.Expressions;
+
+/// One problem found by static analysis of a parsed expression.
+public sealed record AbExIssue(string Message, int Offset, string? Suggestion = null);
+
+/// What static analysis concluded about an expression.
+public sealed record ExpressionFacts(
+ int Depth,
+ bool IsDeterministic,
+ IReadOnlyList Issues)
+{
+ public bool IsValid => Issues.Count == 0;
+}
+
+///
+/// Static analysis over a parsed tree: everything decidable without a document to evaluate against.
+///
+///
+/// Separate from the parser because these are different failures with different audiences. A parse
+/// error means the text is not an expression; an issue here means it is a well-formed expression
+/// that will never do what it says.
+///
+public static class AbExValidator
+{
+ public static ExpressionFacts Analyse(AbExNode node, int maxDepth = AbExParser.MaxDepth)
+ {
+ ArgumentNullException.ThrowIfNull(node);
+
+ var issues = new List();
+ bool deterministic = true;
+
+ Walk(node, issues, ref deterministic);
+
+ if (node.Depth > maxDepth)
+ {
+ issues.Add(new AbExIssue(
+ $"Expression nests {node.Depth} deep; the limit is {maxDepth}.", node.Offset));
+ }
+
+ return new ExpressionFacts(node.Depth, deterministic, issues);
+ }
+
+ /// Parses and analyses in one step, folding a parse error into the issue list.
+ public static ExpressionFacts Check(string expression, int maxDepth = AbExParser.MaxDepth)
+ {
+ AbExResult parsed = AbExParser.Parse(expression);
+
+ return parsed.IsSuccess
+ ? Analyse(parsed.Node!, maxDepth)
+ : new ExpressionFacts(0, true, [new AbExIssue(parsed.Error!.Message, parsed.Error.Offset)]);
+ }
+
+ private static void Walk(AbExNode node, List issues, ref bool deterministic)
+ {
+ switch (node)
+ {
+ case AbExPath path:
+ if (path.IsNonDeterministic)
+ {
+ deterministic = false;
+ }
+
+ break;
+
+ case AbExUnary unary:
+ Walk(unary.Operand, issues, ref deterministic);
+ break;
+
+ case AbExBinary binary:
+ Walk(binary.Left, issues, ref deterministic);
+ Walk(binary.Right, issues, ref deterministic);
+ break;
+
+ case AbExCall call:
+ CheckCall(call, issues);
+ foreach (AbExNode argument in call.Arguments)
+ {
+ Walk(argument, issues, ref deterministic);
+ }
+
+ break;
+ }
+ }
+
+ private static void CheckCall(AbExCall call, List issues)
+ {
+ if (!AbExFunctions.TryGet(call.Name, out AbExFunction function))
+ {
+ string? suggestion = AbExFunctions.Suggest(call.Name);
+ issues.Add(new AbExIssue(
+ $"Unknown function '{call.Name}'.", call.Offset,
+ suggestion is null ? null : $"Did you mean '{suggestion}'?"));
+ return;
+ }
+
+ if (!function.AcceptsArity(call.Arguments.Count))
+ {
+ issues.Add(new AbExIssue(
+ $"'{call.Name}' takes {function.DescribeArity()} argument(s) but was given {call.Arguments.Count}.",
+ call.Offset));
+ }
+
+ // A pattern assembled at run time cannot be checked at authoring time, and an unbounded
+ // pattern is the one genuinely dangerous thing in the grammar. Requiring a literal keeps
+ // every regex in a document reviewable by reading the document.
+ if (call.Name == "matches" && call.Arguments.Count == 2)
+ {
+ if (call.Arguments[1] is not AbExLiteral { Value.Kind: AbExValueKind.String } literal)
+ {
+ issues.Add(new AbExIssue(
+ "The pattern argument to 'matches' must be a string literal.", call.Arguments[1].Offset));
+ return;
+ }
+
+ try
+ {
+ _ = new System.Text.RegularExpressions.Regex(literal.Value.AsString);
+ }
+ catch (ArgumentException ex)
+ {
+ issues.Add(new AbExIssue(
+ $"Invalid regular expression: {ex.Message}", call.Arguments[1].Offset));
+ }
+ }
+ }
+}
diff --git a/src/Abacus.Run.Dsl/Expressions/AbExValue.cs b/src/Abacus.Run.Dsl/Expressions/AbExValue.cs
new file mode 100644
index 0000000..4fb7188
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Expressions/AbExValue.cs
@@ -0,0 +1,202 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Abacus.Run.Dsl.Expressions;
+
+public enum AbExValueKind
+{
+ ///
+ /// The path resolved to nothing. Distinct from , which is a JSON value the
+ /// document actually contains.
+ ///
+ Absent,
+ Null,
+ Boolean,
+ Number,
+ String,
+ Array,
+ Object
+}
+
+///
+/// One value in an AbEx evaluation. Absence is a value rather than an exception, which is what makes
+/// the evaluator total: no expression over any document can throw.
+///
+public readonly struct AbExValue : IEquatable
+{
+ private readonly decimal _number;
+ private readonly bool _boolean;
+ private readonly string? _string;
+ private readonly JsonNode? _node;
+
+ private AbExValue(AbExValueKind kind, decimal number = 0, bool boolean = false,
+ string? text = null, JsonNode? node = null)
+ {
+ Kind = kind;
+ _number = number;
+ _boolean = boolean;
+ _string = text;
+ _node = node;
+ }
+
+ public AbExValueKind Kind { get; }
+
+ public static AbExValue Absent { get; } = new(AbExValueKind.Absent);
+ public static AbExValue Null { get; } = new(AbExValueKind.Null);
+ public static AbExValue True { get; } = new(AbExValueKind.Boolean, boolean: true);
+ public static AbExValue False { get; } = new(AbExValueKind.Boolean, boolean: false);
+
+ public static AbExValue Bool(bool value) => value ? True : False;
+ public static AbExValue Number(decimal value) => new(AbExValueKind.Number, number: value);
+ public static AbExValue String(string value) => new(AbExValueKind.String, text: value);
+
+ public bool IsAbsent => Kind == AbExValueKind.Absent;
+ public bool IsTruthy => Kind == AbExValueKind.Boolean && _boolean;
+
+ public bool AsBoolean => _boolean;
+ public decimal AsNumber => _number;
+ public string AsString => _string ?? string.Empty;
+ public JsonNode? AsNode => _node;
+
+ ///
+ /// Classifies a into a value. A null reference is
+ /// only when the caller means "not there"; a JSON null arrives as and
+ /// maps to .
+ ///
+ public static AbExValue FromNode(JsonNode? node)
+ {
+ switch (node)
+ {
+ case null:
+ return Null;
+
+ case JsonArray array:
+ return new AbExValue(AbExValueKind.Array, node: array);
+
+ case JsonObject obj:
+ return new AbExValue(AbExValueKind.Object, node: obj);
+
+ case JsonValue value:
+ // Classified by JSON kind first, not by trying CLR types in turn. A JsonValue holds
+ // whatever the writer put in it — JsonValue.Create(200) is backed by int, and asking
+ // it for a decimal simply fails — so type-probing quietly turned numbers into
+ // strings and made '$.status == 200' false against a genuine 200.
+ switch (value.GetValueKind())
+ {
+ case JsonValueKind.True:
+ return True;
+
+ case JsonValueKind.False:
+ return False;
+
+ case JsonValueKind.Number:
+ return Number(ReadNumber(value));
+
+ case JsonValueKind.String:
+ return value.TryGetValue(out string? text) && text is not null
+ ? String(text)
+ : String(value.ToJsonString().Trim('"'));
+
+ case JsonValueKind.Null:
+ return Null;
+
+ default:
+ return String(value.ToJsonString().Trim('"'));
+ }
+
+ default:
+ return String(node.ToJsonString());
+ }
+ }
+
+ ///
+ /// Reads a number whatever CLR type backs it. Decimal throughout, so money keeps its scale; a
+ /// double that will not fit is taken as a double and converted, which loses precision but never
+ /// loses the value.
+ ///
+ private static decimal ReadNumber(JsonValue value)
+ {
+ if (value.TryGetValue(out decimal d)) return d;
+ if (value.TryGetValue(out long l)) return l;
+ if (value.TryGetValue(out int i)) return i;
+ if (value.TryGetValue(out double dbl)) return (decimal)dbl;
+ if (value.TryGetValue(out float f)) return (decimal)f;
+
+ return decimal.TryParse(
+ value.ToJsonString(), NumberStyles.Number, CultureInfo.InvariantCulture, out decimal parsed)
+ ? parsed
+ : 0m;
+ }
+
+ /// Length as len() defines it: characters, elements, or properties.
+ public int Length => Kind switch
+ {
+ AbExValueKind.String => AsString.Length,
+ AbExValueKind.Array => ((JsonArray)_node!).Count,
+ AbExValueKind.Object => ((JsonObject)_node!).Count,
+ _ => 0
+ };
+
+ /// Renders for template interpolation. Absent and null both render as empty.
+ public string ToText() => Kind switch
+ {
+ AbExValueKind.Absent or AbExValueKind.Null => string.Empty,
+ AbExValueKind.Boolean => _boolean ? "true" : "false",
+ AbExValueKind.Number => FormatNumber(_number),
+ AbExValueKind.String => AsString,
+ _ => _node?.ToJsonString() ?? string.Empty
+ };
+
+ /// Converts back to a JSON node for writing into an envelope.
+ public JsonNode? ToNode() => Kind switch
+ {
+ AbExValueKind.Absent or AbExValueKind.Null => null,
+ AbExValueKind.Boolean => JsonValue.Create(_boolean),
+ AbExValueKind.Number => JsonValue.Create(_number),
+ AbExValueKind.String => JsonValue.Create(AsString),
+ _ => _node?.DeepClone()
+ };
+
+ ///
+ /// Trailing zeros are dropped so 1.50 and 1.5 render alike — decimal preserves
+ /// scale, and a template that produced "1.50" where the author wrote arithmetic would look wrong.
+ ///
+ internal static string FormatNumber(decimal value)
+ {
+ decimal normalized = value == decimal.Truncate(value) && Math.Abs(value) < 1e15m
+ ? decimal.Truncate(value)
+ : value / 1.000000000000000000000000000000000m;
+
+ return normalized.ToString(CultureInfo.InvariantCulture);
+ }
+
+ public bool Equals(AbExValue other)
+ {
+ if (Kind != other.Kind)
+ {
+ return false;
+ }
+
+ return Kind switch
+ {
+ AbExValueKind.Absent or AbExValueKind.Null => true,
+ AbExValueKind.Boolean => _boolean == other._boolean,
+ AbExValueKind.Number => _number == other._number,
+ AbExValueKind.String => string.Equals(_string, other._string, StringComparison.Ordinal),
+ _ => string.Equals(_node?.ToJsonString(), other._node?.ToJsonString(), StringComparison.Ordinal)
+ };
+ }
+
+ public override bool Equals(object? obj) => obj is AbExValue other && Equals(other);
+
+ public override int GetHashCode() => Kind switch
+ {
+ AbExValueKind.Boolean => _boolean.GetHashCode(),
+ AbExValueKind.Number => _number.GetHashCode(),
+ AbExValueKind.String => StringComparer.Ordinal.GetHashCode(_string ?? string.Empty),
+ _ => (int)Kind
+ };
+
+ public override string ToString() => Kind == AbExValueKind.Absent ? "" : ToText();
+}
diff --git a/src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs b/src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs
new file mode 100644
index 0000000..b3e775d
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs
@@ -0,0 +1,102 @@
+using System.Text.Json.Nodes;
+using Abacus.Run.Dsl.Interpretation;
+using Abacus.Run.Dsl.Validation;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace Abacus.Run.Dsl.Hosting;
+
+/// The DSL's own control-plane routes: validate, schema, node catalog.
+public static class DslEndpoints
+{
+ ///
+ /// Maps the DSL routes. Mounted beside MapWorkflowApi, under the same prefix and the same
+ /// authorization — the validate route reflects the host's registered node names back to the
+ /// caller, which is information about the host and not something to serve anonymously.
+ ///
+ public static IEndpointRouteBuilder MapDslApi(this IEndpointRouteBuilder app)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+
+ app.MapGet("/dsl/schema", () => Results.Text(
+ DslSchemaValidator.SchemaText, "application/schema+json"));
+
+ app.MapGet("/dsl/nodes", (DslRegistry registry) => Results.Ok(new
+ {
+ builtIn = Model.DslNodeKinds.All,
+ custom = registry.Catalog.Describe().Select(entry => new
+ {
+ name = entry.Key,
+ parameterSchema = entry.Value
+ })
+ }));
+
+ app.MapGet("/dsl/functions", () => Results.Ok(
+ Expressions.AbExFunctions.Names
+ .Select(name =>
+ {
+ Expressions.AbExFunctions.TryGet(name, out Expressions.AbExFunction function);
+ return new { name, arity = function.DescribeArity(), summary = function.Summary };
+ })));
+
+ app.MapGet("/dsl/documents", (DslRegistry registry) => Results.Ok(
+ registry.Definitions.Select(d => new
+ {
+ name = d.Name,
+ version = d.Version,
+ documentHash = d.DocumentHash,
+ nodes = d.Document.Nodes.Count,
+ edges = d.Document.Edges.Count
+ })));
+
+ // What an authoring tool calls. Validates without registering, so a document can be checked
+ // against the live host's catalog before anyone commits it.
+ app.MapPost("/dsl/validate", async (HttpRequest request, DslRegistry registry, CancellationToken ct) =>
+ {
+ using var reader = new StreamReader(request.Body);
+ string text = await reader.ReadToEndAsync(ct).ConfigureAwait(false);
+
+ DslParseResult result = DslParser.Parse(text, new DslEnvironment
+ {
+ CustomNodes = registry.Catalog.Describe(),
+ EnforceEgress = registry.EnforceEgress,
+ Policy = registry.Policy
+ });
+
+ return Results.Ok(new
+ {
+ valid = result.IsValid,
+ name = result.Document?.Name,
+ version = result.Document?.Version,
+ documentHash = result.Document?.Hash,
+ skippedChecks = result.Validation.SkippedChecks,
+ diagnostics = result.Validation.Diagnostics.Select(Describe)
+ });
+ });
+
+ return app;
+ }
+
+ private static object Describe(DslDiagnostic diagnostic) => new
+ {
+ code = diagnostic.Code,
+ severity = diagnostic.Severity == DslSeverity.Error ? "error" : "warning",
+
+ // Empty means the document as a whole; "/" is what a pointer to the root actually looks
+ // like, and an editor matching on it should not have to special-case the empty string.
+ pointer = string.IsNullOrEmpty(diagnostic.Pointer) ? "/" : diagnostic.Pointer,
+ message = diagnostic.Message,
+ suggestion = diagnostic.Suggestion
+ };
+
+ /// Describes a document for the catalog route, when the definition is a DSL one.
+ public static JsonObject? DescribeSource(object? definition)
+ => definition is DslWorkflowDefinition dsl
+ ? new JsonObject
+ {
+ ["source"] = "dsl",
+ ["documentHash"] = dsl.DocumentHash
+ }
+ : null;
+}
diff --git a/src/Abacus.Run.Dsl/Hosting/DslHostBuilderExtensions.cs b/src/Abacus.Run.Dsl/Hosting/DslHostBuilderExtensions.cs
new file mode 100644
index 0000000..795264e
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Hosting/DslHostBuilderExtensions.cs
@@ -0,0 +1,166 @@
+using System.Text.Json.Nodes;
+using Abacus.Run.Abstractions;
+using Abacus.Run.Api;
+using Abacus.Run.Dsl.Interpretation;
+using Abacus.Run.Dsl.Validation;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Abacus.Run.Dsl.Hosting;
+
+/// Registers DSL documents and custom nodes alongside compiled workflows.
+public static class DslHostBuilderExtensions
+{
+ ///
+ /// Adds a document from disk. The path is read at startup, not at build time, so a document can
+ /// be edited and the host restarted without a rebuild.
+ ///
+ public static WorkflowHostBuilder AddDslWorkflow(this WorkflowHostBuilder builder, string path)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ string full = Path.GetFullPath(path);
+ return builder.AddDslSource(new DslSource(full, () => File.ReadAllText(full)));
+ }
+
+ /// Adds a document already in hand — an embedded resource, or a test fixture.
+ public static WorkflowHostBuilder AddDslWorkflowText(
+ this WorkflowHostBuilder builder, string text, string? description = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(text);
+
+ return builder.AddDslSource(new DslSource(description ?? "(inline document)", () => text));
+ }
+
+ ///
+ /// Adds every matching document in a directory, in a stable order.
+ ///
+ ///
+ /// Ordered rather than left to the file system: a hash conflict between two documents claiming
+ /// one (name, version) should name the same one every time, or the failure would look
+ /// intermittent.
+ ///
+ public static WorkflowHostBuilder AddDslWorkflowsFromDirectory(
+ this WorkflowHostBuilder builder,
+ string path,
+ string searchPattern = "*.workflow.json",
+ bool recursive = false)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ string full = Path.GetFullPath(path);
+
+ if (!Directory.Exists(full))
+ {
+ // A missing directory is a composition mistake and would otherwise register nothing at
+ // all, which looks exactly like a host with no workflows.
+ throw new DirectoryNotFoundException(
+ $"No DSL workflow directory at '{full}'. Check the path, or remove the registration.");
+ }
+
+ IEnumerable files = Directory
+ .EnumerateFiles(full, searchPattern,
+ recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly)
+ .OrderBy(f => f, StringComparer.Ordinal);
+
+ foreach (string file in files)
+ {
+ builder.AddDslWorkflow(file);
+ }
+
+ return builder;
+ }
+
+ /// Registers a custom node, making its name available to every document.
+ public static WorkflowHostBuilder AddDslNode(this WorkflowHostBuilder builder, IDslNodeFactory factory)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(factory);
+
+ Registry(builder.Services).AddNode(factory);
+ return builder;
+ }
+
+ /// Registers a custom node from a delegate, for a node whose construction is a one-liner.
+ public static WorkflowHostBuilder AddDslNode(
+ this WorkflowHostBuilder builder,
+ string name,
+ Func create,
+ JsonNode? parameterSchema = null)
+ => builder.AddDslNode(new DelegateDslNodeFactory(name, create, parameterSchema));
+
+ ///
+ /// Makes the DSL available without registering any document: the node catalog, the schema route
+ /// and the validate route all work on a host that has not yet been given one.
+ ///
+ ///
+ /// Needed because MapDslApi resolves the registry, and a host that maps the routes before
+ /// anyone has called AddDslWorkflow would otherwise fail to start. Which is exactly the
+ /// host an authoring tool talks to while a first document is being written.
+ ///
+ public static WorkflowHostBuilder UseDsl(this WorkflowHostBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ Registry(builder.Services);
+ return builder;
+ }
+
+ /// Configures the limits and egress policy documents are validated against.
+ public static WorkflowHostBuilder ConfigureDsl(
+ this WorkflowHostBuilder builder, Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ configure(Registry(builder.Services));
+ return builder;
+ }
+
+ private static WorkflowHostBuilder AddDslSource(this WorkflowHostBuilder builder, DslSource source)
+ {
+ DslRegistry registry = Registry(builder.Services);
+ int index = registry.AddSource(source);
+
+ // One IWorkflowDefinition registration per document, all resolving from the same shared
+ // list. Registering the list as a single service would hide the documents from the registry,
+ // which enumerates IWorkflowDefinition to build the catalog.
+ //
+ // The factory runs on first enumeration — after every AddDslNode call has completed, which
+ // is what lets composition be written in any order.
+ builder.Services.AddSingleton(_ =>
+ {
+ IReadOnlyList definitions = registry.Resolve();
+
+ return index < definitions.Count
+ ? definitions[index]
+ : throw new InvalidOperationException(
+ $"The DSL document '{source.Description}' did not resolve to a definition.");
+ });
+
+ return builder;
+ }
+
+ ///
+ /// One registry per service collection, held as a singleton instance so both the composition
+ /// calls and the container see the same object.
+ ///
+ private static DslRegistry Registry(IServiceCollection services)
+ {
+ ServiceDescriptor? existing = services.FirstOrDefault(
+ d => d.ServiceType == typeof(DslRegistry) && d.ImplementationInstance is DslRegistry);
+
+ if (existing?.ImplementationInstance is DslRegistry registry)
+ {
+ return registry;
+ }
+
+ var created = new DslRegistry();
+ services.AddSingleton(created);
+ services.TryAddSingleton(created.Catalog);
+ return created;
+ }
+}
diff --git a/src/Abacus.Run.Dsl/Hosting/DslRegistry.cs b/src/Abacus.Run.Dsl/Hosting/DslRegistry.cs
new file mode 100644
index 0000000..dc44e1d
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Hosting/DslRegistry.cs
@@ -0,0 +1,157 @@
+using Abacus.Run.Abstractions;
+using Abacus.Run.Dsl.Interpretation;
+using Abacus.Run.Dsl.Model;
+using Abacus.Run.Dsl.Validation;
+
+namespace Abacus.Run.Dsl.Hosting;
+
+/// Where a document came from, for naming it in a diagnostic.
+public sealed record DslSource(string Description, Func Read);
+
+///
+/// Collects DSL documents and custom node registrations during composition, then resolves them once
+/// the container is built.
+///
+///
+///
+/// Resolution is deferred for one reason: AddDslWorkflow and AddDslNode can be called
+/// in either order, and a document must be validated against the complete node catalog.
+/// Validating a document the moment it is added would make correctness depend on the order the
+/// composition happened to be written in.
+///
+///
+/// A document that fails validation throws here, which surfaces as a startup failure — the same
+/// place a bad compiled workflow fails, and for the same reason.
+///
+///
+public sealed class DslRegistry
+{
+ private readonly List _sources = [];
+ private readonly DslNodeCatalog _catalog = new();
+ private readonly Lock _gate = new();
+
+ private IReadOnlyList? _resolved;
+
+ public DslPolicy Policy { get; set; } = DslPolicy.Default;
+
+ /// Whether http nodes must declare an allow-list. Mirrors the host's egress setting.
+ public bool EnforceEgress { get; set; } = true;
+
+ public TimeProvider Clock { get; set; } = TimeProvider.System;
+
+ public DslNodeCatalog Catalog => _catalog;
+
+ ///
+ /// Adds a document and returns its position, which is also its position in the resolved list —
+ /// validation either succeeds for every source or throws, so the two stay one to one.
+ ///
+ public int AddSource(DslSource source)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+
+ lock (_gate)
+ {
+ if (_resolved is not null)
+ {
+ throw new InvalidOperationException(
+ "DSL documents cannot be added after the registry has been resolved.");
+ }
+
+ _sources.Add(source);
+ return _sources.Count - 1;
+ }
+ }
+
+ public DslRegistry AddNode(IDslNodeFactory factory)
+ {
+ lock (_gate)
+ {
+ if (_resolved is not null)
+ {
+ throw new InvalidOperationException(
+ "DSL nodes cannot be registered after the registry has been resolved.");
+ }
+
+ _catalog.Add(factory);
+ }
+
+ return this;
+ }
+
+ ///
+ /// Parses and validates every document against the complete catalog, once. Later calls return the
+ /// same definitions — the registry is read at startup and never again.
+ ///
+ public IReadOnlyList Resolve()
+ {
+ lock (_gate)
+ {
+ if (_resolved is not null)
+ {
+ return _resolved;
+ }
+
+ var definitions = new List(_sources.Count);
+ var published = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var failures = new List();
+
+ foreach (DslSource source in _sources)
+ {
+ var environment = new DslEnvironment
+ {
+ CustomNodes = _catalog.Describe(),
+ EnforceEgress = EnforceEgress,
+
+ // Accumulated as documents resolve, so a second document claiming a published
+ // (name, version) with different content is caught here rather than by the
+ // registry's duplicate-version check, which cannot say why they differ.
+ PublishedHashes = published,
+ Policy = Policy
+ };
+
+ DslParseResult result;
+ try
+ {
+ result = DslParser.Parse(source.Read(), environment);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ failures.Add($"{source.Description}: {ex.Message}");
+ continue;
+ }
+
+ if (!result.IsValid)
+ {
+ failures.Add($"{source.Description}:{Environment.NewLine}{result.Validation.Describe()}");
+ continue;
+ }
+
+ DslDocument document = result.Document!;
+ published[$"{document.Name}@{document.Version}"] = document.Hash;
+ definitions.Add(DslWorkflowDefinition.Create(document, _catalog, Clock));
+ }
+
+ if (failures.Count > 0)
+ {
+ // Every failure, not the first. A composition with three broken documents should take
+ // one startup to fix, not three.
+ throw new DslValidationException(
+ $"{failures.Count} DSL document(s) failed validation:{Environment.NewLine}{Environment.NewLine}" +
+ string.Join(Environment.NewLine + Environment.NewLine, failures),
+ DslValidationResult.Empty);
+ }
+
+ _resolved = definitions;
+ return _resolved;
+ }
+ }
+
+ /// The registered documents, for the catalog endpoint. Resolves if it has not already.
+ public IReadOnlyList Definitions => Resolve();
+
+ /// Looks up a definition by name and version, for reporting a document's hash.
+ public DslWorkflowDefinition? Find(string name, string? version = null)
+ => Resolve().FirstOrDefault(d =>
+ string.Equals(d.Name, name, StringComparison.OrdinalIgnoreCase) &&
+ (version is null || string.Equals(d.Version, version, StringComparison.OrdinalIgnoreCase)));
+}
diff --git a/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs b/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs
new file mode 100644
index 0000000..1263023
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs
@@ -0,0 +1,451 @@
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Abacus.Run.Abstractions;
+using Abacus.Run.Core;
+using Abacus.Run.Dsl.Expressions;
+using Abacus.Run.Dsl.Model;
+using Abacus.Run.Executors;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Abacus.Run.Dsl.Interpretation;
+
+///
+/// Opens the envelope. The workflow's declared input is a — that is what
+/// the runner deserializes the stored context into and sends as the first message — while every DSL
+/// node speaks .
+///
+///
+/// A real node rather than a conversion hidden inside the first one: the engine routes by message
+/// type, so without something typed to accept the context the first node simply never receives it
+/// and the run completes having done nothing at all.
+///
+internal sealed class DslEntryExecutor : HostExecutor
+{
+ ///
+ /// Cannot collide with a declared node id: those must match ^[a-z][a-z0-9-]{0,63}$, which
+ /// forbids a leading dollar.
+ ///
+ internal const string NodeId = "$entry";
+
+ internal DslEntryExecutor() : base(NodeId) { }
+
+ public override IReadOnlyDictionary Metadata =>
+ new Dictionary { ["node.kind"] = "entry" };
+
+ protected override ValueTask ExecuteCoreAsync(
+ JsonElement input, IWorkflowContext context, CancellationToken cancellationToken)
+ => ValueTask.FromResult(DslMessage.Start(input));
+}
+
+///
+/// Closes the envelope: the workflow's result is the payload, not the envelope that carried it.
+///
+///
+///
+/// A node rather than a YieldOutputAsync call inside each output node, because the engine
+/// checks a yielded value against the executor's declared output type — a DSL node declares
+/// and may not yield anything else.
+///
+///
+/// Without it the caller would get back the whole envelope, including the start context every
+/// message carries so that expressions can reach it. That context is machinery, not a result.
+///
+///
+internal sealed class DslExitExecutor : HostExecutor
+{
+ internal const string NodeId = "$exit";
+
+ internal DslExitExecutor() : base(NodeId) { }
+
+ public override IReadOnlyDictionary Metadata =>
+ new Dictionary { ["node.kind"] = "exit" };
+
+ protected override ValueTask ExecuteCoreAsync(
+ DslMessage input, IWorkflowContext context, CancellationToken cancellationToken)
+ => ValueTask.FromResult(Unwrap(input.Data));
+
+ ///
+ /// Payload data is an object in every shape the built-in nodes produce. A document that ends on
+ /// something else still gets a result rather than a failure, wrapped so the shape is predictable.
+ ///
+ internal static JsonObject Unwrap(JsonNode? data) => data switch
+ {
+ JsonObject obj => (JsonObject)obj.DeepClone(),
+ null => [],
+ _ => new JsonObject { ["value"] = data.DeepClone() }
+ };
+}
+
+/// Pure projection. The only node that computes, and it computes only through AbEx.
+internal sealed class DslTransformExecutor : DslExecutor
+{
+ private readonly DslTransformNode _node;
+
+ internal DslTransformExecutor(DslTransformNode node, TimeProvider? clock = null) : base(node, clock)
+ => _node = node;
+
+ protected override ValueTask RunAsync(
+ DslMessage input, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ JsonNode? data = DslExpressions.ApplySet(_node.Set, Context(input), input.Data, _node.Replace);
+ return ValueTask.FromResult(input.WithData(data));
+ }
+}
+
+///
+/// Aggregates a fan-in barrier's inputs. The one node whose input is not a bare envelope, because the
+/// engine delivers a barrier's messages as a list.
+///
+internal sealed class DslFanInExecutor : DslExecutor
+{
+ private readonly DslFanInNode _node;
+ private readonly int _expected;
+ private readonly List _arrived = [];
+ private readonly Lock _gate = new();
+
+ internal DslFanInExecutor(DslFanInNode node, int expectedSources, TimeProvider? clock = null)
+ : base(node, clock)
+ {
+ _node = node;
+ _expected = Math.Max(1, expectedSources);
+ }
+
+ protected override ValueTask RunAsync(
+ DslMessage input, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ // A barrier releases its held messages together, but the engine still delivers them one at a
+ // time — it type-checks the target against the individual message, not against a list. So the
+ // aggregation lives here: hold each arrival, and emit once the last one lands.
+ JsonArray? items = null;
+
+ lock (_gate)
+ {
+ _arrived.Add(input.Data?.DeepClone());
+
+ if (_arrived.Count >= _expected)
+ {
+ items = [.. _arrived];
+ _arrived.Clear();
+ }
+ }
+
+ if (items is null)
+ {
+ // Not the last arrival. Returning null emits nothing — the same mechanism a gated node
+ // uses to stay silent, without requesting a halt.
+ return ValueTask.FromResult(null);
+ }
+
+ var data = new JsonObject();
+ DslExpressions.Assign(data, _node.Into, items);
+
+ // The context is identical on every branch by construction — it is frozen at start — so
+ // continuing with this arrival's envelope is not a choice between differing values.
+ return ValueTask.FromResult(input.WithData(data));
+ }
+}
+
+///
+/// Builds the executor for each built-in kind.
+///
+///
+/// Every one of these maps onto an executor the host already ships. Nothing here reimplements HTTP,
+/// prompting, egress control, idempotency keys, durable waits or cost accounting — the DSL is a
+/// front end, and a front end that forked the execution path would stop being one.
+///
+internal static class DslBuiltInNodes
+{
+ internal static IHostExecutor Create(DslNodeContext context, DslNodeCatalog catalog, TimeProvider clock)
+ => context.Node switch
+ {
+ DslTransformNode node => new DslTransformExecutor(node, clock),
+ DslFanInNode node => new DslFanInExecutor(node, BarrierSourceCount(context.Document, node.Id), clock),
+ DslApprovalNode node => Approval(node, context, clock),
+ DslHttpNode node => Http(node, context, clock),
+ DslLlmNode node => Llm(node, context, clock),
+ DslDelayNode node => Delay(node, context, clock),
+ DslPublishNode node => Publish(node, context, clock),
+ DslWaitEventNode node => WaitEvent(node, context, clock),
+ DslCustomNode node => Custom(node, context, catalog, clock),
+ _ => throw new NotSupportedException(
+ $"Node '{context.Node.Id}' has kind '{context.Node.Kind}', which the interpreter does not build.")
+ };
+
+ ///
+ /// How many messages a barrier will release into this node. Read from the document rather than
+ /// counted at run time, because the node has to know when it has them all before the last one
+ /// arrives.
+ ///
+ private static int BarrierSourceCount(Model.DslDocument document, string nodeId)
+ => document.Edges
+ .Where(e => e.IsBarrier && e.To.Contains(nodeId, StringComparer.Ordinal))
+ .Sum(e => e.From.Count);
+
+ ///
+ /// Identity work. The pause comes from the gate the factory guarantees, so the node is visible in
+ /// the graph as the place a human decides rather than as configuration on some other node.
+ ///
+ private static IHostExecutor Approval(DslApprovalNode node, DslNodeContext context, TimeProvider clock)
+ {
+ var inner = new HumanApprovalExecutor(node.Id);
+
+ return new DslHostedExecutor(
+ node, inner, inner.ExecuteTerminalAsync,
+ static (output, _) => (DslMessage)output, clock);
+ }
+
+ private static IHostExecutor Http(DslHttpNode node, DslNodeContext context, TimeProvider clock)
+ {
+ var options = new ApiCallOptions
+ {
+ Method = new HttpMethod(node.Method),
+ UrlTemplate = node.Url,
+ Headers = new Dictionary(node.Headers),
+ BodyTemplate = node.Body,
+ TimeoutSeconds = node.TimeoutSeconds,
+ AllowedHosts = [.. node.AllowedHosts],
+ SendIdempotencyKey = node.SendIdempotencyKey
+ };
+
+ if (node.SuccessCodes.Count > 0)
+ {
+ options.SuccessCodes = [.. node.SuccessCodes];
+ }
+
+ IHttpClientFactory? factory = context.Optional();
+ Func clientFactory = factory is null
+ ? static () => new HttpClient()
+ : () => factory.CreateClient(ApiCallOptions.HttpClientName);
+
+ var inner = new ApiCallExecutor(node.Id, options, clientFactory);
+
+ return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync, ProjectHttp, clock);
+ }
+
+ ///
+ /// Status alongside body, so a document can route on either. Both are needed: the body carries
+ /// the answer, and the status is how a workflow tells "found nothing" from "not found".
+ ///
+ private static DslMessage ProjectHttp(object output, DslMessage input)
+ {
+ var result = (ApiCallResult)output;
+
+ return input.WithData(new JsonObject
+ {
+ ["status"] = result.StatusCode,
+ ["body"] = ToJson(result.Body, result.RawBody)
+ });
+ }
+
+ private static JsonNode? ToJson(object? body, string? raw)
+ {
+ if (body is not null and not string)
+ {
+ return JsonSerializer.SerializeToNode(body, JsonOptions.Default);
+ }
+
+ string? text = body as string ?? raw;
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return null;
+ }
+
+ try
+ {
+ // A JSON response is far more useful addressable than as a string, and a non-JSON one
+ // must not fail the node for being what it always was.
+ return JsonNode.Parse(text);
+ }
+ catch (JsonException)
+ {
+ return JsonValue.Create(text);
+ }
+ }
+
+ private static IHostExecutor Llm(DslLlmNode node, DslNodeContext context, TimeProvider clock)
+ {
+ var options = new LlmOptions
+ {
+ Model = node.Model,
+ SystemPrompt = node.System,
+ UserTemplate = node.Prompt,
+ PromptVersion = node.PromptVersion,
+ Temperature = node.Temperature,
+ MaxTokens = node.MaxTokens,
+ StreamDeltas = node.StreamDeltas,
+ EmitCompletion = node.EmitCompletion
+ };
+
+ var resolver = context.Optional();
+ Func clientResolver = resolver is not null
+ ? resolver.Resolve
+ : _ => context.Require();
+
+ var inner = new LlmExecutor(node.Id, options, clientResolver, context.Optional());
+
+ return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync, ProjectLlm, clock);
+ }
+
+ ///
+ /// Text and the parsed value, plus the numbers a run is judged by. Cost and tokens are on the
+ /// envelope as well as in the log because a document may want to branch on them.
+ ///
+ private static DslMessage ProjectLlm(object output, DslMessage input)
+ {
+ var result = (LlmResult)output;
+
+ return input.WithData(new JsonObject
+ {
+ ["text"] = result.Text,
+ ["value"] = result.Value is null or string
+ ? JsonValue.Create(result.Text)
+ : JsonSerializer.SerializeToNode(result.Value, JsonOptions.Default),
+ ["model"] = result.ModelId,
+ ["inputTokens"] = result.InputTokens,
+ ["outputTokens"] = result.OutputTokens,
+ ["costUsd"] = result.CostUsd,
+ ["finishReason"] = result.FinishReason,
+ ["elapsedMs"] = (long)result.Elapsed.TotalMilliseconds
+ });
+ }
+
+ private static IHostExecutor Delay(DslDelayNode node, DslNodeContext context, TimeProvider clock)
+ {
+ var inner = new DelayExecutor(node.Id, node.For, context.Require(), clock);
+
+ // The envelope passes through: a delay is about when the next node runs, not about changing
+ // what it receives, and losing the payload to a TimerElapsed record would make every delay
+ // need a transform after it.
+ return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync,
+ static (_, input) => input, clock);
+ }
+
+ private static IHostExecutor Publish(DslPublishNode node, DslNodeContext context, TimeProvider clock)
+ {
+ var broker = context.Require();
+
+ DeliveryScope scope = string.Equals(node.Scope, "distributed", StringComparison.Ordinal)
+ ? DeliveryScope.Distributed
+ : DeliveryScope.Local;
+
+ var inner = new PublishDomainEventExecutor(
+ node.Id,
+ broker,
+ node.Topic,
+ payload: message => BuildPayload(node, message),
+ correlationKey: message => node.CorrelationKey is null
+ ? null
+ : DslExpressions.Text(node.CorrelationKey, message.ToExpressionContext(message.Run)),
+ scope: scope,
+ clock: clock);
+
+ // Publishing passes its input through, so the envelope continues unchanged.
+ return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync,
+ static (output, _) => (DslMessage)output, clock);
+ }
+
+ ///
+ /// An explicit payload map, or the whole of data. Defaulting to the payload rather than
+ /// the envelope matters: a subscriber should receive the message, not this workflow's context.
+ ///
+ private static object BuildPayload(DslPublishNode node, DslMessage message)
+ {
+ if (node.Payload.Count == 0)
+ {
+ return message.Data ?? (JsonNode)new JsonObject();
+ }
+
+ AbExContext context = message.ToExpressionContext(message.Run);
+ var payload = new JsonObject();
+
+ foreach ((string key, string expression) in node.Payload)
+ {
+ AbExValue value = DslExpressions.Evaluate(expression, context);
+ payload[key] = value.IsAbsent ? null : value.ToNode();
+ }
+
+ return payload;
+ }
+
+ private static IHostExecutor WaitEvent(DslWaitEventNode node, DslNodeContext context, TimeProvider clock)
+ {
+ WaitExpiryAction onExpiry = string.Equals(node.OnExpiry, "resume", StringComparison.Ordinal)
+ ? WaitExpiryAction.Resume
+ : WaitExpiryAction.DeadStop;
+
+ var inner = new WaitForDomainEventExecutor(
+ node.Id,
+ context.Require(),
+ node.Topic,
+ correlationKey: message => node.CorrelationKey is null
+ ? null
+ : DslExpressions.Text(node.CorrelationKey, message.ToExpressionContext(message.Run)),
+ timeout: node.Timeout,
+ onExpiry: onExpiry,
+ clock: clock);
+
+ // Runs twice: the first pass registers the wait and parks (null output, propagated by the
+ // hosted executor), the second finds the delivered payload and returns it as the new data.
+ return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync,
+ static (output, input) => input.WithData((JsonNode)output), clock);
+ }
+
+ private static IHostExecutor Custom(
+ DslCustomNode node, DslNodeContext context, DslNodeCatalog catalog, TimeProvider clock)
+ {
+ if (!catalog.TryGet(node.NodeName, out IDslNodeFactory factory))
+ {
+ // Validation refuses this at registration, so reaching here means the catalog changed
+ // underneath a document that was already accepted.
+ throw new DslInterpretationException(
+ $"Node '{node.Id}' names custom node '{node.NodeName}', which is not registered. " +
+ $"Known: {(catalog.Names.Count == 0 ? "(none)" : string.Join(", ", catalog.Names))}.");
+ }
+
+ IHostExecutor executor = factory.Create(context)
+ ?? throw new DslInterpretationException(
+ $"The factory for custom node '{node.NodeName}' returned null for node '{node.Id}'.");
+
+ if (executor.InputType != typeof(DslMessage) || executor.OutputType != typeof(DslMessage))
+ {
+ // Every edge in a DSL graph carries the envelope. A node emitting anything else breaks
+ // the next edge rather than its own, so it is refused where the mistake was made.
+ throw new DslInterpretationException(
+ $"Custom node '{node.NodeName}' produced an executor of " +
+ $"{executor.InputType.Name} -> {executor.OutputType.Name}. " +
+ $"A DSL node must be HostExecutor<{nameof(DslMessage)}, {nameof(DslMessage)}>.");
+ }
+
+ if (!string.Equals(executor.Id, node.Id, StringComparison.Ordinal))
+ {
+ throw new DslInterpretationException(
+ $"Custom node '{node.NodeName}' produced an executor with id '{executor.Id}', " +
+ $"but the document declared '{node.Id}'. Gate policy and node state key off the " +
+ "declared id, so they must match.");
+ }
+
+ // Hosted rather than returned bare, so a custom node gets everything a built-in one gets:
+ // the bound expression roots, its declared notification, and the output yield. A factory
+ // author writes ExecuteCoreAsync and nothing else.
+ var typed = (HostExecutor)executor;
+
+ return new DslHostedExecutor(
+ node, typed, typed.ExecuteTerminalAsync,
+ static (output, _) => (DslMessage)output, clock);
+ }
+}
+
+///
+/// Resolves a chat client by model name.
+///
+///
+/// A document names its model as a string, and a host serving several models needs some way to map
+/// that to a client. Optional: a host with one model registers an and
+/// nothing else.
+///
+public interface IChatClientResolver
+{
+ IChatClient Resolve(string model);
+}
diff --git a/src/Abacus.Run.Dsl/Interpretation/DslExecutor.cs b/src/Abacus.Run.Dsl/Interpretation/DslExecutor.cs
new file mode 100644
index 0000000..8a8d968
--- /dev/null
+++ b/src/Abacus.Run.Dsl/Interpretation/DslExecutor.cs
@@ -0,0 +1,171 @@
+using System.Runtime.ExceptionServices;
+using System.Text.Json.Nodes;
+using Abacus.Run.Abstractions;
+using Abacus.Run.Abstractions.Middleware;
+using Abacus.Run.Dsl.Expressions;
+using Abacus.Run.Dsl.Model;
+using Microsoft.Agents.AI.Workflows;
+
+namespace Abacus.Run.Dsl.Interpretation;
+
+///
+/// Base for every DSL node. Binds the expression roots, applies the declared notification, and keeps
+/// the park path intact.
+///
+///
+/// is sealed so no node can skip the envelope handling. A subclass
+/// implements and returns null to park, exactly as the framework's own
+/// approval and event-wait executors do.
+///
+public abstract class DslExecutor : HostExecutor
+{
+ private readonly TimeProvider _clock;
+
+ protected DslExecutor(DslNode node, TimeProvider? clock = null) : base(node.Id)
+ {
+ Node = node;
+ _clock = clock ?? TimeProvider.System;
+ }
+
+ protected DslNode Node { get; }
+
+ public override IReadOnlyDictionary Metadata => new Dictionary
+ {
+ ["node.kind"] = Node.Kind,
+ ["dsl.node"] = Node.Id
+ };
+
+ protected sealed override async ValueTask ExecuteCoreAsync(
+ DslMessage input, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ DslMessage bound = Bind(input);
+
+ DslMessage? result = await RunAsync(bound, context, cancellationToken).ConfigureAwait(false);
+
+ // Null is the park signal — the engine declines to send a null handler result, which is what
+ // lets a gated or waiting node halt without emitting a bogus message downstream.
+ if (result is null)
+ {
+ return null!;
+ }
+
+ await NotifyAsync(result, cancellationToken).ConfigureAwait(false);
+ return result;
+ }
+
+ /// The node's own work. Return null to park the instance.
+ protected abstract ValueTask RunAsync(
+ DslMessage input, IWorkflowContext context, CancellationToken cancellationToken);
+
+ ///
+ /// Refreshes $run and meta for this hop. Rebuilt per node rather than carried,
+ /// because superstep and attempt are the two things that change as a run proceeds.
+ ///
+ protected DslMessage Bind(DslMessage input)
+ {
+ JsonObject run = DslMessage.RunMetadata(
+ Runtime.InstanceId,
+ Runtime.TenantId,
+ Runtime.Descriptor.WorkflowName,
+ Runtime.Descriptor.WorkflowVersion,
+ Runtime.Attempt,
+ Runtime.CurrentSuperstep,
+ _clock.GetUtcNow());
+
+ return input
+ .WithRun(run)
+ .WithMeta(new DslMeta(Id, Runtime.CurrentSuperstep, Runtime.Attempt));
+ }
+
+ protected AbExContext Context(DslMessage message) => message.ToExpressionContext(message.Run);
+
+ private async ValueTask NotifyAsync(DslMessage result, CancellationToken cancellationToken)
+ {
+ if (Node.Notify is not { } notify || Runtime.Notify is not { } notifier)
+ {
+ return;
+ }
+
+ var payload = new JsonObject();
+ AbExContext context = Context(result);
+
+ foreach ((string key, string expression) in notify.Payload)
+ {
+ AbExValue value = DslExpressions.Evaluate(expression, context);
+ if (!value.IsAbsent)
+ {
+ payload[key] = value.ToNode();
+ }
+ }
+
+ await notifier.NotifyAsync(notify.Name, payload, cancellationToken).ConfigureAwait(false);
+ }
+}
+
+///
+/// Hosts one of the framework's own executors inside a DSL node.
+///
+///
+///
+/// The built-in executors are typed to their own inputs and outputs — ApiCallResult,
+/// LlmResult, TimerElapsed — which is exactly what the uniform envelope cannot carry.
+/// Rather than reimplement any of them, this calls
+/// on the inner executor and projects the
+/// result back into the envelope. No HTTP, egress, idempotency, prompt or cost logic is duplicated.
+///
+///
+/// The inner executor's own gate and middleware pipeline are deliberately bypassed: this node has
+/// already run both, and running them twice would double every middleware and evaluate the gate
+/// against an input that has already passed it.
+///
+///
+internal sealed class DslHostedExecutor : DslExecutor
+{
+ private readonly IHostExecutor _inner;
+ private readonly Func _invoke;
+ private readonly Func