Skip to content

Commit ab36e95

Browse files
authored
Declarative workflow authoring: the Abacus DSL
* docs(dsl): design and implementation plan for declarative workflow authoring Adds a second authoring path: a workflow as a JSON document, validated against a published schema and interpreted onto the existing runtime. 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, reached either as a built-in kind or as a custom node registered by name. Three decisions follow from putting JSON in front of a generic, delegate- shaped API: - One envelope type. Every node is HostExecutor<DslMessage, DslMessage>, so the graph is uniformly typed, checkpoints serialize for free, and the null-return park path keeps working. The envelope carries the start context alongside the current value, restoring the ambient scope a document otherwise lacks. - A closed expression language: total, pure, statically checkable, with a fixed function set. Non-deterministic functions are refused in edge conditions and gate predicates, because a resumed run must retrace the routing its checkpoint recorded. - A named node catalog with a registration seam, and no delegate kind. Validation is two phases: JSON Schema for shape, then a semantic validator for what a schema cannot express - id uniqueness, reachability, cycles without a delay, expression parsing, catalog resolution. Every diagnostic carries a JSON Pointer. The schema is published rather than described. It checks as legal Draft 2020-12, accepts the worked example, and rejects 17 malformed variants covering each conditional branch it declares. The plan keeps the core change to one additive opt-in interface, IContextValidatingWorkflow, consulted by the registry after the type bind. Runtime publication, sub-workflows and iteration are named as deferred, with reasons. * docs(dsl): move design and plan into the numbered implementation series Slots the DSL artifacts in alongside the existing docs/implementation sequence as 06 and 07, and repoints the relative links the move broke. The schema stays at docs/schema/. It is a published product artifact - embedded as a resource, served from GET /v2/dsl/schema, and consumed by editors - not an implementation note, so it does not belong in the series. * feat(dsl): phase 1 - envelope and expression core Adds Abacus.Run.Dsl with the two pieces the interpreter is built on, both testable without a host. DslMessage is the single envelope every DSL node sends and receives. It carries the frozen start context alongside the current value, which is what lets an expression eleven nodes deep still read $ctx - a compiled node closes over C# scope, and a document has none. Immutable, so a message a checkpoint captured cannot be mutated by a node that runs later. AbEx is the expression language: a hand-written lexer and recursive-descent parser producing an immutable AST, a total evaluator, and static analysis. Total is the load-bearing property. Absence is a value, not an exception, so no expression over any document shape can throw - a workflow must take the other branch, not fail. Comparisons involving an absent operand are false including !=, because a document asking whether a field it never set differs from a value must not be told yes; has() is how presence is asked about. Conditions are strictly boolean, with no truthiness ladder. Arithmetic is decimal, because these documents price orders. The function set is closed. An unknown name is a validation error with a nearest-match suggestion, and the pattern argument to matches() must be a string literal so every regex in a document is reviewable by reading the document. Regex matching carries a 200ms timeout; a timed-out match is a non-match rather than a way for an author to stall a dispatcher. Two deviations from the grammar as written, both recorded in the plan: unary ! and - bind tightest rather than sitting between && and comparison, and bare-identifier path roots are dropped in favour of requiring $, $ctx or $run. The first matches what an author expects; the second removes a real ambiguity between a path and a function name. One additive change to Abacus.Run: ITemplateBindingSource, which lets a message resolve its own {{ }} placeholders. The default dotted-path walk suits one POCO root and cannot address an envelope carrying two objects. Opt-in - a type that does not implement it resolves exactly as before. 207 tests. * feat(dsl): phase 2 - document model and two-phase validation Text in, typed model and pointer-accurate diagnostics out. Validation runs in two phases because one cannot do the job. JSON Schema checks shape - required properties, kind-discriminated variants, id and SemVer patterns. It cannot compare two array items, follow a reference, walk a graph, or parse a sub-language, so the semantic validator handles id uniqueness, edge endpoints, reachability, cycles, expressions, gates, catalog resolution and limits. Twenty-two stable codes, each with a test asserting its code, pointer and severity. The phases stop where continuing would be noise: a document that fails the schema is not read into the model, because reporting forty type errors from a half-understood document buries the one that matters. Two rules are worth calling out. 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 that occupies a dispatcher until the lifetime cap. And a duplicate unconditional edge is refused while two conditional edges between the same pair are fine, because that is exactly how a branch with a fallback is written. Environment-dependent checks - custom node registration, parameter schemas, egress hosts, hash conflicts - are reported as skipped rather than passed when there is no host to check against. A check that silently did not run is worse than one that openly did not, because only the second can be acted on. Document identity is a canonical SHA-256 (RFC 8785 JCS). Reformatting and property reordering do not change it; one byte of behaviour does. That makes the immutability rule enforceable and answers the operational question directly: is this instance running the document I am looking at? The schema is embedded from docs/schema/ rather than copied, with a test asserting the embedded resource matches the published file - otherwise an editor validates against one document and the host enforces another. One bug found by its own tests: the validator crashed on duplicate node ids, which is one of the things it exists to report. Building the kind lookup with ToDictionary threw before the diagnostic could be produced. Now built tolerantly, with robustness tests over pathological documents. 317 tests (+110). * feat(dsl): phases 3 and 4 - interpreter and host integration A DSL document now registers and runs as an ordinary workflow definition. Every kind maps onto an executor the host already ships. Nothing here reimplements HTTP, prompting, egress control, idempotency keys, durable waits or cost accounting: DslHostedExecutor calls ExecuteTerminalAsync on the real executor and projects the result back into the envelope, skipping the inner gate and pipeline because the outer node has already run both. A front end that forked the execution path would stop being one. Custom nodes go through the same wrapper, so a factory author writes ExecuteCoreAsync and gets the bound expression roots, the declared notification and the result projection for free. The graph gained an entry and an exit node, neither of them 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 at all. The exit node is the mirror: 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 be handed the start context back as though it were a result. Registration is deferred until the container is built, so AddDslWorkflow and AddDslNode can be written in either order - a document is always validated against the complete node catalog. An invalid document fails startup with every diagnostic, not the first: three broken documents should take one startup to fix. Two defects surfaced through their own tests. AbExValue.FromNode probed CLR types in turn, and a JsonValue created from an int will not hand back a decimal, so an HTTP status of 200 fell through to the string branch and never equalled 200. And the semantic validator crashed on duplicate node ids, which is one of the things it exists to report. Two pre-existing issues are recorded in the plan rather than fixed here: FanInExecutor<TItem,TOut> cannot work with AddFanInBarrierEdge, because the engine type-checks a barrier target against the individual message and not the list; and no ITimerService is registered anywhere, so DelayExecutor cannot run on a stock host. Suites: 723 unit (unchanged), 358 DSL unit, 201 integration (+51), 7 chaos. * docs(dsl): phase 5 - wiki chapter, README, and a worked example Documents the DSL where the compiled path is documented, so the two are legible side by side - the honest reason to pick one over the other is what a reader most needs. The wiki chapter sits beside "Authoring a workflow" and covers the envelope, the expression language and its semantics, every node kind and what it puts on the envelope, the custom-node seam, registration, the two validation phases with their diagnostic codes, version immutability, the routes, the limits, and - stated plainly - what the DSL does not do. Ships example-order.workflow.json beside the compiled example, with tests asserting it validates and registers. Documentation people copy should fail here rather than in their editor. A parity fixture runs the same work authored both ways on one host and asserts identical results, including on values where binary floating point would diverge from the compiled path. That is the clearest available statement that the DSL is a front end and not a fork. One deviation, recorded in the plan: the parity pair is not ExampleOrderWorkflow. That workflow sums an array of order lines and the DSL has no iteration, which is exactly the limitation the design records - found by trying to hit it. Suites: 723 unit, 358 DSL unit, 209 integration (+59), 7 chaos. * feat(dsl): close the plan's remaining gaps and dead-stop interpretation failures Three items from 07-workflow-dsl-implementation-plan.md were unimplemented, and writing the tests for one of them found a defect. Catalog provenance (plan 4.3). GET /v2/workflows/{name} now reports source and documentHash. Abacus.Run cannot name the DSL, so a definition answers for itself through IDocumentAuthoredWorkflow and the catalog reports "compiled" with a null hash for anything that does not implement it. Same probe-by-is pattern the runtime already uses for INotifyingWorkflow. Architecture tests. Nothing enforced the DSL project's layering: it now asserts the DSL references Abacus.Run and neither the host nor infrastructure, holds no internals access, that the framework references neither the DSL nor JsonSchema.Net, and that the schema ships as exactly one embedded resource. Redaction and checkpoint size. Both were listed as tested mitigations and were not tested. Under a restrictive policy a node reads a secret from ctx, no event in the run carries it, and the instance's own state still does. A 128 KB context through a four-hop document shows the checkpoint holds about one context rather than one per hop. Defect: a custom node of the wrong executor shape was refused correctly and then retried, burning the whole attempt budget re-deriving an error that cannot change. Interpretation failures now throw DslInterpretationException and classify as a dead stop ahead of the document's own onFailure rules. Found because WrongShapeNodeFactory had been registered in the fixture but never exercised. Also covers egress enforcement at startup in both directions and compares the /dsl/* routes' authorization metadata against /workflows, so locking down the catalog forces the DSL routes to keep pace. 723 unit, 361 DSL, 232 integration, 7 chaos, all green. * docs: fork workflow authoring into a C# guide and a DSL guide The wiki carried the whole compiled-authoring surface plus a 16-recipe appendix, and the DSL had a full reference beside it. That left the two paths documented at different depths and in different places. Adds docs/workflow-authoring-guide.md: the complete C# reference, mirroring the DSL guide section for section. Every built-in executor with its real constructor and behaviour, the full gate, notification, trigger and audit surfaces, both middleware seams, registration, versioning, a section on choosing between the two front ends, and an options appendix with every default. Written from the source, which corrected a few things the wiki had wrong: the exception type is ApiCallFailureException, HumanApprovalExecutor is identity work and the gate is what pauses, FanInExecutor cannot be a barrier target, and there is no SubWorkflow API. A "sharp edges" section states those and the rest rather than leaving them to be discovered. The wiki's authoring chapter now opens with the fork: two ways in, what each reaches, and where to read next for either. Its appendix moved into the two guides so a recipe sits beside the field reference it uses; the heading stays as a signpost, so existing deep links still land. Also commits docs/dsl-authoring-guide.md, which the wiki has linked to since phase 5 but which .gitignore's docs/* rule kept out of the repository.
1 parent 2bebb6c commit ab36e95

61 files changed

Lines changed: 15410 additions & 370 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Abacus.Run.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
<Project Path="src/Abacus.Run/Abacus.Run.csproj" />
44
<Project Path="src/Abacus.Adapters.Cache.Redis/Abacus.Adapters.Cache.Redis.csproj" />
55
<Project Path="src/Abacus.Adapters.Messaging.RabbitMQ/Abacus.Adapters.Messaging.RabbitMQ.csproj" />
6+
<Project Path="src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj" />
67
<Project Path="src/Abacus.Run.Service/Abacus.Run.Service.csproj" />
78
</Folder>
89
<Folder Name="/tests/">
910
<Project Path="tests/Abacus.Run.IntegrationTests/Abacus.Run.IntegrationTests.csproj" />
1011
<Project Path="tests/Abacus.Run.UnitTests/Abacus.Run.UnitTests.csproj" />
12+
<Project Path="tests/Abacus.Run.DslTests/Abacus.Run.DslTests.csproj" />
1113
<Project Path="tests/Abacus.Run.ChaosTests/Abacus.Run.ChaosTests.csproj" />
1214
<Project Path="tests/Abacus.Run.BrokerTests/Abacus.Run.BrokerTests.csproj" />
1315
<Project Path="tests/Abacus.Run.LoadTests/Abacus.Run.LoadTests.csproj" />

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ builder.Services
8787

8888
`OrderWorkflow` must implement `IWorkflowDefinition` or `IWorkflowDefinition<TContext, TResult>`. Use `WorkflowBuildContext.Node(...)` to attach host executors and declare approval gates.
8989

90+
There are two ways to author a workflow, and both produce an `IWorkflowDefinition` on the same
91+
runtime: **in C#**, as above, or **as a JSON document** ([Authoring with the DSL](#authoring-with-the-dsl)
92+
below). Code computes, documents compose; a host can run both at once. Complete references:
93+
[Authoring workflows in C#](docs/workflow-authoring-guide.md) and
94+
[Authoring workflows with the Abacus DSL](docs/dsl-authoring-guide.md).
95+
9096
### Declaring executor gates
9197

9298
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
422428
Full walkthrough: [Events, history, and SSE](docs/wiki.md#events-history-and-sse) and
423429
[Event broker](docs/wiki.md#event-broker-and-event-driven-workflows).
424430

431+
## Authoring with the DSL
432+
433+
A workflow can be a **JSON document** instead of C#: validated against a published schema,
434+
interpreted at build time, and registered exactly like a compiled definition. Same graph, same
435+
executors, same gates, same events — the DSL is a second front end onto the runtime, not a fork.
436+
437+
The governing rule is that **the DSL composes but never computes**. A document declares which nodes
438+
exist, how they connect, and when an edge is taken; it carries no behaviour. Every unit of work is a
439+
capability the host already shipped, so the answer to "the DSL cannot express this" is always
440+
*register a node*, never *embed a script*.
441+
442+
```json
443+
{
444+
"dsl": "abacus.workflow/1.0",
445+
"name": "order-settlement",
446+
"version": "1.0.0",
447+
"context": { "type": "object", "required": ["orderId", "amount"] },
448+
"start": "price",
449+
"output": ["settle"],
450+
"nodes": [
451+
{ "id": "price", "kind": "transform", "set": { "total": "$ctx.amount * 1.2" } },
452+
{ "id": "settle", "kind": "http",
453+
"method": "POST",
454+
"url": "https://ledger.internal/v1/settlements",
455+
"allowedHosts": ["ledger.internal"],
456+
"body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}",
457+
"gate": { "mode": "conditional", "when": "$.total > 25000", "reason": "RegulatedSettlement" } }
458+
],
459+
"edges": [ { "from": "price", "to": "settle" } ]
460+
}
461+
```
462+
463+
```csharp
464+
builder.Services.AddWorkflowHost(configuration)
465+
.AddWorkflow<ExampleOrderWorkflow>() // compiled, unchanged
466+
.UseDsl()
467+
.AddDslNode(new RiskScoringNodeFactory()) // extend the vocabulary
468+
.AddDslWorkflowsFromDirectory("workflows/"); // compose it
469+
470+
app.MapDslApi();
471+
```
472+
473+
Node kinds cover `transform`, `http`, `llm`, `delay`, `approval`, `publish`, `wait-event`, `fan-in`
474+
and `custom`. Expressions are a closed, total language — absence is a value rather than an exception,
475+
conditions are strictly boolean, and arithmetic is decimal. Validation runs in two phases, and every
476+
diagnostic carries a JSON Pointer:
477+
478+
```
479+
DSL0412 error /nodes/3/gate/when Unknown function 'lookupCustomer'. Did you mean 'coalesce'?
480+
DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. Did you mean 'settle'?
481+
```
482+
483+
An invalid document fails startup. A published `(name, version)` is immutable, enforced by a
484+
canonical hash of the document. Routes: `GET /dsl/schema`, `/dsl/nodes`, `/dsl/functions`,
485+
`/dsl/documents`, and `POST /dsl/validate`. The ordinary catalog reports which front end authored
486+
each version: `GET /workflows/{name}` carries `source``dsl` or `compiled` — and, for a document,
487+
its `documentHash`.
488+
489+
Full walkthrough: [Authoring with the DSL](docs/wiki.md#authoring-with-the-dsl) and the complete
490+
reference, [Authoring workflows with the Abacus DSL](docs/dsl-authoring-guide.md) — whose mirror for
491+
the compiled path is [Authoring workflows in C#](docs/workflow-authoring-guide.md).
492+
Schema: [docs/schema/abacus-workflow-dsl-1.0.json](docs/schema/abacus-workflow-dsl-1.0.json).
493+
425494
## Audit records
426495

427496
Events record what the runtime did. An audit record answers the separate question of why a run's
@@ -549,8 +618,10 @@ at startup.
549618
| `src/Abacus.Run` | Headless framework: workflow runtime, dispatch, executors, middleware, in-memory store defaults, and HTTP API endpoints |
550619
| `src/Abacus.Adapters.Cache.Redis` | Redis adapters: Streams event bus, workflow event broker, cross-replica control channel |
551620
| `src/Abacus.Adapters.Messaging.RabbitMQ` | RabbitMQ adapter: topic-exchange workflow event broker |
621+
| `src/Abacus.Run.Dsl` | Declarative authoring: JSON Schema validation, the AbEx expression language, and the document interpreter |
552622
| `src/Abacus.Run.Service` | Deployable host: control-plane UI, SQL Server stores, the SQLite audit-record store, startup wiring, and the example workflow |
553623
| `tests/Abacus.Run.UnitTests` | Unit coverage for runtime behavior; references the library only |
624+
| `tests/Abacus.Run.DslTests` | Expression, validation and interpreter coverage for the DSL |
554625
| `tests/Abacus.Run.IntegrationTests` | HTTP, control-plane, and architecture-boundary coverage against the real host |
555626
| `tests/Abacus.Run.ChaosTests` | Failure and lifecycle resilience coverage |
556627
| `tests/Abacus.Run.BrokerTests` | The distributed brokers against real Redis and RabbitMQ, via Testcontainers |

0 commit comments

Comments
 (0)