Refusals, fields and invariants
What the model insists on
These are the authoring decisions that surprise people coming from OpenAPI-first or prose designs.
Each exists to keep a generated test honest. Every block below is an excerpt of
examples/billing/, abridged to the construct being explained.
A command that can be refused says so
Not an emits list — outcomes. From domains/invoice.yaml:
outcomes:
- name: accepted
when: amount.amount > 0
creates: billing.invoice.Invoice
instance: invoice_id
emits:
- billing.invoice.InvoiceCreated
- name: rejected
error: billing.invoice.InvalidAmount
A command with a precondition has at least two results. A specification recording only the happy one generates a suite that never checks the branch where the money does not move.
Two guards may both hold of one input. A refusal with a when: over the input is taken before any
accepting branch whose guard it overlaps, whatever order they are written in. With closed: open == false and id-required: ticket_id == "", the input {ticket_id: "", open: false} is refused as
id-required, and the generated suite sends it and requires that. An accepting branch cannot read
the identity to step aside, so this precedence is how such a command is written.
Two accepting branches may overlap as well. The first declared whose guard holds answers: with
small: amount < 100 written before flagged: amount > 50, the input {amount: 75} takes
small, and the generated suite sends an input in that overlap and requires small. Write the
narrower branch first when it should win. An external: branch takes its place in the same order: written after
small, it is asked only for an input small does not claim.
Two refusals with a when: over the input follow declaration order too: the first declared whose
guard holds answers, and the suite sends their overlap apart from the first refusal's own witness
(two input refusals whose guards overlap).
An invariant reads only what every creation sets
An entity invariant that reads a required field needs every creates: branch of that entity to set
the field. Without a value, reminder_count >= 0 would hold only if the implementation happened to
pick a value that satisfies it. validate refuses the gap with ESS-COMMAND-018 at the creating
outcome. The fix is to set the field there:
- name: accepted
creates: billing.invoice.Invoice
instance: invoice_id
sets:
account_id: input.account_id
total: input.amount
reminder_count: "0"
Or declare the field Optional<…> if an instance may lack it. The identity, the lifecycle state
and Optional fields are never asked for. A field an invariant reads anywhere counts, including
inside any:.
A literal in sets: or payload: may also be written as the YAML value it means:
reminder_count: 0 over an Integer and paused: false over a Boolean compile to exactly what
"0" and "false" do. An unquoted number or boolean over a text field or an enum is refused with
the repair, quote it: label: '0'. Over any other type it gets the same refusal as its quoted form.
A decimal such as 1.5 is never a literal, quoted or not; read it from an input.
A literal cannot say that an Optional field holds nothing. metrics: none is not read as the
field's type and synthesis drops it, and lane_id: "" is an empty string, which a reader tells
apart from an absent value and which a struct cannot hold at all. To empty a field, write
{cleared: true}; see value expressions.
Cover every declared enum value
Since 0.23.0 a command may omit its default when its input guards
select exactly one outcome for every value of a required, closed enum. For example,
if status has the declared variants Ready and Stopped, the two guards
status == Ready and status == Stopped cover that input. Synthesis uses the same
declared domain to construct a witness for each reachable branch. An omitted value
or overlapping guards produce a concrete failing assignment. Required Boolean
inputs also form a closed domain: pause == true and pause == false cover every
value without a default. Their witnesses carry Boolean values, not text.
An Optional value read only through defined(…) is closed too: it is either absent
or present. when: defined(coupon) and when: not defined(coupon) cover every
input without a default, and so do the same pair over a stored field in
when_subject: and over a related row's field in when_related:. A missing case is
named as coupon = absent or coupon = present. The pair is not proved beside
when_subject_state:, where a default is still needed.
This proof is bounded to 64 joint assignments and 128 predicate nodes. Required
enum and Boolean fields, transparent wrappers, equality, membership, Boolean truthiness,
defined(…) of an Optional path and supported Boolean combinations participate.
Every referenced input must fit that finite domain; an Optional path compared by
value (token == a, even beside defined(token)), open types, unsupported
expressions and unknown results retain the requirement for a default. Existing
defaults and external outcomes keep their behavior.
Give enum variants typed attributes
When each key of a closed set carries facts known as the specification is written — an operator
takes a number, a plan allows 50 seats — declare them on the enum rather than repeating the table
in every guard. From ess/23 an enum declares attributes: in the {name, type} shape of
fields:, and each variant gives every one a typed literal under its own attributes::
format: ess/23
system: demo
version: v1
domain: demo.rules
types:
- name: demo.rules.Operator
kind: enum
attributes:
- {name: takes_number, type: Boolean}
- {name: arity, type: Optional<Integer>}
variants:
- {name: GreaterThan, attributes: {takes_number: true, arity: 2}}
- {name: LessThan, attributes: {takes_number: true, arity: 2}}
- {name: Contains, attributes: {takes_number: false}}
events:
- name: demo.rules.RuleAdded
fields:
- {name: operator, type: demo.rules.Operator}
commands:
- name: demo.rules.AddRule
input:
- {name: operator, type: demo.rules.Operator}
outcomes:
- name: numeric
when: operator.takes_number == true
emits: [demo.rules.RuleAdded]
payload:
demo.rules.RuleAdded: {operator: input.operator}
- name: textual
when: operator.takes_number == false
emits: [demo.rules.RuleAdded]
payload:
demo.rules.RuleAdded: {operator: input.operator}
An attribute is a Boolean, an Integer, a Decimal, a String, a newtype of one, an enum, or
an Optional of these; a List attribute is refused by name. Each value is checked by the rule
sets: types a literal by, every variant fills every attribute that is not Optional, and a value
for an attribute the enum does not declare is refused. Below ess/23 the declaration is refused
as unsupported_format_version.
A guard, an invariant or a view filter reads an attribute as <fact>.<attribute>. The comparison
is lowered to membership over the variants that satisfy it: operator.takes_number == true is
exactly operator: {any_of: [GreaterThan, LessThan]}, so the coverage proof, synthesis, suites and
every runner see only variants. A comparison with another fact, seats >= plan.max_seats, expands
to one branch per variant, within the 128 predicate nodes the coverage proof allows; past that it
is refused by name. An Optional attribute a variant leaves out satisfies no comparison. Reading
an attribute as a value — sets: {x: input.plan.max_seats} — is refused in this cut.
The JSON Schema and OpenAPI projections annotate the enum with x-ess-attributes, the generated
documentation lists each variant's values, and the types-only Rust, Go and TypeScript outputs give
each enum an accessor per attribute.
A value that is one of several cases, each with its own payload, is not an enum and not a struct of
optional fields: it is a kind: union with a tag:, declared on tagged unions.
Order two instants
A right-hand side without a dot is a literal, so when: ends_at > starts_at compares ends_at
with the text "starts_at". Validation refuses it and names the spelling that reads a field:
declare the two Timestamp fields in one struct and compare its members.
input:
- {name: window, type: rooms.booking.Window} # struct {starts_at, ends_at: Timestamp}
outcomes:
- name: booked
when: window.ends_at > window.starts_at
A Timestamp is ordered by the RFC 3339 instant it names, so +01:00 and Z spellings compare
correctly. Against a literal, write an instant: when: ends_at > "2020-01-01T00:00:00Z". Ordering
a Timestamp against text that is not an instant is refused. Duration has no ordering yet.
Say which characters a text may hold, and how long it may be
A String the implementation restricts to a character set says so with alphabet: on its newtype,
and a length limit is .count, the number of Unicode scalar values. Both need format: ess/11.
types:
- name: keypad.dial.KeySequence
kind: newtype
of: String
alphabet: "0123456789*#ABCD"
commands:
- name: keypad.dial.SendKeys
input:
- {name: keys, type: keypad.dial.KeySequence, example: "12#"}
outcomes:
- name: too-long
when: keys.count > 64
error: keypad.dial.UnsupportedKey
- name: sent
emits: [keypad.dial.KeysSent]
Every character of a value is one of the alphabet's, compared exactly, with no normalization or case
folding. Synthesis builds the input's witness from those characters, and a guard such as
keys.count > 64 from texts of 65 and 64 characters. An example: on a command input is the value
the first witness starts from. It is not a constraint, it has to be a value of the input's type, and
only a scalar input takes one, or from ess/31 a list input, whose example is a list of element
values, example: [websocket], sent as written wherever no guard reads into the list. The published JSON Schema, OpenAPI and AsyncAPI assert the alphabet
as an anchored pattern beside the x-ess-alphabet annotation, so a schema validator refuses a
value outside it: 0123456789abcdef is ^[0-9a-f]*$, and every character other than an ASCII
letter or digit is written as a four-digit \u escape. An alphabet holding a character outside the
Basic Multilingual Plane stays an annotation, since no class reads it alike with and without the
u flag. Generated code documents an alphabet and does not enforce it, and Entity Runtime refuses
both an alphabet and a text length by name.
A .count bound against an integer literal is published as minLength and maxLength, which count
the same Unicode scalar values. It applies to a String newtype through value.count, and to a
String field of a struct or an entity, including an Optional one, through <field>.count:
value.count == 64 publishes both keywords as 64, and value.count: {gte: 1, lte: 10} publishes
minLength: 1 and maxLength: 10, as the two comparisons written apart do. utf8_bytes, !=
and the .count of a field typed by a newtype stay in x-ess-invariants only; the newtype's own
definition carries its bounds.
Say what a text starts with
A String newtype whose every value starts with fixed text says so with prefix:. It needs
format: ess/15.
types:
- name: demo.msgs.Channel
kind: newtype
of: String
prefix: "/"
The prefix is literal text, not a pattern. The published JSON Schema carries it as an anchored
pattern (^/), every witness synthesis builds starts with it (/channel for a field named
channel), and a literal written for the field that does not start with it is refused. Beside an
alphabet: every character of the prefix has to be in the alphabet, and the two are one pattern
(^0x[0-9a-fx]*$ for the prefix 0x and the alphabet 0123456789abcdefx). A newtype of a newtype may
declare a longer prefix that starts with the inner one. Entity Runtime lowers the prefix to a
starts_with rule.
A value type's invariants hold wherever a view publishes one
A newtype or a struct that declares invariants: makes a claim about every value of the type, and a
generated suite reads it wherever a view publishes a value of the type: directly in a field, or as
the element of a List, the value of a Map, the present value of an Optional or the payload of
one Union variant (tagged unions).
types:
- name: demo.tags.Tag
kind: newtype
of: String
alphabet: "abcdefghijklmnopqrstuvwxyz0123456789.-"
invariants: [value.count >= 1]
views:
- name: demo.tags.Boards
source: demo.tags.Board
fields:
- {name: board_id, type: demo.tags.BoardId}
- {name: tags, type: List<demo.tags.Tag>}
A direct position is asserted on the row: value.count >= 1 at a field tag becomes satisfies: tag.count >= 1 over every row, and a text's .count read this way takes suite format
ess-conformance/40. Any other position is observed by the value_invariants expectation (suite
format ess-conformance/52): the runner checks every actual element of tags against the type's
invariants and against its alphabet: and prefix:, so an empty element or one outside the alphabet
fails, and so does a bad element after a good one. An empty list, an absent Optional and another
Union variant are legal, but a row has to hold at least one actual value of the type at the
position, so a target that publishes none fails too. The scenario is
<type>/invariant-wrapped/at/<view>/<field>/<digest>, the digest naming the position.
The reference runner behind ess verify conform run, the interpreted target and the browser product
execute the observation. The generated Go and TypeScript runners admit the suite and report that
scenario unsupported, naming value_invariants. A position reached only across a recursive type is
refused by name (ESS-SYNTH-001), as is one whose arranged row the scenario does not know (ESS-SYNTH-013).
Declare a rule of every element on the element's type, as above. The same rule written as an entity
invariant over the list, forall: {in: tags, as: t, that: 't != ""'}, reads the list element by
element, which a view row does not assert: it is refused with ESS-SYNTH-011, which names the list
as published and read that way.
Carry any JSON value
Json is a primitive for a value the specification does not structure: a body delivered as it
arrived. It needs format: ess/15.
types:
- {name: demo.msgs.Body, kind: newtype, of: Json}
It projects to the empty JSON Schema, which every value satisfies, and a suite compares it
structurally: an object with the same members in another order is the same value. It is never a map
key, a predicate never reads one, and no literal spells one, so a payload fills a Json field from
an input. Entity Runtime stores it as its own JSON field kind. The Rust code target represents it
as the generated types crate's dependency-free json::Value, and the Go code target as the
generated primitives.Json, which carries the document text unchanged. The web code target
carries the Rust value through its bridge, and its page holds it as JSON.parse answers it. The
CLI code target reads a Json flag as one JSON document into the same json::Value.
Carry finite binary floating-point values
The ess/2 format, introduced in 0.20.0, adds Binary64 for finite IEEE-754 values. Use it
when a source contract requires binary floating-point rounding and signed zero:
format: ess/2
system: sample
version: v1
domains: [sample.settings]
domain: sample.settings
types:
- name: sample.settings.Ratio
kind: newtype
of: Binary64
Integer retains exact signed integer identity; Decimal retains its patterned
string representation. Neither implicitly assigns to Binary64. Map keys cannot
be Binary64. Format 1 refuses the new primitive, including fields in headerless
fragments. Authored numeric predicates may compare Binary64 to numeric literals,
using the existing Number predicate rules; that comparison does not construct a
floating value.
Binary64 values are executed through
normalization recipes (ess-normalization/5).
Standalone structural Rust/Go codecs and whole-system targets refuse Binary64;
TypeScript structural output reports the finite codec obligation. A conformance
suite carries a Binary64 in an entity's fields, a command's input and response, an
event's and an error's fields, and a view's fields, from ess-conformance/62. An
authored scenario writes a Binary64 literal below 2^53 in magnitude; a larger one
is refused as ESS-AUTHOR-015, because a view row carries no type and compares an
integer exactly.
Adding a Binary64 type to a model selected in full therefore requires checking
every intended target's support before adopting it.
Numbers are finite
Decimal and Binary64 hold finite values only. A Decimal is its patterned decimal string, and
a JSON number has no token for NaN or an infinity; a Binary64 stores a finite bit pattern, and
NaN and both infinities refuse construction and decoding. So a NaN or an infinity never reaches
the domain, and an outcome that refuses one cannot be reached through the specification.
Refuse such a value where it arises: in the decoder or adapter that turns a non-JSON or binary
source into the command's input, and test that refusal at that seam. Over HTTP, a number the
request cannot carry is the binding's malformed refusal
(refusals). Do not declare an outcome
for a non-finite input, or declare it external:, which no input decides.
A scenario cannot write one either: .nan, .inf or -.inf where a value is expected is refused
as ESS-AUTHOR-015, naming the number and saying that the type has no non-finite values.