Skip to content

2.0.0-rc.1

Pre-release
Pre-release

Choose a tag to compare

@reid-spencer reid-spencer released this 30 Jul 00:32

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 unlink riddlc && 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"

The Homebrew line conflicts with stable riddlc on purpose — an RC is meant to
BE the riddlc on your PATH while you exercise it. To go back:
brew unlink riddlc-rc && brew link riddlc.

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

  • @ossuminc/riddl-lib publishes to GitHub Packages, not npmjs.com. If
    npm install @ossuminc/riddl-lib@rc cannot find it, you need the scope mapped:
    npm config set @ossuminc:registry https://npm.pkg.github.com.
  • 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.