Back to Blog

RIDDL 2.0.0-rc.1 Released

RIDDL 2.0.0-rc.1 is now available.

release riddl compiler

RIDDL 2.0.0-rc.1 is now available. This release of the RIDDL compiler and language tooling includes the following changes:

What’s New

This is a release candidate. It is a real, immutable artifact, but nothing resolves to it by default — you have to ask for it by name:

brew install ossuminc/tap/riddlc@rc          # not `riddlc`
npm install @ossuminc/riddl-lib@rc           # not `latest`
"com.ossuminc" %% "riddl-language" % "2.0.0-rc.1"

Please exercise it against real models and report anything that looks wrong. The whole point of a release candidate is to find what our test suites and the two model corpora did not.

Migrating from 1.x? Start with the 1.x → 2.0 Migration Guide. It covers every breaking change below with before/after examples.


Executive Summary

RIDDL 2.0 makes models generatable. 1.x let you describe a system; 2.0 makes that description precise enough for a code generator — or an AI — to act on without guessing.

Three shifts get you there:

  • A unified processor model. Every processor — context, entity, projector, repository, adaptor — can now declare ports and be wired into a streaming pipeline. Streaming stops being a separate world you opt into.
  • Meaning where there was convention. A record is data, not a message. A projector handles events. A function is pure. Things that were merely usually true are now checked, so a generator can rely on them.
  • Expressions instead of prose. Conditions, comparisons, constructors and function calls are now structured values rather than opaque strings — the difference between a model a generator can compile and one it must interpret.

Around that: 40-odd validation rules that catch modelling mistakes early, a build on sbt 2 and Scala 3.9, and a JSON surface that round-trips exactly so AI tools can read and write models losslessly.

Eleven breaking changes, all with clear migrations. Nine deprecated syntax forms still parse and warn, so a 1.x model keeps working while you migrate.


Language Changes

Syntax summaries. Each is elaborated in the Migration Guide.

New syntax

Construct Form What it gives you
Generic processor processor P as <shape> is { … } One keyword for every streaming component
Context intention application context C is { … } Says what a context is for — and gets checked
Ports anywhere inlet in is command C in any processor Wire entities and repositories directly into pipelines
Version version V is "1.2.3" at nine scopes Versioning inherited down the model tree
Copyright copyright C is "© 2026 …" at nine scopes Attribution a generator can emit into headers
yields command Place yields event Placed is { … } Declares the response, so flows are traceable
yield statement yield event Placed Replaces reply; clearer intent
Boolean expressions when a.x == b.y and c.z != d.w Real conditions, not prose
Comparisons == != < <= > >= Type-safe, reference-to-reference
Constructors command Place(sku = s, qty = q) Build a message from values
Function calls call function Total(items = i) Compose behaviour
foreach foreach item in order.lines do … end Bounded iteration with a local
get get input Form.field Read a value from a UI input or state
put / return put v to output O · return v Explicit outputs
prompt(…) value when prompt("the customer looks unhappy") Marks what an AI decides, distinct from a literal
Refusal step step refuse … because "…" Model the unhappy path in an epic
Figma reference figma "FILEKEY" node "12:34" Ties a UI element to its design
Attachments attachment Doc is text/plain in file "…" Carry docs and ULIDs with a definition
Comment before ??? // what goes here then ??? Documented stubs

Deprecated but still parsing

Each emits [deprecated] and keeps working. All are removed in a future major.

Old New Why
state S is record R state S of record R is { … } is introduces a body everywhere else
send … to inlet X send … to outlet X You send from an outlet
reply yield Names what actually happens
prompt "…" statement prompt("…") value A prompt is a value, usable anywhere
bare string when "…" when prompt("…") Says explicitly that an AI decides
source/sink/flow/merge/split processor P as source One keyword, shape ascribed
gateway/service/external/wrapper options context intention prefix An intention, not an option
Abstract type Anything Says what it means
anonymous nebula module M is { … } Named, so it can be referenced

Breaking Changes

Eleven. Each says what breaks, what to do, and why it is worth doing.

1. A record is data, not a message

RecordRef no longer sits under the message hierarchy. A record is a data shape; commands, events, queries and results are messages.

Migration: stop using a record where a message is expected — a handler clause, a send, a tell. Use the message kind you mean.

Why: a generator needs to know whether something crosses a wire. Conflating the two meant it had to guess, and guessed wrong on persistence and transport.

2. A state’s data type must be a record

state S of record R — not an inline aggregate, not an arbitrary type.

Migration: name the aggregate as a record, then reference it.

Why: state is persisted. A named record gives the generator a schema to target and gives you one place to change it.

3. UI elements require an application context

input, output and group may only appear in a context declared application context C is { … }.

Migration: add the application intention to any context holding UI.

Why: UI generation targets a different runtime from service generation. The intention says which, instead of leaving it to be inferred from contents.

4. Epic interactions must target an application context

A user interaction in an epic may only reach a context with the application intention.

Migration: route interactions through the application context that owns the UI, rather than at a service or domain directly.

Why: epics describe what a user does. A user meets your system at its application boundary; modelling them poking a service directly hides a layer that has to exist.

5. Comparisons are reference-to-reference only

when order.total > limit.value — comparisons take two references. Numeric and string literals are not permitted as operands.

Migration: name the value as a constant or a field and compare against that.

Why: a literal in a comparison is a magic number with no name, no type and no single place to change it. Naming it makes the model self-documenting and the comparison type-checkable.

6. Refusals must precede effects

Within a statement list, error and require come before any send, tell, yield, set or morph.

Migration: move refusals to the top of the clause.

Why: a refusal after an effect is unreachable — the effect already happened. Ordering makes the guard clause real rather than decorative.

7. Adaptors need an on other clause

An adaptor must say what it does with messages it does not specifically handle.

Migration: add on other is { … } — even just error "unsupported".

Why: an adaptor is a boundary. Silence at a boundary is how messages vanish without a trace.

8. Projectors are event-only

A projector may handle events. Not commands, not queries.

Migration: move command handling to an entity, query handling to a repository.

Why: a projector maintains a read model from a stream of facts. Letting it take commands made it a second entity with no lifecycle.

9. Functions must be pure

Effect statements — send, tell, yield, set, morph, become — are banned in function bodies.

Migration: move effects to the calling handler; let the function compute and return.

Why: a pure function is safe to call from a condition, memoize, test in isolation and generate as a plain method.

10. Every processor is a streamlet

Contexts, entities, projectors, repositories and adaptors all carry inlets/outlets and an optional ascribed shape. Exactly one connector may attach to any port.

Migration: fan-in and fan-out are modelled by declaring multiple ports, not by attaching several connectors to one. The old shape keywords still parse.

Why: one connector per port means a pipeline’s topology is unambiguous, so it can be generated, drawn and reasoned about.

11. The JSON wire schema carries ordered contents

A container’s children travel in one contents array of $kind-tagged entries; with { … } likewise through metadata.items. ProjectorDto.repository is now repositories. Entries may carry $at: [offset, endOffset] source locations.

Migration: usually none — the old per-kind arrays are still read and report a deprecation. Only the writer changed.

Why: per-kind buckets could not express the order definitions were written in, so a comment at the top of a file came back at the bottom.


New Features

Modelling

Version and copyright as first-class definitions. Declare version and copyright at any of nine scopes; each resolves by nearest declaring ancestor, composing root-to-leaf. A generator can stamp accurate headers without you repeating yourself.

Repositories at domain scope. A repository shared by several contexts no longer has to live inside one of them.

Connectors at domain scope. Wire pipelines that cross context boundaries where the crossing is visible.

Modules. module M is { … } is a flat, named collection of any top-level definition — a unit you can reference and import. The anonymous nebula form is deprecated in its favour.

A predefined Riddl standard module. Ships Anything, plus the terminators BottomlessPit (a sink for anything) and ForeverEmpty (a source of nothing), so a partial pipeline can be made complete without inventing placeholders.

BAST imports. import domain X from "file.bast", with selective and aliased forms, so a large model can be split across compiled units.

Expression language

Boolean expressions in when, require and invariant, with and, or, not and type-safe comparisons. Constructors to build a message from named or positional arguments, checked for arity and type. Function calls as values. foreach for bounded iteration. get to read from an input or state. put/return for explicit outputs. prompt(…) to mark a value an AI decides — compatible with any field type, and distinct from a literal.

Validation

Roughly forty new checks. The ones you will notice:

  • Use-case witnessing — every step of an epic is traced against the structure that would carry it, so an epic that describes an impossible interaction says so.
  • Streaming completeness — unattached ports, unreachable tell targets, over-parallelised pipelines.
  • Saga discipline — steps confined to their domain, one failure point per do-block, compensate and parallel options.
  • Handler completeness — entity lifecycle, adaptor coverage, command→event.
  • Hygiene — shadowed on-clauses, inconsistent nested terms, duplicated single-valued metadata, include hygiene, domains with no author.
  • Translatability prediction (A40) — flags vague or arbitrary interactions an AI is unlikely to render faithfully, but only where there is prose to judge.
  • Figma drift (A42) — an opt-in check that a referenced design node still exists.

Suggestions on every message. riddlc advise (or --provide-tips) attaches a concrete next step to each diagnostic. Default output is unchanged.

Tooling

Fully reflective JSON. A model written to JSON and read back reproduces its source exactly, order included — 63 divergent fixtures to 0, and the external corpus from 185/187 to 189/189 on both round-trip identity and validation parity. This is what lets an AI safely read, edit and write models.

Source locations survive the JSON round trip, so diagnostics on a JSON-authored model point at real lines instead of empty(1:1->1).

BAST reaches its 2.0 format with a restarted revision counter.

riddlc info reports the source commit, so you can tell exactly what a binary was built from.

Build: sbt 2.0, projectMatrix layout, Scala 3.9.0-RC4, sbt-ossuminc 3.1.0.


Bugs Fixed

Correctness

  • A projector’s updates repository clause vanished on prettify — the reference lived in contents but nothing emitted it, so a round trip silently dropped the link between a projector and its store.
  • A body-less state lost its metadatastate S of record R with { briefly … } dropped its documentation, because the metadata was emitted only alongside a closing brace that a body-less state does not have. 41 states across riddl-models were affected.
  • A schema’s types were reported unused — schema references live in fields, not contents, so nothing counted them.
  • Nested send/tell references went unresolved, producing false positives in message-flow analysis.
  • Two same-named ports on different processors were conflated, reporting one connector each as two connectors on one port.
  • attachment ULID is "…" could not be parsed at all. The construct had no fixture and no test anywhere, so a documented piece of syntax was unreachable.
  • command Checkout() was unchecked against a message that has fields.
  • show … to … steps were unvalidatable — the parser produced the empty relationship the validator rejects.
  • A comment could not introduce a ??? stub, so a starter template had to drop its own explanation to parse.
  • A refusal-only command clause was reported incomplete, and the warning was silenced by adding unreachable code after the refusal — rewarding exactly the shape to avoid.
  • Same-named siblings of different kinds passed silentlytype Thing beside entity Thing made Ctx.Thing ambiguous.
  • Prettify emitted deprecated syntax, so canonicalising a clean model introduced deprecation warnings.
  • Prettify emitted String(7), which does not parse.
  • A negative integer discarded its sign.
  • Definition keywords were accepted as bare identifiers.

Diagnostics

  • Unreadable input threw a raw exception — a missing file printed a Java stack trace at the user. Load failures are now located messages.
  • Parse-time warnings and deprecations were dropped on a successful parse, so they appeared under validate and nowhere else. They now surface under every command.
  • The error reporter could crash on a missing }, replacing the real parse error with an internal exception.
  • Deprecations had no distinct label and were invisible in grouped output.

Quality

  • Sixty-two test cases had never run. Four suites were dead — a fixture lambda on a plain spec, abstract suites with no concrete subclass, constructor parameters preventing discovery. Reviving them exposed 24 real failures, since fixed. The three failure modes are now documented so they are recognised rather than rediscovered.
  • The corpus round-trip suite asserted 0 mustBe 0 for months, driving off stale .bast artifacts whose every read failed and was skipped. It now drives from source and reports failures.
  • A grammar fixture had been failing CI for two days, missing from the validator’s skip list.

Known Limitations

  • The Homebrew riddlc@rc line and the npm rc dist-tag are built and unit-covered but have never been exercised against a live registry. If brew install ossuminc/tap/riddlc@rc or npm install @ossuminc/riddl-lib@rc misbehaves, that is the most likely cause.
  • GBNF is generated and statically linted but has never parsed RIDDL. Treat it as unverified.
  • Include fragments are not covered by the standalone grammar validators (EBNF, GBNF); only entry points are.
  • isEmpty is comment-tolerant but nothing yet reports a container that holds only comments. The predicate is in place; the check is not.