Skip to main content
Refusals, fields and invariants | ESSWhy a refusable command declares outcomes, what an invariant may read, and how enums, instants, text, JSON and Binary64 fields are declared.ESShow-toesshow-toadopterdevelopertry-spec-driven-developmentspecify

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.