Core model·BEX in contracts

BEX in contracts

BEX is Blue Expression Objects: deterministic logic written as Blue-compatible data. In the current Coordination examples, BEX usually appears inside Coordination/Compute steps, where it reads the current document and delivered event, computes values, appends patch operations, appends event payloads, and returns a result.

BEX is intentionally not JavaScript. It is not WASM. It is not a network runtime. It is not a database trigger. It does not mutate the document by itself. It computes a deterministic result, and the host decides how to use that result.

§Why BEX exists

Business rules often need to travel with the document they govern. If the logic lives only in private service code, a receiver can inspect the current state but not the rule that produced it. If the logic is embedded as arbitrary code, replay becomes difficult and the security boundary becomes vague.

BEX gives Blue documents a smaller surface:

  • programs are Blue object trees;
  • operators are explicit;
  • host inputs are explicit bindings;
  • output is a value, changeset, events, gas, and metrics;
  • external capabilities are available only through registered intrinsics;
  • deterministic failure is part of the model.

This is enough for many workflow tasks: validation, guarded state transitions, patch construction, event construction, projections, and idempotency checks.

§BEX syntax in one rule

A BEX operator is an object with exactly one key that starts with $:

YAML
1$add:
2 - 2
3 - 3

Objects with normal keys are literal Blue-compatible objects:

YAML
1status: confirmed
2amountMinor: 49900
3currency: PLN

This means BEX programs are still Blue data. They can be stored in documents, referenced, hashed, inspected, and transported like other Blue content.

§Reading from the runtime context

BEX reads data through explicit operators.

OperatorReads from
$documentThe current document view.
$eventThe delivered event binding, commonly the current timeline entry or triggered event.
$bindingA named host-provided binding.
$currentContractThe current contract binding when the host provides it.
$stepsPrior step results when the host provides them.
$varA local variable.
$constA declared program constant.

Counter uses the two most common reads:

YAML
1$add:
2 - $document: /counter
3 - $event: /message/request/amount

The first operand reads the current counter value from the document. The second reads the requested increment amount from the delivered event.

§Appending changes does not mutate immediately

A Compute step often uses $appendChange:

YAML
1- $appendChange:
2 op: replace
3 path: /counter
4 val:
5 $add:
6 - $document: /counter
7 - $event: /message/request/amount

This appends a patch entry to the BEX result changeset. It does not directly edit the document in-place. Later, the Compute host can retrieve the accumulated changes with $changeset:

YAML
1- $return:
2 changeset:
3 $changeset: true

In a Coordination/Compute step, the Coordination processor applies the returned changeset directly. That is why Compute is the right surface for dynamic document updates.

Coordination/Update Document is useful for literal patch lists. Coordination/Compute is the dynamic surface when patch contents depend on document state or event data.

§Appending events is the same pattern

A BEX program can accumulate events:

YAML
1- $appendEvent:
2 type: Counter/Incremented
3 amount:
4 $event: /message/request/amount

And return them:

YAML
1- $return:
2 events:
3 $events: true

In a Compute step, returned events are emitted by the host processor. The BEX program does not publish to a queue or call another system directly.

This is what makes event construction inspectable. The event payload is part of the document-owned logic.

§A complete Counter Compute step

YAML
1- name: IncrementAndEmit
2 type: Coordination/Compute
3 do:
4 - $let:
5 name: nextCounter
6 expr:
7 $add:
8 - $document: /counter
9 - $event: /message/request/amount
10 - $appendChange:
11 op: replace
12 path: /counter
13 val:
14 $var: nextCounter
15 - $appendEvent:
16 type: Counter/Incremented
17 amount:
18 $event: /message/request/amount
19 counter:
20 $var: nextCounter
21 - $return:
22 changeset:
23 $changeset: true
24 events:
25 $events: true

Read it line by line:

  1. $let computes a local value called nextCounter.
  2. $appendChange adds a JSON-patch-like replace operation for /counter.
  3. $appendEvent records a domain event payload.
  4. $return returns the accumulated changeset and events.

There is no hidden state. The only inputs are the document, event, constants, variables, and host bindings.

§Guards make workflows idempotent

The weekend package example uses BEX guards heavily. A guard checks current state and returns unchanged when an action has already happened or is not allowed.

For payment token attachment, the first guard is simple:

YAML
1- $if:
2 cond:
3 $eq:
4 - $document: /payment/tokenAttached
5 - true
6 then:
7 - $return:
8 changeset:
9 $changeset: true
10 events:
11 $events: true

If /payment/tokenAttached is already true, the program returns the current accumulated result. Since no changes or events have been appended yet, this is an unchanged result.

This pattern is one reason the weekend package source document is a strong example. Real distributed flows retry. A document-owned guard makes repeated delivery safe at the business layer, not just the transport layer.

§Rejection events are better than silent corruption

Another weekend package pattern checks whether a component order is already attached. If the incoming hotel order snapshot refers to the same existing document or session, the workflow may replace the existing snapshot. If it refers to a different order, the workflow emits a rejection event instead of overwriting the package.

The small core of that logic is:

YAML
1- $if:
2 cond:
3 $and:
4 - $var: hasExisting
5 - $not:
6 $var: sameExisting
7 then:
8 - $appendEvent:
9 type: Conversation/Event
10 kind: Component Order Attachment Rejected
11 orderKind: hotel
12 reason: component_order_already_attached
13 - $return:
14 changeset:
15 $changeset: true
16 events:
17 $events: true

This is not just validation. It is audit-friendly validation. The document can emit a structured explanation of why it did not accept an entry.

§Completion gates combine several reads

The PayNote inside the weekend package should request payment completion only after both merchant orders confirm.

The BEX shape is:

YAML
1- $if:
2 cond:
3 $or:
4 - $ne:
5 - $document: /embeddedDocs/orders/hotelOrder/confirmation/status
6 - confirmed
7 - $ne:
8 - $document: /embeddedDocs/orders/restaurantOrder/confirmation/status
9 - confirmed
10 - $truthy:
11 $document: /completionRequested
12 then:
13 - $return:
14 changeset:
15 $changeset: true
16 events:
17 $events: true
18- $appendChange:
19 op: replace
20 path: /completionRequested
21 val: true
22- $appendEvent:
23 type: PayNote/Complete Payment Requested
24 amount: 49900

The guard returns unchanged if either confirmation is missing or completion was already requested. Only when both child confirmations are present does the program update /completionRequested and emit PayNote/Complete Payment Requested.

This is the kind of rule that should be visible in the document. It is central to the checkout safety story.

§Dynamic paths should be built safely

BEX uses JSON Pointer paths for document and value access. When building paths from dynamic segments, use $pointerJoin rather than string concatenation:

YAML
1$pointerJoin:
2 - embeddedDocs
3 - orders
4 - $var: orderSlot
5 - confirmation
6 - status

If a segment contains / or ~, $pointerJoin escapes it correctly. This prevents bugs where a value intended as one field name is interpreted as multiple pointer segments.

Use static pointers when possible. Use $pointerJoin when the path is data-dependent.

§Variables, constants, and functions

Use $let for local values that are read more than once:

YAML
1- $let:
2 vars:
3 request:
4 $event: /message/request
5 currentStatus:
6 $document: /status

When one local depends on another in the same block, use ordered binding:

YAML
1- $let:
2 order: [request, amount]
3 vars:
4 request:
5 $event: /message/request
6 amount:
7 $var:
8 name: request
9 path: /amountMinor

Use constants for values that belong to the program definition rather than the delivered event:

YAML
1constants:
2 expectedAmount: 49900
3expr:
4 $const: expectedAmount

Functions are useful when the same validation or payload construction appears in several branches. Keep functions small. A BEX program should remain inspectable by readers who are not language implementers.

§Intrinsics are explicit host capabilities

BEX core has no network, filesystem, clock, randomness, database, JavaScript, or WASM access. A host can register intrinsics, but the program must name the intrinsic through static Blue type data, and the engine must have a matching processor.

That boundary is deliberate. For example, signature verification can be modeled as a host intrinsic, but the intrinsic must define exactly what bytes are verified and must charge gas. It should not become a generic escape hatch for arbitrary host code.

A good intrinsic is:

  • deterministic for the same inputs;
  • named by a stable Blue type or BlueId;
  • explicitly registered by the host;
  • covered by conformance fixtures;
  • clear about gas accounting;
  • narrow in purpose.

§Gas and metrics

BEX execution accounts for gas. Gas gives hosts a way to measure and limit computation. A BEX result can include gasUsed and metrics in addition to the returned value, changeset, and events.

Gas should not be described as a magical billing system. It is execution accounting. A host may enforce limits, reject programs that exceed budgets, expose metrics to tests, or use gas to compare fixture behavior.

For documentation examples, the important rule is simple: BEX computation should be bounded and deterministic. A program that loops over delivered request items should have predictable iteration behavior. Object iteration should be deterministic. Host intrinsics should charge gas explicitly.

§Failure is deterministic

BEX should fail deterministically when required inputs are missing or malformed and the program says they are required.

Use $failIf for explicit validation:

YAML
1- $failIf:
2 cond:
3 $not:
4 $exists:
5 $event: /message/request/id
6 message: request id is required

Use rejection events when the business process should continue and record a rejected action. Use deterministic failure when the computation itself cannot proceed validly.

That distinction matters in workflows. A malformed event might be a deterministic failure. A valid but unauthorized or duplicate component order might be a business rejection event.

§Testing BEX

Test BEX in two layers.

First, run the program directly with a known document view and bindings. Assert the returned value, changeset, events, gas, and failure behavior.

Second, run the same logic through Coordination/Compute in a document processor. Assert that the changes are actually applied, events are emitted, and checkpoints are written.

For Counter, the direct BEX test is tiny. For the weekend package, direct tests are valuable for the tricky guards: payment token attachment, PayNote attachment, component order attachment, and completion gates.

§What to remember

BEX is the document-owned deterministic logic layer. It lets a Blue document say how to compute a transition without giving that logic hidden authority over the outside world.

When reading examples, look for three things:

  1. What does BEX read from the document and event?
  2. What changes and events does it append?
  3. Which host surface applies those results?

If you can answer those questions, the workflow is inspectable.