RIDL
One platform, four languages, one grammar. RIDL is a family of languages for modeling component-based reactive systems: a shared vocabulary layer and three description languages over it, sharing one grammar, one toolchain, and one intermediate representation (IR).
The family
| Language | Expands to | Describes | Audience |
|---|---|---|---|
| typl | type language | data — types, ranges, units, constants | data architects |
| ridl | reactive interface description language | interactions at every boundary — system, person, world (signal / event / command / query / fixed) | service, HMI, and sensor/actuator teams |
| rmdl | reactive model description language | behaviour — synchronous / functional compute | control / algorithm engineers |
| rsdl | reactive system description language | architecture — components, wiring, deployment | integrators |
The dependency lattice is typl ← {ridl, rmdl} ← rsdl: typl is the only
standalone member, and rsdl is the apex that composes the others. rxdl is not a
language — it is a file kind that lifts restrictions, plus spellings over ridl's
interaction families (ADR-0012).
What is built
Three layers of the family have a working toolchain in this repository:
- typl — the vocabulary layer: compiler,
ridl fmt, an LSP server, and a VS Code extension. - ridl — the interface layer over it: the five interaction kinds,
timing annotations, contracts, interfaces and services, a TypeScript code
generator beside the Rust one, and
ridl diff. - rsdl — the architecture layer: components, the system,
distributions and deployments, checked against the rsdl reference and lowered
to the IR beside the package IR, and
ridl diffat the system. See Describing a system.
ridl-rt is the no_std runtime library that a
generated ridl package links and a runtime implements: identity, time
and the envelope, samples, the payload traits, the interaction descriptors,
the ports, and the contract and transport errors. It has no dependency in any
feature combination. ridl-loopback implements its ports in
process, and the Rust crate ridl-transport-ws serves the WebSocket binding:
it carries the observe and call ports to a second process, as the providing
side of a frame session on a connection the program that embeds it accepted.
The TypeScript package @driftsys/ridl-transport-ws
(packages/ridl-transport-ws/) is the consuming side of that binding, for Deno
and the browser. No other transport is built. The three payload codecs,
FlatBuffers, proto3 and podbuf, are built: the Rust backend emits a codec for
the payload types of a package for each one the manifest names, and
ridl build --emit podbuf writes the C header of the podbuf layout.
ridl build --emit writes Rust source, TypeScript source, a proto3 schema, a
FlatBuffers schema, or the IR as JSON, with the lowered rsdl system beside it.
rxdl and rmdl are specified but not built. Their language references are complete enough to design against, but no compiler accepts them and nothing in this book describes them as usable. rmdl is parked, with no implementation scheduled; rxdl keeps only its unrestricted profile, narrowed to types, interfaces and wiring, scheduled in step 2.
There is no runtime you can run a contract over from a command in this
book. Of the transport bindings, the WebSocket one is implemented, by
ridl-transport-ws, which a program embeds; the delivery semantics of the
others are specified and not implemented. Provider-side contract
enforcement is: the generated serve evaluates a require and an ensure
clause and settles the contract error, and the Rust backend's tests run that
over the in-process runtime below. Nothing the compiler emits links a runtime,
so none of it is reachable from a command in this book. ridl-rt (above) is the
library a runtime implements, not a runtime itself — it performs no I/O and links no codec. The
repository does hold one runtime, ridl-loopback: an in-process reference that
carries values between a provider and a consumer in one program, with no
transport, no wire format and a clock a test advances by hand. Nothing the
compiler emits links it yet, so it is reached only from a program written
against it by hand.
Getting started walks through what you can run today.
Where the specifications live
This book is the reader's entry point. The normative documents live in the repository:
- Specifications —
docs/specification/: the normative home of the family overview, the family general form (the surface rules every language shares), the five language references, the expr-core specification (the shared grammar ofrequire/ensureclauses), and the frame specification (what crosses a runtime boundary per interaction kind). Every one of them except the family overview and the general form is reproduced in this book's Language reference section — typl, ridl, rxdl, rmdl, rsdl, expr-core, frame. - Work in progress —
docs/wip/: the pre-ADR concept note and the authoring-skill outline. - Decisions —
docs/decisions/: the architecture decision records — ADR-0002 (module system), ADR-0004 (implementation sequencing and stack), ADR-0005 (agent enablement), ADR-0006 (walking-skeleton execution), ADR-0007 (E1 execution), ADR-0008 (E2 execution), and ADR-0009 (toolchain and gate parity). - Technotes —
docs/technotes/: informative architecture notes, which bind nothing. - Roadmap —
docs/ROADMAP.md: the forward plan — the two steps the 2026-09-12 re-scope sets, the parked blocks with the observation that reopens each, and the milestone summary. What has already shipped is indocs/archive/roadmap-landed-record.md. - Archive —
docs/archive/: superseded documents, and the plans of the epics that have landed.
Browse them on GitHub: https://github.com/driftsys/ridl/tree/main/docs.
Status
The typl and ridl references are no longer drafts; the version and status of
every specification are in the family overview. The toolchain is
released as binaries on GitHub and as crates on crates.io. This book describes the
main branch, which can be ahead of the newest release;
Getting started says how to build it from a clone.
Reading guide
The specifications are separate by design — one learnable whole per audience.
Each path below is a route through the same four languages, not a different set
of them. Pick the path for your role; each links to the full reference. The
family overview
(docs/specification/ridl-family-overview.md)
is the map that ties them together.
typl, ridl and rsdl have a toolchain. A path below that ends in rxdl or rmdl ends in a specification you can design against but cannot compile — see what is built.
- Data architect — typl, end to end. It stands alone.
- Service / bus SSOT engineer — typl §1–§10, then ridl end to end.
- UX / frontend engineer — typl §1–§10, ridl's core semantics and the presentation and intent families, then rxdl (specified, not built).
- Sensor / actuator engineer — typl §1–§10, ridl's acquisition and control families and their correspondence obligations, then rxdl §5 (specified, not built).
- Control / algorithm engineer — typl §1–§10, ridl §3/§9, then rmdl (specified, not built).
- Integrator / architect — everything above at survey depth, then rsdl, with Describing a system.
- Auditor / safety assessor — the family overview's shared doctrines, then typl's keyword registry and evolution model, ridl's error strata and services, and the diagnostics table of each reference.
Writing your first .ridl file rather than reading a specification? Start with
First steps, then Getting started.
For the motivation and the big picture, read the concept note
(docs/wip/ridl-family-concept.md);
for the shared surface rules across every profile, the general form
(docs/specification/family-general-form.md).
Building the toolchain? Start with
the roadmap and
the decision records.
First steps
This chapter takes you from nothing to a checked package and a generated Rust crate, in five steps. It states each step once and links to the chapter that owns the detail. Read Getting started next for the language itself.
The commands below do not run a contract. ridl checks your declarations and
generates code from them. The only runtime in this repository is
ridl-loopback, which runs inside one program; no command here starts it.
1. Install the toolchain
On Linux and macOS:
curl -fsSL https://raw.githubusercontent.com/driftsys/ridl/main/install.sh | bash
ridl --version
On Windows, run install.ps1 from PowerShell instead. The environment variables
that choose the install directory and the release, and the option of building
from a clone, are in
Installing the toolchain. The
editor and the agent tools are in
Editor and agent setup.
2. Create a package
ridl new demo
ridl new creates the directory demo and writes two files into it. Use
ridl init to write the same files into a directory that already exists, and
--workspace to write a workspace root with one member instead of a standalone
package. The flags and the cases where the command refuses are in the
CLI reference.
demo/ridl.toml is the manifest. It names the package and its version, and it
ends with a commented-out [backend.rust] table that step 5 uses.
demo/demo.ridl is the source file:
package demo
/// Road speed.
type Speed: km/h [0.0..250.0 step 0.5]
/// Reports speed.
interface Hello {
/// Current speed, every 10 ms.
signal speed: Speed @10ms
}
The first line names the package. A package name must equal the directory path
below the workspace root, and every file in the directory declares the same
name. Speed is a scalar type with a unit and a range, and Hello is an
interface with one signal whose contract requires the provider to publish it
every 10 ms. The typl reference and the ridl reference
define every form.
3. Check it
ridl check demo
ridl check reads the package and prints the diagnostics it draws. It exits 0
when none is an error and 1 when one is. To see an error, change Speed to
Sped in the signal and run the command again: it prints TYPL-011 (unknown
type name) with the file, the line and the column. The ridl check
reference lists the flags and the exit codes, and
the lints chapter describes the warnings that you can raise or
lower.
4. Format it
ridl fmt --check demo
ridl fmt rewrites the sources into the canonical layout, and --check writes
nothing and exits 1 when a file differs. The scaffold already conforms.
5. Build it
ridl build demo
ridl build writes the Rust crate under out in the current directory:
Cargo.toml, lib.rs and one file for the package. With no [backend.rust]
table in the manifest, the crate holds the domain types only. To also generate
the FlatBuffers codec, the descriptors and the interaction face, uncomment the
table in demo/ridl.toml:
[backend.rust]
encodings = ["flatbuffers"]
and build again. Other targets, such as TypeScript and the IR, are chosen with
--emit; see ridl build.
Where to go next
- To learn the language, work through Getting started.
- To link the crate that step 5 wrote from a program and run it, read From a package to a running program.
- To use the crate in every form, read Using the generated Rust code.
- To describe components, wiring and deployments, read Describing a system.
- To change an interface after you published it, read Evolving an interface.
- To see what stays fixed across the 0.10.x releases, read Stability.
From a package to a running program
First steps ends at a generated crate. This chapter goes on: it links that crate from a Rust program and runs the program. It states each step once and links to the chapter that owns the detail.
What runs here. The program runs in one process. The only runtime in this
repository is ridl-loopback, which connects the faces of one program to each
other; no network runtime exists, so nothing below delivers a message between
processes, measures a timing bound, or enforces a contract on a remote provider.
Using the generated Rust code says what a call does over
ridl-loopback.
The schema
The scaffold of First steps has one signal. A program that
shows every kind of interaction needs a schema with more, so this chapter uses
the worked example examples/cabin of the repository. Its schema declares a
signal, an event, a command and a query:
package veh.cabin
/// Cabin air temperature, in degrees.
type Temperature: integer [-40..85]
/// A percentage of full scale.
type Level: integer [0..100]
/// An averaging window, in milliseconds.
type Window: integer [0..100000]
/// The average a query replies with. Its range is wider than `Level`'s,
/// so a reply is not constrained to the scale the command takes.
type Average: integer [0..1000]
/// The health of a cabin subsystem.
enum Health {
/// The subsystem works normally.
OK = 0
/// The subsystem works, with a fault that needs attention.
WARN = 1
/// The subsystem does not work.
FAIL = 2
}
/// Raised when a subsystem leaves `OK`.
struct Warning {
/// The warning code, as a percentage of full scale.
code: Level
/// The health the subsystem moved to.
health: Health
}
/// The cabin's own interface: a signal, an event, a command and a query, so
/// the proof covers every interaction kind that carries a payload.
interface Cabin {
/// The current cabin air temperature.
signal temperature: Temperature @10ms
/// Sent when a subsystem leaves `OK`.
event warning: Warning @[100ms..1s]
/// Sets the cabin level, below 100.
command setLevel(level: Level) @[..50ms] [
require level < 100
]
/// The average level over the window, in milliseconds.
query average(window: Window): Average @[..200ms] [
require window > 0
ensure result >= 0
]
}
The manifest of that example names the encoding that the face needs, with the
[backend.rust] table that step 5 of First steps shows:
[package]
name = "veh.cabin"
version = "1.0.0"
[backend.rust]
encodings = ["flatbuffers", "proto3", "podbuf"]
The face needs one of the encodings; the example lists all three so that the
program can also encode a value with podbuf. Do the same in your own package:
uncomment the table in ridl.toml, and the build writes the codec, the
descriptors and the face.
Generate the crate
ridl build examples/cabin --emit rust --out-dir generated
The crate is named after the package, with each . replaced by _, so this one
is veh_cabin. Using the generated Rust code
lists what it holds.
Link the crate
A Rust package names the generated crate by path, ridl-loopback for the
ports, and ridl-rt for the items the program uses from it. This Cargo.toml
fragment is an illustration and is not compiled; the program that just demo
builds has the same three dependencies in examples/cabin/consumer/Cargo.toml:
[dependencies]
veh_cabin = { path = "generated", features = ["std"] }
ridl-loopback = "0.14.0"
ridl-rt = { version = "0.14.0", features = ["std"] }
The program imports CatalogRef, Interface and Provenance from ridl-rt
and calls ridl_rt::task::noop_waker, which exists only under the std feature
of ridl-rt. The std feature of the generated crate is on by default and is
named here so the dependency is explicit. The features of each crate are in
Cargo features.
Write the program
The face is a set of types that the program imports. prelude brings the traits
of the face into scope; without it, Client::new does not compile:
use api::cabin;
use api::cabin::prelude::*;
use ridl_loopback::Loopback;
use ridl_rt::contract::{CatalogRef, Interface};
use ridl_rt::encoding::Podbuf;
use ridl_rt::payload::{Payload, Ref};
use ridl_rt::sample::Provenance;
use veh_cabin::veh::cabin as api;
A runtime gives out ports, and each face binds one. Loopback::new takes the
catalog that the face was generated from, which CATALOG holds in the program:
let rt = Loopback::new(CATALOG);
let mut publisher = cabin::Publisher::new(rt.attach());
let mut client = cabin::Client::new(rt.attach());
let mut blocking_client =
cabin::blocking::Client::new(rt.attach()).with_timeout(CLIENT_TIMEOUT);
let mut handler = rt.handler();
let mut provider = Cabin {
levels: Vec::new(),
average: api::Average::new(7).expect("7 is inside Average's range"),
};
The first interaction is the signal. The publisher side publishes a value and
commits it, and the client side reads it. new checks the typl range of
Temperature and returns an error for a value outside it:
{
publisher
.temperature(api::Temperature::new(21).expect("21 is inside Temperature's range"))
.expect("publish temperature");
publisher.commit();
let sample = client.temperature().expect("read temperature");
assert_eq!(sample.value.get(), 21);
assert_eq!(sample.provenance, Provenance::Live);
println!("signal ok {}", sample.value.get());
}
A command and a query need a provider. The program implements the trait that the face generates, and the face calls it only with a request that passed its checks:
struct Cabin {
levels: Vec<i64>,
average: api::Average,
}
impl cabin::Provider for Cabin {
fn set_level(&mut self, level: &api::Level) {
self.levels.push(level.get());
}
fn average(&mut self, _window: &api::Window) -> api::Average {
self.average
}
}
The rest of the program, which sends the command and the query through the async client and through the blocking client, is explained in Using the generated Rust code.
Run it
From a clone of the repository, just demo generates the crate, runs clippy,
the no_std checks and the documentation build on it, and then runs the
program. To run only the program, generate the crate as above and then:
cargo run --manifest-path examples/cabin/Cargo.toml -p consumer
The program prints one line per round trip. Each line carries the value that the round trip moved, and the program exits with a non-zero status when a value is not the expected one. The last line comes from a value that goes through the generated podbuf codec alone, without the runtime: the program encodes it, verifies the bytes and decodes it again:
signal ok 21
event ok 5
command ok 42
query ok 7
blocking command ok 43
blocking query ok 9
podbuf round trip: 21.5
Where to go next
- To call the interface in every form, and to implement a provider, read Using the generated Rust code.
- To connect a face to a transport of your own, read Writing a port.
- To change an interface after you published it, read Evolving an interface.
Getting started
A practical introduction to ridl, the interface description language of the RIDL family. It is written for engineers who describe the contracts between software components — in a vehicle, or in any component-based reactive system.
Every ridl code block in this chapter is compiled by the repository's test
suite against the toolchain in this repository, and must draw no diagnostic
beyond the ones its own fence names. If a block is shown here, it compiles.
The prose around the blocks is not machine-checked; where it describes
behaviour rather than syntax, read
the note below first.
What ridl is
ridl answers one question about a service: what does it produce, consume, and guarantee?
A .ridl file describes an interface — not an implementation. It does not
say how a service works internally, only what it exposes at its boundary. That
is what makes it usable as the single source of truth between teams,
components, and tools.
ridl is transport-neutral by design: the same contract is intended to bind to SOME/IP, gRPC, DDS, MQTT, or AIDL without modification. The transport bindings are specified in the ridl language reference, Appendix B. They are not implemented yet. What the compiler emits today is listed under What the compiler produces.
What "the contract requires" means in this chapter
Several sections below describe what happens when a contract runs: that a
subscriber is delivered the last value, that an occurrence past its staleness
bound is discarded, that a provider checks a require clause before your code
sees the call. Every one of those is a requirement the specification places on
a runtime and its transport bindings, and no command in this chapter runs one.
The repository does hold an in-process runtime, ridl-loopback, which carries a
last value from a provider to a consumer inside one program; it measures nothing
against a staleness bound, and nothing the compiler emits links it. So what you
run below neither delivers a message nor enforces a contract.
The sentences are written in the present tense because they describe what the
contract means, which is the thing you are designing against. They do not
describe behaviour you can observe today. What you can observe today is the
toolchain: it checks your declarations, generates data types, samples your
require clauses, and classifies changes between versions. Where a comment
inside a listing says a binding "must discard" an occurrence, read it as what
the contract demands of a binding, not as something running.
ridl is one language of five
ridl does not define types. It references them. The vocabulary layer is a
separate language, typl, and the two are used together: a .ridl file
accepts every typl declaration in addition to its own interaction
declarations.
| Language | Describes | Status in this repository |
|---|---|---|
| typl | data — types, ranges, units, constants | built |
| ridl | system interactions — signal, event, command, query, fixed | built |
| rxdl | domain spellings — present, notify, measure, detect, actuate | specified, not built |
| rmdl | behaviour — functions and reactive models | specified, not built |
| rsdl | architecture — components, wiring, deployment | built |
This chapter covers typl and ridl; Describing a system covers rsdl. Where this chapter mentions rxdl or rmdl, it is describing a plan.
Installing the toolchain
Released binaries of ridl and ridlc are attached to the GitHub Releases
tagged editor-v<version>. On Linux and macOS, install.sh downloads the
newest one, verifies its SHA-256 checksum, and installs it. It supports x86_64
and aarch64 Linux and x86_64 and arm64 macOS, and exits with unsupported platform on any other:
curl -fsSL https://raw.githubusercontent.com/driftsys/ridl/main/install.sh | bash
On Windows, run install.ps1 from PowerShell. It installs the x86_64 build:
irm https://raw.githubusercontent.com/driftsys/ridl/main/install.ps1 | iex
Both scripts read the same environment variables:
| Variable | Effect |
|---|---|
RIDL_INSTALL_DIR | Directory to install into. Default ~/.local/bin (%LOCALAPPDATA%\Programs\ridl on Windows). |
RIDL_INSTALL_BINARY | ridl (default) or ridlc. |
RIDL_VERSION | The release tag to install, instead of the newest editor-v* one. |
When the install directory is not on your PATH, the script prints the command
that adds it.
This book describes the main branch, which can hold features the newest
release does not. To get everything this book describes, build the two binaries
from a clone of this repository:
cargo build --release
That produces target/release/ridl — the command you will use — and
target/release/ridlc, the compiler underneath it. Put ridl on your PATH,
or call it by path. cargo install --path crates/ridl installs ridl alone.
Editor and agent setup
VS Code. The RIDL extension is published to the VS Code Marketplace and to
Open VSX under the name "RIDL". It highlights .typl, .ridl and .rsdl
files and runs the language server. It bundles a ridl binary and starts it as
ridl lsp, so it needs no separate install. The command RIDL: Install ridl to
PATH copies the bundled binary to ~/.local/bin
(%LOCALAPPDATA%\Programs\ridl on Windows). When that directory is not on your PATH, the command warns and
offers a command that adds it. The setting ridl.serverPath names a different
ridl binary to run, for both ridl lsp and ridl mcp; empty, the default,
uses the bundled binary, or ridl on PATH when none is bundled. The setting
ridl.trace.server (off, messages or
verbose) traces the traffic between the editor and the server. The
extension README
lists the features.
Other editors. ridl lsp serves the Language Server Protocol over stdio, the
only transport it supports. Configure your editor's language-server client to
run ridl lsp for .typl, .ridl and .rsdl files. ridl lsp
in the CLI reference states the accepted flags and exit codes.
Agent hosts. ridl mcp serves the Model Context Protocol over stdio, with
read-only tools that check, explain and inspect a workspace. It takes no flag.
An MCP-aware agent host starts it as a plain command and arguments:
{ "command": "ridl", "args": ["mcp"] }
Each host wraps this in its own format, in its own file. Claude Code reads it
from the project's .mcp.json (or claude mcp add ridl -- ridl mcp writes it):
{
"mcpServers": {
"ridl": { "command": "ridl", "args": ["mcp"] }
}
}
Codex reads it from ~/.codex/config.toml:
[mcp_servers.ridl]
command = "ridl"
args = ["mcp"]
The command ridl must be on the PATH the host uses. The RIDL extension
needs VS Code 1.109 or newer, and registers ridl mcp automatically, so GitHub
Copilot in VS Code needs no configuration.
The authoring skill. Each editor release attaches
ridl-authoring-<version>.zip, a skill that teaches an agent to write and
review RIDL and to check its work with the ridl MCP server. For Claude Code,
unpack it into ~/.claude/skills/ or into a project's .claude/skills/; for
Codex, into ~/.codex/skills/. The VS Code extension already contains the
skill for GitHub Copilot.
ridl mcp lists the tools, and the
ridl-mcp README
documents their input and output schemas.
A workspace
A package is a directory, and every package carries a ridl.toml manifest.
A workspace groups several packages under a root manifest that lists them.
The tutorial builds this layout:
tutorial/
├── ridl.toml the workspace root
└── veh/
├── common/
│ ├── ridl.toml
│ ├── types.ridl the shared vocabulary
│ └── scalars.ridl
├── cluster/
│ ├── ridl.toml
│ └── cluster.ridl the interfaces
├── identity/ annex 1
├── powertrain/ annex 2
├── body/ annex 3
└── dms/ annex 4
The root manifest lists every member. Start with the two the tutorial builds first, and add the annex packages as you reach them:
[workspace]
members = ["veh/common", "veh/cluster"]
Each member names itself and its version:
[package]
name = "veh.common"
version = "1.0.0"
The package name mirrors the directory path below the workspace root, and every file in the directory declares that same name. A mismatch is a hard error (TYPL-002).
A package that lives in another repository is named in an [imports] table of
the root manifest, under the key its ridl.toml declares as its name:
[imports]
"veh.common" = "https://registry.example.com/veh/common@v1.0.0"
ridl check fetches it once into ~/.ridl/cache, pins it in ridl.lock, and
from then on reads the cache until vendor/ holds the unit;
import veh.common.Speed then resolves as it does for a member. ridl vendor
copies each unit into vendor/ beside ridl.toml, after which ridl check
reads that copy first, so a ridl check --frozen on a machine with no network
and no cache still builds; ridl vendor --check verifies that directory in CI.
A remote unit is a read-only dependency: its errors fail the check, its warnings
are not shown, and ridl build generates it beside your own packages. The
Remote units and ridl vendor chapter is the guide, and the
CLI reference says what each command writes.
You can run ridl from the workspace root or from inside a member. A command
started in veh/cluster finds the root manifest that lists veh/cluster, so
the member still sees veh.common, and the root's settings apply to it. The
check then reports only the diagnostics of files in veh/cluster.
To start a project of your own, ridl new scaffolds one. Run it outside the
tutorial workspace, for example from the directory that holds tutorial/, because it refuses a directory
that sits inside an existing unit. It creates the directory and writes a
ridl.toml and a small .ridl file that already checks:
ridl new demo
ridl check demo
ridl init does the same inside a directory that exists, and --workspace
writes a workspace root with one member instead of a standalone package. Both
refuse, and write nothing, when a file they would write exists or when the
directory sits inside, or above, another unit. The CLI reference
lists the flags and the refusals.
The manifest ends with a commented-out [backend.rust] table. Until you
uncomment it, ridl build --emit rust generates the types only; with it, the
build also generates the FlatBuffers codec, the descriptors and the interaction
face. With --workspace, the table is in the workspace root's manifest, not the
member's.
Your first vocabulary
Create veh/common/types.ridl:
package veh.common
/// Road speed of the vehicle.
type Speed: km/h [0.0..250.0 step 0.5]
That is a complete package. type Speed: km/h [0.0..250.0 step 0.5] declares a
named scalar with a physical unit (UCUM km/h), a closed range, and a
quantization step.
Your first interface
Create veh/cluster/cluster.ridl:
package veh.cluster
import veh.common.Speed
/// Vehicle speed reporting interface.
interface VehicleSpeed {
/// Current vehicle speed, published every 10 ms.
signal currentSpeed: Speed @10ms
}
Line by line:
package veh.cluster— this file belongs to theveh.clusterpackage.import veh.common.Speed—Speedis declared in another package. Imports are single-type and qualified; there are no wildcards and no relative imports.interface VehicleSpeed— the contract boundary.signal currentSpeed: Speed @10ms— a continuously published value, whose contract requires the provider to publish it every 10 ms.
Checking it
From the workspace root:
ridl check
ridl check type-checks a file, a package directory, or a whole workspace, and
defaults to the current directory. It exits 0 when nothing is wrong, 1 when a
diagnostic is an error, and 2 when it cannot even load the workspace — for
example when no ridl.toml is found.
Swap the bounds in types.ridl to [250.0..0.0 step 0.5] and run it again:
error[TYPL-104]: range minimum 250 is greater than maximum 0
┌─ ./veh/common/types.ridl:4:18
│
4 │ type Speed: km/h [250.0..0.0 step 0.5]
│ ^^^^^^^^^^^^^^^^^^^^^
Most diagnostics carry a stable code. TYPL- codes come from the vocabulary
layer, RIDL- codes from the interaction layer, FORM- codes from the shared
surface syntax, and MANI- codes from the manifest. Those codes are listed in
the language references and are never renumbered or reused. A few diagnostics
are still uncoded and print as a bare error: — a constant written where a
type is expected is one — so do not assume a code is always there to search
for.
Primitives and named types
typl has five primitives: boolean, integer, float, string, bytes.
In an interaction, a primitive is never written directly. A signal payload,
an event payload, a command or query parameter, and a fixed value must each
name a type. Writing fixed doorCount: integer [1..8] is a FORM-102 error;
declare type DoorCount: integer [1..8] and use the name. This is the single
rule that most often surprises newcomers, and it is what gives every value on
the boundary a domain meaning rather than a width.
Struct fields are more permissive. An inline constrained primitive is accepted
there, including string [1..10] and bytes [64] — what the compiler rejects
in a field is a bare string or bytes with no constraint at all
(TYPL-208). The typl reference states the rule without that qualification, so
trust the compiler here: the constraint is what the check is about, and the
reference's own Appendix B writes frame : bytes [8]. Named types remain the
recommendation everywhere, and they are mandatory on a boundary.
Add a second file, veh/common/scalars.ridl.
package veh.common
// physical unit types — UCUM units
type Temperature: Cel [-40.0..125.0 step 0.1]
type FuelLevel: % [0.0..100.0 step 0.1]
type Voltage: V [0.0..48.0 step 0.1]
type Ratio: % [0.0..100.0 step 0.1]
// constrained integer types
type Counter: integer [0..65535]
type DoorIndex: integer [0..7]
type DoorCount: integer [1..8]
type Severity: integer [0..5]
// a named boolean, so it can be used on a boundary
type Enabled: boolean
// string types — bounds are recommended everywhere and required on a boundary
type ModelCode: string [3..6 match MODEL_PATTERN] = "ABC"
type Notes: string [1..256] = "-"
type Vin: string [17 match VIN_PATTERN] = "1HGCM82633A004352"
Two details in that listing:
- The range drives the wire width.
integer [0..255]is auint8on transport;integer [0..65535]is auint16. The generated language type is alwaysint64. You never write a width. = "ABC"is an init value — the value a consumer holds before anything real arrives. It is derived automatically for most types:falsefor booleans,0for a number whose range contains it and the range minimum otherwise, and the empty value when astringorbytesbound allows length 0. It cannot be derived for astringorbytestype carrying amatchpattern or a non-zero minimum length, so those declare one — which is whyModelCode,NotesandVinabove do. Without a declared init such a type draws aTYPL-115note. That note stays informational, always; what turns a missing init into an error is using the type as a signal payload, and the error isRIDL-109, raised at the signal.
Standard library types are always available without an import: Uuid, Ulid,
Uri, Url, Email, Label, Name, Message, Timestamp, Duration,
Version, Date, CountryCode, Sha256Hash, and others. They live in the
implicitly imported ridl.std package.
ridl.std holds only what is fixed by a cross-industry standard and means the
same in every domain, because it is in scope in every file you write. A type
that belongs to your industry — Vin above — is declared in your own
vocabulary package, like any other domain type.
Constants
const declares a compile-time constant, reusable in range bounds, match
patterns, init values, and contract expressions:
package veh.common
const MAX_SPEED: Speed = 250.0
const SPEED_LIMIT_EU: Speed = 130.0
const MAX_GEAR: integer = 6
// regex constants
const MODEL_PATTERN = /^[A-Z][A-Z0-9]{2,5}$/
const VIN_PATTERN = /^[A-HJ-NPR-Z0-9]{17}$/
// reuse in a range bound
type EngineSpeed: km/h [0.0..MAX_SPEED step 0.5]
struct GearState {
gear: integer [0..MAX_GEAR]
}
Constants are written SCREAMING_SNAKE by convention. The compiler does not
enforce it — the lexer folds every case shape into one identifier kind, so a
lower-case constant name compiles. The declared value, on the other hand, must
satisfy the constant's own type constraints, and that is checked.
Signals and events
The split between signal and event is the load-bearing distinction in ridl.
It is a distinction in meaning, which the specification then turns into
demands on a binding (see the note above; of the bindings, the WebSocket one
is built):
- A signal is continuous state. The latest sample is the truth, and an intermediate sample may be missed. The contract requires the channel never to be empty: a new subscriber must be delivered a value immediately — the init value before the first publication, the latest value after it.
- An event is a discrete occurrence. Every occurrence matters, so the contract requires occurrences to be queued rather than coalesced, and forbids late-joiner delivery. An occurrence that happened before you subscribed did not happen to you.
package veh.cluster
import veh.common.Speed
import veh.common.Temperature
import veh.common.FuelLevel
import veh.common.SPEED_LIMIT_EU
/// The three timing forms a signal accepts.
interface Sampling {
/// Strict periodic — published every 10 ms whether or not it changed.
signal currentSpeed: Speed @10ms
/// Change-driven — not faster than 20 ms, refreshed at least every 100 ms.
signal engineTemp: Temperature @[20ms..100ms]
/// Staleness bound only — refreshed at least every 5 s.
signal fuelLevel: FuelLevel @[..5s]
/// An init value override, written as a bare `= value` before the timing.
signal targetSpeed: Speed = SPEED_LIMIT_EU @[20ms..500ms]
}
The bounds have one meaning everywhere: min is the rate floor, the
minimum interval between publications, and max is the staleness bound,
the maximum age of the value. Those two bounds are what the compiler records in
the IR, and they are all it records. What a binding is required to do at each
bound follows from the declaring keyword — specified, not implemented:
| Bound | Required on a signal (state) | Required on an event (occurrence) |
|---|---|---|
min — rate floor | debounce — coalesce a faster update | throttle — do not raise occurrences faster |
max — staleness bound | refresh ceiling — re-publish even if unchanged | TTL — discard an older occurrence |
Events take a range only. Strict periodic @Xms on an event is a RIDL-103
error, because an isochronous rate is meaningless for occurrences:
package veh.cluster
import veh.common.DoorIndex
struct DoorPayload {
sensorId: DoorIndex
isOpen: boolean
}
interface DoorEvents {
/// Raised on every door state change; a binding must treat it as stale
/// 500 ms after it was raised.
event doorOpened: DoorPayload @[50ms..500ms]
/// A binding must throttle to one occurrence per 100 ms, and must discard
/// one that reaches a consumer more than 2 s after it was raised.
event speedLimitExceeded: DoorPayload @[100ms..2000ms]
}
A signal or event with no @ annotation is not rejected — it receives the
default range @[100ms..1000ms] and draws a RIDL-100 warning. The default is
configurable per package or per workspace:
[defaults]
timing = "[100ms..1000ms]"
command_timing = "[..1s]"
query_timing = "[..3s]"
A command or query with no response bound is treated the same way: it
receives command_timing (built-in [..1s]) or query_timing (built-in
[..3s]) and draws RIDL-112. Each key resolves on its own.
Because the IR always carries resolved bounds, changing a default changes
every untimed interaction it covers in the package, and ridl diff reports it
as a contract change. Safety-graded packages should annotate every interaction
explicitly.
Enums and enum sets
An enum is an integer-backed set of discrete values, for choosing one:
package veh.common
enum GearPosition {
PARK = 0
REVERSE = 1
NEUTRAL = 2
DRIVE = 3
LOW = 4
}
enum DriveMode {
NORMAL = 0
ECO = 1
SPORT = 2
OFF_ROAD = 3
}
Newline and comma are interchangeable separators everywhere, so both layouts above are the same declaration. There are no semicolons in the language.
An enumset is a named bitfield, for several flags at once:
package veh.common
enumset AccessFlags {
DOOR_UNLOCKED = 0 // bit 0
BOOT_UNLOCKED = 1 // bit 1
WINDOW_OPEN = 2 // bit 2
}
When you need both the single value and the set, declare the enum and derive
the enumset from it:
package veh.common
enum Warning {
LOW_FUEL = 0
CHECK_ENGINE = 1
DOOR_OPEN = 2
SEATBELT = 3
}
enumset WarningFlags: Warning
Both names are then usable on a boundary — the enum where one value is meant, the enumset where several are:
package veh.cluster
import veh.common.Warning
import veh.common.WarningFlags
interface Warnings {
command clearWarning(flag: Warning) @[..50ms]
signal activeWarnings: WarningFlags @[50ms..1s]
}
Provisioned values — fixed
A fixed is a value set at build, factory, or over-the-air update, and
immutable for the lifetime of the running software instance:
package veh.cluster
import veh.common.DoorCount
import veh.common.Enabled
import veh.common.Vin
interface VehicleIdentityBasics {
fixed vin: Vin
fixed softwareVersion: Version
fixed marketRegion: Label
fixed ecuSerial: Uuid
fixed capabilities: [Label; 0..32]
fixed doorCount: DoorCount
fixed hasCruise: Enabled
}
Declaring a value fixed is a promise that it never changes while the software
runs, which is what makes it safe for a consumer to cache unconditionally — a
promise to a future binding, like the rest of the delivery semantics. What the
compiler enforces today is the shape: a fixed takes no timing annotation and
no contract block, and both are RIDL-106 errors. Note hasCruise: Enabled
rather than hasCruise: boolean: a boundary value names a type.
Commands and queries
A command is a fire-and-forget action request. It never returns a value —
writing a return type is a RIDL-104 error. If the caller needs to know the
outcome, the outcome is observed as state, or the interaction is a query.
A query is request/response. The reply is mandatory, and a query returning
() is a RIDL-105 error.
package veh.cluster
import veh.common.Speed
import veh.common.GearPosition
interface Control {
command setTargetSpeed(speed: Speed) @[..50ms]
command enableCruiseControl() @[..50ms]
command resetFaults() @[..200ms]
command setGear(position: GearPosition) @[20ms..100ms]
query getCurrentSpeed(): Speed @[..50ms]
query getSpeedHistory(window: Duration): (min: Speed, max: Speed, avg: Speed) @[..100ms]
}
A query return may be a named type, a tuple of named fields, a stream, or an
inline T | E union that makes the query fallible. Parameters are named types
or streams — a tuple is not a parameter type; pass a struct.
A command or query takes the same @[min..max] range a signal does, with the
same two bounds read for a call: min is the call throttle — the caller
must not call faster — and max is the response bound — the provider must
respond within it. For a query, responding is the reply; for a command it is
acceptance, the delivery acknowledgment, not execution. setGear above
declares both: callers throttle to one request per 20 ms, and the provider
accepts within 100 ms. Only the range form is admitted — a strict periodic
@Xms on a command or query is a RIDL-103 error, because a caller is not
isochronous by contract.
Like a signal, an RPC with no declared response bound takes a default: the
package's command_timing or query_timing, else the built-in [..1s] for a
command and [..3s] for a query. It still draws a RIDL-112 warning, and the
IR carries the resolved bound. @[20ms..] — a throttle with no response bound —
warns the same way and takes max from the default; a missing min draws
nothing.
Structs and optionality
package veh.common
struct CruiseControlState {
enabled: boolean
targetSpeed: Speed
mode: DriveMode
override: Speed? // optional — absent when no override is active
}
The ? suffix means may be absent, which is not the same as present-and-null;
typl has no null.
Struct fields carry implicit ordinals by declaration order, so evolution is
append-only: add new fields at the end, and retire a removed field with a
reserved tombstone so its slot is never reused.
package veh.common
struct DriverProfile {
name: Name
reserved legacyChecksum // was ordinal 2 — retired, never reused
speed: Speed
}
Structs may not be recursive: an unbounded wire size would contradict the bounded-size guarantee every typl composite carries.
Errors are data
ridl has no throws, no exceptions, and no status codes. A query that can fail
as part of its domain semantics says so in its return type, using vocabulary
declared in typl:
package veh.common
/// Failure vocabulary — the `error` modifier marks a shape as a failure shape.
error enum CalError {
SENSOR_UNAVAILABLE = 0
VEHICLE_MOVING = 1
OUT_OF_RANGE = 2
}
struct CalReport {
offset: float [-1.0..1.0 step 0.001]
}
struct Axle {
index: integer [0..3]
}
package veh.cluster
import veh.common.CalError
import veh.common.CalReport
import veh.common.Axle
interface Calibration {
/// Fallible query — the inline `T | E` return is the canonical spelling.
query calibrate(axle: Axle): CalReport | CalError @[..5s]
}
The left arm is the success type, the right arm exactly one error type.
One half of what follows from that you can see today: the Rust backend renders
the fallible return as a Result<T, E>, so exhaustive handling is enforced by
the compiler you already use. The other half is specified only — a binding is
required to split the union mechanically, sending the success arm as the reply
payload and mapping the error arm onto the transport's own error channel. No
binding is built to do it.
Two other failure kinds never appear in source at all, and both belong entirely
to that unbuilt layer. Contract violations — a payload outside its declared
range, or a failed require — are derived from the declarations you already
wrote, and the specification requires a runtime to report them uniformly rather
than making you declare them. Transport failures — timeouts, broker loss,
connection resets — are infrastructure failures the specification requires a
runtime to detect and leaves undeclared in the language, because a contract
author has no knowledge to express about them.
Collections and streams
Collections are finite and always explicitly bounded:
package veh.common
struct FaultCode {
dtc: integer [0..65535]
}
struct DiagReport {
faults: [FaultCode; 0..32] // bounded array — at most 32 faults
sensors: [Label: Speed; 1..8] // bounded map — 1 to 8 entries
readings: [Speed; 8] // fixed array — exactly 8
rawFrame: bytes [64] // fixed 64-byte buffer
}
A stream <T> is the one unbounded container, and it is valid only in
interaction position — on a command or query parameter, or a query return. Its
element type is a named type describing one logical element; framing and
backpressure are transport concerns.
package veh.common
type LogLine: string [1..1024] = "-"
type FwBlock: bytes [1..4096]
struct SensorSample {
value: Speed
}
struct ProcessedSample {
value: Speed
}
struct DiagFilter {
severity: Severity
}
FwBlock draws a TYPL-115 note: bytes with a minimum length above zero has
no derivable init value, exactly as a string with a minimum length above zero
or a match pattern does. The note is harmless here — a stream element is never
a signal payload, so nothing ever asks it for an init — and the block's fence
carries allow=TYPL-115 to say so.
package veh.cluster
import veh.common.LogLine
import veh.common.FwBlock
import veh.common.DiagFilter
import veh.common.FaultCode
import veh.common.SensorSample
import veh.common.ProcessedSample
interface Transfer {
query streamFaults(filter: DiagFilter): <FaultCode> @[..1s] // provider produces
query streamLogs(): <LogLine> @[..1s] // text stream
command uploadFirmware(data: <FwBlock>) @[..1s] // consumer produces
query pipe(samples: <SensorSample>): <ProcessedSample> @[..1s] // bidirectional
}
Direction follows position. A stream in return position is declared to flow
from provider to consumer, one in parameter position the other way, and a query
with both declares full duplex. That is a statement about the contract; nothing
flows until a binding exists to carry it. A stream on a signal or an event is a RIDL-201
error — a signal is the better stream for state, and an unbounded push of
occurrences is an event.
Contracts
require and ensure clauses go in an attribute block after a command or a
query. require is a precondition over the parameters and the interface's own
signals; ensure is a postcondition over result, and is valid on queries
only.
package veh.cluster
import veh.common.Speed
import veh.common.SPEED_LIMIT_EU
interface Cruise {
signal currentSpeed: Speed @10ms
command setTargetSpeed(speed: Speed) @[..50ms] [
require speed > 0.0
require speed <= SPEED_LIMIT_EU
]
query getSpeedHistory(window: Duration): (min: Speed, max: Speed, avg: Speed) @[..100ms] [
require window > 0ms
ensure result.min <= result.avg
ensure result.avg <= result.max
]
}
Contract expressions are type-checked, unquoted, and side-effect-free. They are
restricted to a guaranteed subset: comparison, boolean connectives,
arithmetic, enum access, tuple-field access, and duration comparison, over
parameters, result, constants, enum values, and the interface's own signals.
Anything outside it is a RIDL-306 error. Reading a field of a struct-typed
signal is outside the subset — publish the scalar you want to constrain as its
own signal instead.
The clauses are not documentation, and one use of them runs today: ridl test
draws sample parameter tuples and evaluates each require clause against them.
It does not evaluate ensure clauses — it lists them as observer stubs,
because checking a postcondition needs a result, and producing one needs the
runtime that does not exist yet. That same unbuilt layer is where the
specification puts the other use: a provider binding evaluating require
before your code runs, and ensure after it returns.
Labels
@labels is a doc-comment tag carrying free-form classification identifiers —
safety, security, privacy, or any other domain-specific tag. The core language
defines no vocabulary:
package veh.cluster
import veh.common.Speed
/// Cruise control interface.
/// @labels SIL_2, SEC_2, PRIVATE
interface CruiseControl {
signal engagedSpeed: Speed @[50ms..500ms]
}
/// Logging interface — no safety or security requirement.
/// @labels SIL_QM, SEC_NA, PUBLIC
interface LogService {
signal lastSpeed: Speed @[1s..10s]
}
Different domains use different vocabularies: an automotive profile would
define ASIL_*, a medical profile IEC_62304_*, an industrial profile
IEC_61508_*.
As built, labels are carried through unchecked. The compiler parses them
and passes them into the IR unchanged. The assurance profiles that would
validate a vocabulary and its combinations (diagnostics TYPL-402 and
TYPL-403) are specified but not implemented.
What the compiler produces
ridl build --emit rust --out-dir out
ridl build compiles a workspace and writes one artifact per package for each
emit target, except that catalog writes one per unit and none for a unit that
declares no interface and no service with an inline body.
Eleven emit targets exist today:
--emit | Output | Contents |
|---|---|---|
rust | <package>.rs | idiomatic Rust source (the default) |
ir-json | <package>.ir.json | the lowered IR v2 as exact-decimal JSON |
ir-text | <package>.ir.txtpb | the lowered IR v2 as prototext |
ir-binary | <package>.ir.binpb | the lowered IR v2 as protobuf binary |
typescript | <package>.ts | idiomatic TypeScript source, and <package>.codec.ts under codec = true for a package with a payload root that gets a codec |
proto | <package>.proto | the proto3 schema — types and the ordinals |
flatbuffers | <package>.fbs | the FlatBuffers schema — types and the ordinals |
podbuf | <package>.h | the podbuf C header — the head structs and the accessors a C consumer reads a verified buffer through |
codegen-model | <package>.codegen.json | the lowered codegen model (ridl.codegen.v1) as canonical protobuf JSON |
codegen-request | <package>.codegen-request.json | the whole codegen request a build hands a backend, as canonical protobuf JSON |
catalog | <unit>.catalog.binfb | the catalog descriptor an engine reads — interfaces, numbers, members, sizes |
One more artifact joins them when a package names a type from ridl.std —
Duration above does — because generated code refers to standard types by
package path and does not compile without the standard package beside it.
The command above therefore writes out/ridl.std.rs as well, and
--emit typescript writes ridl.std.ts the same way (and ridl.std.codec.ts
under [backend.typescript] codec = true, when a codec module imports it). The
three IR targets
are an exception: a direct IR dump records the packages the workspace
declares, and ridl.std ships with the compiler. catalog is the other
exception: ridl.std declares no interface, so no ridl.std.catalog.binfb
is written.
Using the generated Rust code shows how a program calls
the crate that --emit rust writes, and
The catalog descriptor explains the catalog file.
There is no transport binding and no code generator for SOME/IP, gRPC, DDS, MQTT or AIDL yet. Those mappings are specified in the ridl language reference, Appendix B, and are the work of later epics.
Classic CAN is not on that list, and never will be for a whole interface:
Appendix B records that DBC/CAN binds signal declarations only, and that an
event, command or query on classic CAN is a profile error. A bus carries
dataflow, not calls.
The rest of the toolchain
| Command | What it does |
|---|---|
ridl check | type-check a file, package, or workspace |
ridl build | compile to the artifacts above |
ridl fmt | rewrite .typl, .ridl and .rsdl files into one canonical form; --check reports without writing |
ridl baseline | publish the current workspace as .ridl/baseline/<package>.ir.json snapshots and <unit>.catalogs files |
ridl diff | compare two IR snapshots or source trees and classify the change |
ridl lock | allocate interface numbers in each unit's interfaces.lock; --rename and --retire record a renamed or removed interface |
ridl test | run the property suite: range self-corpora, and sampling of require clauses. ensure clauses are listed as observer stubs, never evaluated |
ridlc is the plumbing underneath, with check and build only. Use ridl
unless you are scripting the compiler directly.
Two of those deserve a note.
ridl fmt applies the canonical layout used by this chapter's verified
listings. It tightens colons and spacing and places one member per line in
brace bodies.
The CLI reference describes the two-space
indentation and configurable line width, which defaults to 100 characters.
ridl diff classifies, and its exit code carries the answer: 0 when the
change is compatible or the snapshots are identical, 1 when it is breaking, 2
on an error. Interactions carry implicit ordinals by declaration order, so
appending is compatible and inserting or reordering is not. Retire an
interaction with a reserved tombstone rather than deleting it:
package veh.cluster
import veh.common.Speed
// `DoorPayload` is declared earlier in this same package (`veh.cluster`), so
// it needs no import — everything in a package is visible to the rest of it.
interface VehicleStatus {
signal currentSpeed: Speed @10ms
reserved legacyTemp // was ordinal 2 — retired
event doorOpened: DoorPayload @[50ms..500ms]
}
ridl check also compares against a published baseline when
.ridl/baseline/ exists, and reports a moved ordinal as a RIDL-407 warning
at your desk, before CI sees it.
Interfaces and services
An interface is an abstract shape: a reusable, identity-less group of
interactions. It is not addressable and not deployed.
A service is a global, named, published declaration of one or more of them —
the catalog entry that gives contracts a concrete identity in the system. The
specification defines its members as addressed service.member; no runtime
resolves such an address today, so read it as the naming scheme a deployment
will use. Service names are unique across the system and always public, and
that uniqueness the compiler does enforce (RIDL-140).
package veh.cluster
import veh.common.Temperature
service veh.cluster.status: VehicleStatus
service veh.cluster.dash: VehicleStatus, Warnings
service veh.cluster.hvac {
signal temperature: Temperature @[1s..10s]
command setTarget(temp: Temperature) @[..50ms]
}
The first form names existing interfaces — a comma-separated list, and the commas are required, the one place the family's separators are not optional: the list has no closing token, so it ends where the next declaration begins. The last form declares an inline shape for a one-off contract not worth a reusable interface. A service declaration says nothing about how the contract is realized on the wire — that is a deployment question, and rsdl's (see Describing a system).
Composing interfaces is how a recurring interaction set — a diagnostics block,
a heartbeat — is reused without duplication: each composed interface keeps its
own ordinal space, and its number comes from its unit's interfaces.lock,
not from its place in the list, so the list is a set and its order carries
nothing. Because members stay addressed service.member, two composed
interfaces must not share a member name (RIDL-144).
Interfaces are flat: there is no interface inheritance. Sharing a shape is typl's job; a shared interaction set is composed at the service, never flattened into the interface, so that a base change can never renumber a derived contract's wire identity.
Naming conventions
| Construct | Convention | Example |
|---|---|---|
type, struct, enum, enumset, union | CamelCase | VehicleSpeed, WarningFlags |
| Interfaces | CamelCase | VehicleStatus |
| Interactions, fields, tuple fields, parameters | camelCase | currentSpeed |
| Enum values, enumset bits, constants | SCREAMING_SNAKE | PARK, MAX_SPEED |
| Packages and service names | lowercase.dot | veh.common |
Beyond spelling: name signals and fixed values as nouns (currentSpeed), events as
past-tense occurrences (doorOpened), commands as imperatives (setGear), and
queries as get… or stream…. A query named like a mutation draws a
RIDL-404 warning, because it probably wanted to be a command.
Every registry word is reserved in every profile, including words this profile
does not accept. state, states, model, component and the rest of the
registry are not available as identifiers even in a .ridl file — the typl
language reference §1.4 lists the whole set. The domain spellings of ADR-0012
(present, trigger, …) are not in the registry; they are identifiers until a
profile implements one.
Next steps
- The ridl language reference — the normative interaction layer.
- The typl language reference — the normative vocabulary layer.
- The expr-core specification — the grammar of
requireandensure. - The family overview — the map, the shared doctrines, and the decision ledger.
- The four annexes below — complete, compiling interfaces from a vehicle domain.
Annex 1 — Vehicle identity
A pure fixed interface: everything provisioned at the factory or over the
air. Note that every capability flag names a type rather than writing
boolean, and that ModelYear and Speed are declared types, not inline
ranges.
package veh.identity
import veh.common.Speed
import veh.common.Vin
type ModelYear: integer [2000..2100]
type Capability: boolean
enum FuelType {
PETROL = 0
DIESEL = 1
HYBRID = 2
ELECTRIC = 3
HYDROGEN = 4
}
enum DriveLayout {
FWD = 0
RWD = 1
AWD = 2
FOUR_WD = 3
}
/// Vehicle identity and hardware capability manifest.
/// Provisioned at the factory. Software fields updated over the air.
///
/// @labels SIL_QM, SEC_2, CONFIDENTIAL
interface VehicleIdentity {
// vehicle identity — factory provisioned
fixed vin: Vin
fixed modelYear: ModelYear
fixed marketRegion: Label
fixed fuelType: FuelType
fixed driveLayout: DriveLayout
// software identity — updated over the air
fixed softwareVersion: Version
fixed bootloaderVersion: Version
fixed hardwareVersion: Version
// capability flags — build time
fixed hasAdaptiveCruise: Capability
fixed hasEmergencyBraking: Capability
fixed hasLaneKeepAssist: Capability
fixed hasParkingAssist: Capability
fixed hasDriverMonitoring: Capability
fixed maxSupportedSpeed: Speed
}
Annex 2 — Powertrain
Two details worth reading closely. The engine's scalar speed is published as
its own signal engineRpm, because a require clause may read a signal but
not reach into a struct-typed one. And the engine state field is named
engineState: state is a reserved word family-wide, so it cannot be an
identifier. This annex also draws RIDL-414 (low-cohesion-interface):
PowertrainManager keeps every powertrain fact behind one interface, and its
members fall into seven groups that share no named type. The annex keeps that
grouping to show one interface per subsystem.
package veh.powertrain
type RPM: /min [0.0..8000.0 step 10.0]
type Torque: N.m [0.0..500.0 step 0.1]
type Temperature: Cel [-40.0..150.0 step 0.1]
type FuelLevel: % [0.0..100.0 step 0.1]
type Voltage: V [0.0..48.0 step 0.1]
type Dtc: integer [0..65535]
type FaultCount: integer [0..65535]
type Severity: integer [0..5]
enum EngineState {
OFF = 0
CRANKING = 1
RUNNING = 2
FAULT = 3
}
enum GearPosition {
PARK = 0
REVERSE = 1
NEUTRAL = 2
DRIVE = 3
LOW = 4
}
struct EngineMetrics {
rpm: RPM
torque: Torque
oilTemp: Temperature
coolantTemp: Temperature
engineState: EngineState
}
struct TransmissionState {
gear: GearPosition
inputRPM: RPM
outputRPM: RPM
oilTemp: Temperature
}
/// The `timestamp` field records when the fault occurred, which is domain
/// information distinct from the envelope's publication time. `ridl check`
/// notes the overlap (RIDL-406); the note is informational and the field is
/// the legitimate exception the rule names.
struct FaultPayload {
dtc: Dtc
message: Message
timestamp: Timestamp
}
/// Powertrain management interface.
///
/// @labels SIL_3, SEC_2, PUBLIC
interface PowertrainManager {
signal engineMetrics: EngineMetrics @20ms
signal engineRpm: RPM @20ms
signal transmissionState: TransmissionState @[50ms..500ms]
signal fuelLevel: FuelLevel @[..5s]
signal batteryVoltage: Voltage @[100ms..1s]
event engineFault: FaultPayload @[100ms..30s]
event transmissionFault: FaultPayload @[100ms..30s]
command requestStart(mode: EngineState) @[..200ms] [
require mode == EngineState.CRANKING
]
command requestStop() @[..200ms]
command setGear(gear: GearPosition) @[20ms..100ms] [
require gear != GearPosition.PARK || engineRpm == 0.0
]
query getDiagnostics(): (
engineState: EngineState,
activeGear: GearPosition,
faultCount: FaultCount,
lastFaultTime: Timestamp
) @[..100ms]
query streamFaults(severity: Severity): <FaultPayload> @[..1s]
fixed softwareVersion: Version
fixed calibrationVersion: Version
fixed ecuSerial: Uuid
fixed supportedGears: [GearPosition; 1..8]
}
Annex 3 — Body control
A signal payload is a single named type, so a collection of door states is
published through a struct that wraps the array rather than as a bare array.
Command parameters name types too: DoorIndex, not integer [0..7]. And the
lock parameter is named lock, because state is reserved. BodyControl
draws RIDL-414 (low-cohesion-interface): its members fall into ten groups that
share no named type, which the annex keeps to show one interface per subsystem.
package veh.body
import veh.common.Temperature
type Ratio: % [0.0..100.0 step 1.0]
type DoorIndex: integer [0..7]
type DoorCount: integer [1..8]
type WindowCount: integer [0..8]
type UnlockCount: integer [0..8]
type Zone: integer [0..7]
type HasWindows: boolean
type AllLocked: boolean
type Granted: boolean
enum DoorPosition {
CLOSED = 0
OPEN = 1
AJAR = 2
}
enum LockState {
LOCKED = 0
UNLOCKED = 1
}
enum LightState {
OFF = 0
ON = 1
FLASH = 2
}
struct DoorState {
position: DoorPosition
locked: LockState
sensorId: DoorIndex
}
struct WindowState {
position: Ratio
sensorId: DoorIndex
}
struct DoorSet {
doors: [DoorState; 1..8]
}
struct WindowSet {
windows: [WindowState; 1..8]
}
struct ClimateState {
interiorTemp: Temperature
targetTemp: Temperature
fanSpeed: Ratio
acActive: boolean
}
/// `timestamp` records when access was attempted — domain time, not the
/// envelope's publication time (RIDL-406 note).
struct AccessEvent {
zone: Zone
granted: Granted
method: Label
timestamp: Timestamp
}
/// Body control module interface.
/// Covers doors, windows, lights and climate.
///
/// @labels SIL_1, SEC_3, PRIVATE
interface BodyControl {
signal doorStates: DoorSet @[50ms..2s]
signal windowStates: WindowSet @[100ms..5s]
signal climateState: ClimateState @[500ms..10s]
event doorStateChanged: DoorState @[50ms..10s]
/// Vehicle access attempt — elevated privacy.
/// @labels SIL_1, SEC_4, CONFIDENTIAL
event accessAttempt: AccessEvent @[100ms..30s]
command setAllLocks(lock: LockState) @[..100ms]
command setDoorLock(sensorId: DoorIndex, lock: LockState) @[..100ms]
command setWindowPosition(sensorId: DoorIndex, position: Ratio) @[..100ms]
command setClimateTarget(temp: Temperature) @[..100ms] [
require temp >= 16.0
require temp <= 30.0
]
query getLockStatus(): (allLocked: AllLocked, unlockedCount: UnlockCount) @[..50ms]
query getBodyStatus(): (doors: DoorSet, windows: WindowSet) @[..50ms]
fixed doorCount: DoorCount
fixed windowCount: WindowCount
fixed hasElectricWindows: HasWindows
}
Annex 4 — Driver monitoring
DriverMonitoring draws RIDL-414 (low-cohesion-interface): its members fall
into seven groups that share no named type, which the annex keeps to show one
interface per subsystem.
package veh.dms
type Ratio: % [0.0..100.0 step 0.1]
type SampleInterval: integer [50..1000]
type ZoneCount: integer [1..16]
type Distracted: boolean
type Fatigued: boolean
type Monitoring: boolean
enum AlertLevel {
NONE = 0
LOW = 1
MEDIUM = 2
HIGH = 3
CRITICAL = 4
}
enum GazeZone {
FORWARD = 0
MIRROR_LEFT = 1
MIRROR_RIGHT = 2
MIRROR_REAR = 3
INSTRUMENT = 4
INFOTAINMENT = 5
OFFROAD = 6
UNKNOWN = 7
}
struct AttentionMetrics {
gazeZone: GazeZone
eyeOpenness: Ratio
headPitch: float [-30.0..30.0 step 0.1]
headYaw: float [-90.0..90.0 step 0.1]
attentionScore: Ratio
}
struct DrowsinessState {
level: AlertLevel
score: Ratio
eyeClosurePct: Ratio
}
/// `timestamp` records when the distraction occurred — domain time
/// (RIDL-406 note).
struct DistractionEvent {
level: AlertLevel
gazeZone: GazeZone
duration: Duration
timestamp: Timestamp
}
/// `timestamp` records when the fatigue was detected — domain time
/// (RIDL-406 note).
struct FatigueEvent {
level: AlertLevel
score: Ratio
timestamp: Timestamp
}
/// Driver monitoring system interface.
/// Processes biometric and behavioural signals to assess driver state.
///
/// @labels SIL_2, SEC_3, CONFIDENTIAL
interface DriverMonitoring {
signal attentionMetrics: AttentionMetrics @[100ms..500ms]
signal drowsinessState: DrowsinessState @[500ms..5s]
event distractionDetected: DistractionEvent @[200ms..10s]
event fatigueDetected: FatigueEvent @[500ms..30s]
command acknowledgeAlert(level: AlertLevel) @[..50ms]
command setMonitoringEnabled(enabled: Monitoring) @[..50ms]
query getDriverState(): (
attentionScore: Ratio,
drowsinessLevel: AlertLevel,
isDistracted: Distracted,
isFatigued: Fatigued
) @[..100ms] [
ensure result.attentionScore >= 0.0
ensure result.attentionScore <= 100.0
]
query streamAttention(interval: SampleInterval): <AttentionMetrics> @[..1s]
fixed modelVersion: Version
fixed sensorType: Label
fixed gazeZoneCount: ZoneCount
}
Describing a system
Getting started declares contracts: the types a system carries and the interactions it offers. This chapter declares what uses them — which components exist, which contracts each one offers and requires, how the copies are bundled, and which machine each copy runs on. That is rsdl, the architecture layer of the family, and the rsdl reference is its specification.
What is built. The compiler reads .rsdl files, checks every rule of the
reference, and lowers the checked system to the IR beside the package IR.
ridl diff compares two systems.
What is not built. No runtime reads the lowered system. The links, the routing table and the grants this chapter shows are facts the compiler writes down; nothing delivers a message along them. No descriptor is emitted from the lowered system, and there is no transport.
Contracts first
rsdl declares no type, no interface and no service. An .rsdl file that tries
draws RSDL-604. The contracts live in a ridl package, exactly as the getting
started chapter writes them:
package veh.climate
type Temperature: integer [-40..85]
type FanLevel: integer [0..7]
type Heated: boolean
interface Climate {
signal cabinTemperature: Temperature @[100ms..1s]
command setFan(level: FanLevel) @[..50ms]
}
interface Seats {
signal heated: Heated @[100ms..1s]
}
service veh.climate.control: Climate
service veh.climate.seats: Seats
service veh.climate.diag {
query readTemperature(): Temperature @[..100ms]
}
veh.climate.diag declares its contract inline rather than naming a reusable
interface, so its dotted name is also the name of its one interface.
Components
A component is one execution context. It offers services, requires interfaces, and names one reference per line:
package veh.cabin
import veh.climate.Climate
import veh.climate.Seats
/// Runs the climate loop. Two copies, one per zone controller.
component ClimateControl [ instances = (front, rear) ] {
offers veh.climate.control
requires Seats
}
component SeatHeating {
offers veh.climate.seats
}
component Dashboard {
requires Climate
requires Seats
}
/// The phone app. No implementation in this workspace.
component PhoneApp [ external ] {
requires Climate
requires veh.climate.diag
}
requiresnames an interface, never a provider. The compiler finds the one service in the closure that lists that interface, and the one component that offers that service. A consumer that requires the narrowest interface it needs is unaffected when the service gains or splits interfaces.instancesnames the copies. A component that declares none has one instance, writtenUnitin the IR and in diagnostics.externalmarks a component with no implementation in this workspace. It is still part of the closure, and its links are still derived.ClimateControlhas two instances, so it is a redundant provider set.Dashboard's andPhoneApp'srequires Climateeach draw RSDL-409, "not yet realizable": every link is lowered to each instance, and there is no arbitration. The fence above allows RSDL-409 for that reason.
The system
A system is the closure — the components the build is about. A workspace
declares at most one:
package veh.cabin
system Cabin {
ClimateControl
SeatHeating
Dashboard
PhoneApp
veh.climate.diag
}
veh.climate.diag is a lone service that no declared component offers, so it
stands for an implicit component of that name, with one Unit instance. Writing
a component for it would say nothing the service does not already say.
Distributions
A distribution is what is versioned, signed and shipped together. When a workspace declares one, every implemented closure component is in exactly one:
package veh.cabin
distribution Comfort [ tier = PLATFORM ] {
ClimateControl
SeatHeating
veh.climate.diag
}
distribution Display [ tier = APPLICATION ] {
Dashboard
}
PhoneApp is external, so it is in no distribution. Display depends on
Comfort, because Dashboard requires interfaces that ClimateControl and
SeatHeating offer. The dependency is derived, never written. An application
distribution may depend on a platform one; the reverse is a tier inversion,
RSDL-901.
Deployments and machines
A deployment places the closure on machines. Each machine lists what it hosts, and every instance sits on exactly one machine per deployment:
package veh.cabin
deployment Car for Cabin {
machine ZoneFront [ labels = (ASIL_A) ] {
ClimateControl.front
SeatHeating
veh.climate.diag
}
machine ZoneRear {
ClimateControl.rear
}
machine Head {
Dashboard [ linux.cpuset = (1), slots = 8 ]
}
machine Cloud [ external ] {
PhoneApp
}
}
deployment Bench for Cabin {
machine Rig {
ClimateControl
SeatHeating
Dashboard
veh.climate.diag
PhoneApp
}
}
- A bare component name places every instance of it, as
Rigdoes withClimateControl.ClimateControl.frontplaces one. - An
externalmachine hosts external components only (RSDL-707). OnRig,PhoneAppis placed on an on-board machine: a stub standing in for the phone. linux.cpusetis a backend key, carried uninterpreted onDashboard's placement. No configured backend claims the namespacelinux, so it draws RSDL-804, which the fence allows.slotsis a sizing key, written on a placement line likeDashboard's. It sets the table entries of every command or query channel thatDashboardconsumes, inCaronly.depthandbudgetare the other two, and a placement line and adeploymentdeclaration both take all three; the reference gives their ranges and the precedence (rsdl reference §5).
Two mistakes are worth naming, because each blocks only its own deployment.
Placing ClimateControl.front on ZoneFront and then reaching
ClimateControl.rear twice in Car is RSDL-706, an instance placed twice.
Dropping the ZoneRear machine leaves ClimateControl.rear on no machine,
which is RSDL-701. Either one leaves the other deployment's lowering intact.
What the compiler writes
ridl check reports every rule above. ridl build writes the package IR and
the lowered system beside it:
ridl build --emit ir-json --out-dir out && ls out
veh.cabin.Cabin.system.json
veh.cabin.ir.json
veh.climate.ir.json
The system artifact is named after the system's qualified name. --emit ir-text
and --emit ir-binary write .system.txtpb and .system.binpb instead.
The facts inside it are stated once for the closure and once per deployment. For the closure:
- The components, with their instances, their
offersandrequireslines, and theirexternalflag. - The producers — for each service, the component that offers it and that
component's instances.
veh.climate.controlis marked not yet realizable, becauseClimateControlhas more than one instance. - The grants — per component, the catalog regions its requirements reach.
PhoneAppreachesveh.climate, and its grant is lowered with theexternalflag: it is derived here, and enforcing it is the external side's business.SeatHeatingrequires nothing, so its grant is empty. - The region map — one region per catalog the closure reaches, here
veh.climate, holdingClimate,Seatsandveh.climate.diagwith the number each takes from the unit'sinterfaces.lock. A region is one per unit, and the interface names in a region, like those in a route, are catalog names: the name relative to the unit, such asClimatefor an interface of the unit's root package orcluster.Speedfor one of a subpackage. An inline shape keeps its global name, soveh.climate.diagappears under that name. Each region also carries the catalog hash, computed by ridl over the unit's interfaces, their numbers and every type they reach: the same hash--emit catalogwrites in the unit's descriptor. - The distributions, with the derived dependency.
Per deployment:
- The placements — every instance with the machine it runs on, including an instance with no link at all.
- The links, one per consumer instance and producer instance, each with its
crossing kind. In
Car,ClimateControl.front → SeatHeating.Unitis same machine,ClimateControl.rear → SeatHeating.Unitis different machine, and every link fromPhoneAppis off-board. InBenchevery link is same machine, because everything sits onRig. - The routing table — the key
(catalog, interface number, member ordinal)to the producing instances and their machines, for every member of every interface a closure service lists. - The surface set — the system's external boundary. Here it is the three
links from
PhoneApp, with the external side consuming, in both deployments: theexternalflag and not the machine defines the boundary, soBenchkeeps them although they cross nothing. - The installation of each distribution — in
Car,ComfortonZoneFrontandZoneRear,DisplayonHead.
What an error does. An error in the closure blocks the lowering of every deployment, and the build writes nothing. An RSDL-7xx error — a placement problem, or a sizing value out of range (RSDL-709) — blocks its own deployment only: the build writes the package IR and the system without that deployment, and still exits 1.
Comparing two systems
ridl diff compares the contracts by the ridl categories, and those alone give
the verdict and the exit code. The changes that are not contract changes are
listed after them, under two headings and with no verdict. Moving Dashboard
from Head to ZoneRear in Car:
identical
placement changed
Car/veh.cabin.Dashboard.Unit: Head -> ZoneRear
The contracts did not move, so the verdict is identical and the exit code 0.
A requires line added, a component added to the system, instances changed,
a component made external, or a component moved from one distribution to
another are listed under composition changed instead.
A changed labels, deprecated, tier or backend key is listed under the
heading of the declaration it is on, as a before-and-after line; a changed
sizing key (depth, slots, budget) is not listed, because it changes no
link and no contract.
Which of these changes break an already deployed system, and for whom, is not
something the compiler decides.
What is not here yet
- No runtime reads the lowered system, and no descriptor is emitted from it. The facts are written down and go no further.
- rsdl declares no transport, no network and no process. A same-machine crossing says two instances share a machine, not how they talk. If a backend needs such a fact, it arrives as that backend's namespaced key.
- Posture, redundancy arbitration and the
optionalrequirement are reserved — see §12 of the reference. A redundant provider set is reported, not resolved.
Remote units and ridl vendor
A package does not have to live in your repository. This chapter shows how to
import a package from a URL, where the compiler reads it from, what
ridl.lock pins, how ridl vendor copies it into your repository, and how to
build with no network. The CLI reference lists
what each command writes, and ADR-0002 records the rules; this
chapter is the guide.
The examples use TOML manifests and shell sessions. A remote import needs a network, so no block here is compiled, and the URLs are placeholders.
Import a package from a URL
The [imports] table of a manifest names each remote package. The key is the
package name that the remote package declares in its own ridl.toml; the value
is the URL of a tar archive that holds the package:
[package]
name = "veh.cluster"
[imports]
"veh.common" = "https://registry.example.com/veh/common@v1.0.0"
A package that is a member of a workspace puts its imports in the root manifest or in its own manifest. The compiler takes the union of both.
In a source file, import from the remote package as from any other package:
import veh.common.Speed
The import resolves by the longest dot-separated prefix that is an [imports]
key, so veh.common.Speed finds the key veh.common. A remote package exposes
the same declarations to you as a package in your repository does, except
internal ones.
A remote package, called a remote unit in the diagnostics, follows these rules:
- Its archive holds one package at the top level: a
ridl.tomlwith a[package]table, not a[workspace]table, whosenameequals the[imports]key. Anything else isMANI-105. - Your
[imports]lists every remote package the build needs, including the ones that your remote packages import. The compiler does not fetch the imports of a remote unit. It reads them only to check that you list each key, and a key you do not list isMANI-106, with a message that names the URL the remote unit gave. When the two URLs differ, yours is used. - A remote unit is a read-only dependency. Its errors fail the command, its
warnings and infos are not shown, and your
[lints]table does not apply to it.ridl buildgenerates code for it beside your own packages, andridl fmtnever changes it. No catalog, baseline snapshot orridl lockentry includes it; its owner publishes those. - The compiler skips the entries that macOS adds to an archive when it reads a
remote unit: any file or directory whose name starts with
._, and the__MACOSX/directory. Your own packages are read as they are.
Where the compiler reads a remote unit from
For each URL, the compiler takes the first source that applies:
vendor/<key>/at the manifest root, when that directory exists andridl.lockpins atreefor the URL.- The cache,
~/.ridl/cache, for the hash thatridl.lockpins. - A fetch from the URL, which stores the archive in the cache. Only commands
that may fetch do this, and never under
--frozen.
When ridl.lock pins no tree for the URL, the compiler reads the cache or
fetches first, so that a pin exists. It then verifies a vendored directory, if
there is one, against that pin and reads the directory in place of the cache
entry.
Which commands fetch and which only read matters:
| Commands | Read from vendor/, then the cache | Fetch on a miss | Write ridl.lock |
|---|---|---|---|
ridl check, ridl build | yes | yes | yes |
ridl check --frozen, ridl build --frozen | yes | no | no |
ridl baseline | yes | yes | yes |
ridl vendor | cache only, never vendor/ | yes | yes |
ridl vendor --check | vendor/ only | no | no |
ridl test, ridl lock, ridl diff, ridl lsp, ridl mcp | yes | no | no |
The ridl check section of the CLI reference
says when a run writes ridl.lock.
What ridl.lock pins
ridl.lock sits at the manifest root, beside ridl.toml. Each remote import
has one entry, keyed by its URL:
[entries."https://registry.example.com/veh/common@v1.0.0"]
sha256 = "<the SHA-256 of the fetched archive>"
tree = "<the hash of the unpacked tree>"
sha256pins the bytes of the downloaded archive. A fetch whose content hashes to another value isMANI-102, and the import is dropped.treepins the unpacked tree: a SHA-256 over every regular file in it, with its path and length. It is what the compiler checks on a cache entry and on a vendored directory. A tree that does not match isMANI-107. A tree that holds a symbolic link or another file that is not a regular file is alsoMANI-107, and the message names the file.
Commit ridl.lock. It is generated: do not edit it by hand. A ridl.lock
written before the tree hash existed has no tree. A run without --frozen
adds it. A run with --frozen, and ridl vendor --check, report MANI-108
instead, because they never write.
Copy the units into the repository with ridl vendor
Run the command from any directory of the project:
$ ridl vendor
vendored veh.common
The command acts on the manifest root above the directory, so vendor/ is at
the workspace root whichever member you run it from. It copies each remote unit
of your [imports] to vendor/<key>/ and updates ridl.lock. It also removes
the directories in vendor/ that no [imports] key names. The
ridl vendor section of the CLI reference gives
the exact rules for what the command writes, removes and leaves alone.
Commit vendor/ together with ridl.lock. The tree hash covers the bytes of
every file, so keep Git from converting line endings in vendor/; the CLI
reference gives the .gitattributes line.
After a change of URL in [imports], run ridl vendor again to replace the
directory.
Check the directory in CI
ridl vendor --check compares vendor/ with the imports and with ridl.lock.
It fetches nothing and writes nothing, so it suits CI:
$ ridl vendor --check
It exits 0 when vendor/ matches. A failure reports MANI-109, MANI-110,
MANI-107, MANI-103 or MANI-108; the
CLI reference gives the meaning of each and the
exit codes. In each case, run ridl vendor and commit the result.
Build with no network
An air-gapped build needs the units in the repository. On a machine with a network:
$ ridl vendor
$ git add ridl.lock vendor
$ git commit -m "chore(deps): vendor the remote units"
On the machine with no network, and no cache:
$ ridl check --frozen
Under --frozen, the compiler reads vendor/<key>/ first, verifies it against
the tree in ridl.lock, and fetches nothing. The same holds for
ridl build --frozen.
A project that does not vendor can still build offline, as long as the cache
holds every pin: ridl check --frozen reports MANI-103 for a URL with no
ridl.lock entry and MANI-104 for a pinned unit that is not in the cache. The
message of MANI-104 names ridl check and ridl vendor as the fix.
Fresh clones
ridl test, ridl lock, ridl diff, ridl lsp and ridl mcp never fetch and
never write ridl.lock. In a fresh clone of a project that imports remote
units and does not commit vendor/, the cache is empty, so run ridl check
once first. It fetches the units into the cache and writes ridl.lock.
Otherwise these commands report MANI-103 or MANI-104. A project that
commits vendor/ and ridl.lock needs no such step.
The vendor/ directory is reserved
vendor/ at the manifest root belongs to ridl vendor. The loader does not
read source files there as packages of your project:
- In a workspace, a
membersentry that isvendoror a path under it isMANI-017, an error, and the entry is not loaded. - In a standalone package, a
.typl,.ridlor.rsdlfile undervendor/that is not inside a directory named by an[imports]key isMANI-018, a warning with the lint namevendor-source-not-loaded, and is not loaded. One warning is reported per file. - The name is reserved at the manifest root only. A directory named
vendordeeper in a package is an ordinary source package. A remote unit's ownvendor/directory is part of its tree and its hash, and the compiler skips it when it reads the unit.
This is a breaking change for a project that kept source packages in a
vendor/ directory at its root. Those packages stop loading. Move them to
another directory, and update the members list of the workspace or the
imports that name them.
ridl fmt also skips vendor/ at every manifest root, so it does not rewrite
the units that belong to other owners.
Where to go next
- Getting started shows the workspace that a remote import joins.
- The
ridl checkandridl vendorsections of the CLI reference list the exit codes. - The lints page lists
vendor-source-not-loaded. - ADR-0002 records the module system, the lockfile and the vendor directory.
Using the generated Rust code
ridl build --emit rust turns a package into a Rust crate. This chapter shows
how an application uses that crate: the types it holds, the two ways to call an
interface — async and blocking — the Cargo features that select what the crate
contains, and the failures a call can report. The flag itself is described in
the CLI reference.
Most code snippets in this chapter are taken from
examples/cabin/consumer/src/main.rs, a program that the repository's
just demo gate builds and runs against the crate generated from
examples/cabin/cabin.ridl. A snippet that is taken neither from that program
nor from that schema is marked as an illustration in the sentence before it,
and is not compiled. The interface the snippets call, and the types it uses,
from that schema:
package veh.cabin
/// Cabin air temperature, in degrees.
type Temperature: integer [-40..85]
/// A percentage of full scale.
type Level: integer [0..100]
/// An averaging window, in milliseconds.
type Window: integer [0..100000]
/// The average a query replies with. Its range is wider than `Level`'s,
/// so a reply is not constrained to the scale the command takes.
type Average: integer [0..1000]
/// The health of a cabin subsystem.
enum Health {
/// The subsystem works normally.
OK = 0
/// The subsystem works, with a fault that needs attention.
WARN = 1
/// The subsystem does not work.
FAIL = 2
}
/// Raised when a subsystem leaves `OK`.
struct Warning {
/// The warning code, as a percentage of full scale.
code: Level
/// The health the subsystem moved to.
health: Health
}
/// The cabin's own interface: a signal, an event, a command and a query, so
/// the proof covers every interaction kind that carries a payload.
interface Cabin {
/// The current cabin air temperature.
signal temperature: Temperature @10ms
/// Sent when a subsystem leaves `OK`.
event warning: Warning @[100ms..1s]
/// Sets the cabin level, below 100.
command setLevel(level: Level) @[..50ms] [
require level < 100
]
/// The average level over the window, in milliseconds.
query average(window: Window): Average @[..200ms] [
require window > 0
ensure result >= 0
]
}
What the crate contains
The build writes one <package>.rs file per package, a lib.rs that declares
the module tree, and a Cargo.toml. A package veh.cabin is the module
veh::cabin. The crate is named after the [package] name of the ridl.toml
manifest, with each . replaced by _: examples/cabin declares the package
veh.cabin, so its crate is veh_cabin. A [workspace] manifest names no
package, and a build from one names the crate ridl_generated.
The cabin program imports the package module as api, the face of the
interface Cabin as cabin, and the face's prelude:
use api::cabin;
use api::cabin::prelude::*;
use ridl_loopback::Loopback;
use ridl_rt::contract::{CatalogRef, Interface};
use ridl_rt::encoding::Podbuf;
use ridl_rt::payload::{Payload, Ref};
use ridl_rt::sample::Provenance;
use veh_cabin::veh::cabin as api;
prelude brings into scope the traits that carry the fixed methods of the
face: ridl_rt::face::Bind (new), Events (next_event), Publish
(commit), and, under the crate's std feature, Timeout (with_timeout and
set_timeout); and, without a name, the interface's own Subscribe
(subscribe_<event>) and Invalidate (invalidate_<signal>) traits. Each
interface's prelude holds only the traits its face implements. These methods
are trait methods rather than inherent methods so that a member of the
interface can have one of these names. Without the use of the prelude,
cabin::Client::new does not compile. The other imports are for the snippets
below: Loopback is the runtime, CatalogRef and Interface name the catalog
the face was generated from, and Provenance is read from a signal sample. A
Loopback carries FlatBuffers, which is its default encoding; the face takes
the encoding from the port it is bound to and names none itself.
The package module holds:
- The domain types. A struct, an enum, an enum set, a union and a named
scalar each become a Rust type. A constrained type is built with
new, which checks the typl constraints and returns an error when the value breaks one;new_uncheckedskips the check and is for a value already known to satisfy them.getreturns the value of a named scalar. - The codecs of each payload type, one per encoding the manifest names
under
[backend.rust] encodings:flatbuffers,proto3, or both. Each codec isencode,verify,decodeand aMAX_SIZEbound; with no list the module holds the types only. Each payload type also has a<T as Payload<FlatBuffers>>::Buffertype and a zeroedBUFFERvalue, sized by that type's ownMAX_SIZE, and the same inProto3. EveryMAX_SIZEis finite: a declared type whose encoding has no finite bound, such as astringwith no length bound, is refused in either encoding, andridl buildfails with an error that names the type and the cause; the proto3 codec record's known gaps give the limit this sets and its tracking issue. The face calls the codec; an application calls it only when it moves payloads by itself. - The interaction descriptors: one unit type per interface (here
api::Cabin) that implementsridl_rt::contract::Interfaceand carries the interface'sCATALOG,NUMBERandNAME. Each interface also implementsridl_rt::contract::Sizes<E>for each encoding the package carries, the size table of that codec for the interface: its reservations, its largest buffer (MAX_BUFFER_SIZE), and the claim and event buffers the face holds. Like the codecs, the descriptors are present only when the manifest names an encoding under[backend.rust] encodings. - The face: one module per interface that has one, named after it (here
api::cabin), present under the same condition. The face is what the application calls.
The face
Each interface module holds the parts its own interactions need:
| Item | Present when the interface carries | Used by |
|---|---|---|
Client | any interaction | the consumer: reads, subscriptions, calls |
Publisher | a signal or an event | the producer: publishes values, raises events |
Provider (trait) | a command or a query | the provider: the application implements it |
serve | a command or a query | the provider: settles the calls waiting on a port |
Event (enum) | an event | the consumer: one variant per event |
blocking (module) | an event, a command or a query | a thread that waits: blocking Client and serve |
An interface that carries only fixed declarations gets no face module, and
neither does an internal interface, whose descriptors are crate-visible (the
design record gives the reason). A signal-only interface gets a
Client and a Publisher and no blocking module, because a signal read
returns at once.
An interface the face cannot carry gets none of these items and no interaction
descriptor. In their place, the package module holds a
__RIDL_NO_FACE_<NAME> constant whose documentation names the interface and
the reason; the CLI reference lists the cases.
A call always sends one named type. A command or query with exactly one
parameter of a named type sends that type. Any other parameter list gets an
argument struct that the compiler names <Interface><Call>Params, with one
field per parameter in declaration order, each carrying the parameter's
documentation. A call with no parameter sends the same kind of struct with no
field. A query whose reply is a tuple, or that declares no return type, gets a
reply struct named <Interface><Call>Reply in the same way. These structs are
generated types of the package module, and the face methods take and return
them like any other payload.
The wire backends emit the matching positional .proto messages and .fbs
tables under the names assigned by lowering. When an induced payload has no
finite bound in an encoding the manifest names — no finite FlatBuffers bound
under flatbuffers; no finite proto3 bound, or a member proto3 cannot
represent, under proto3 — its interface is skipped with the refusal constant
described above rather than emitting a descriptor that names a missing codec.
The face opens no socket and holds no runtime. Each Client, Publisher and
serve runs over a port that the application passes in, and a port comes from
a runtime. The one runtime in this repository is ridl-loopback, which connects
the faces of one process to each other. The cabin program binds every face to
it:
let rt = Loopback::new(CATALOG);
let mut publisher = cabin::Publisher::new(rt.attach());
let mut client = cabin::Client::new(rt.attach());
let mut blocking_client =
cabin::blocking::Client::new(rt.attach()).with_timeout(CLIENT_TIMEOUT);
let mut handler = rt.handler();
let mut provider = Cabin {
levels: Vec::new(),
average: api::Average::new(7).expect("7 is inside Average's range"),
};
CATALOG is a constant of the program that holds the catalog the face was
generated from, read from <api::Cabin as Interface>::CATALOG. Binding a face
compares the port's catalog with it, and panics when they differ; see
Failures. CLIENT_TIMEOUT is a Duration the program declares,
and with_timeout comes from the Timeout trait of the prelude.
Implementing the provider
The application implements one method per command and per query. A command's method returns nothing, and a query's method returns the reply type:
struct Cabin {
levels: Vec<i64>,
average: api::Average,
}
impl cabin::Provider for Cabin {
fn set_level(&mut self, level: &api::Level) {
self.levels.push(level.get());
}
fn average(&mut self, _window: &api::Window) -> api::Average {
self.average
}
}
The method is called only with a request that passed every check: the payload
verified, its typl constraints hold, and its require clauses are true. For a
query, serve evaluates the ensure clauses on the reply before it sends it.
The provider does not repeat those checks.
Async calls
The Client methods for a command and a query, next_event, and serve each
return a named future. The futures are executor-agnostic: they take no
dependency on an async runtime, and any executor can poll them. A call is sent
when the method runs, not when its future is first polled.
Inside an async function, the calls read like this. This snippet is an
illustration and is not compiled; port and handler stand for ports a
runtime gives the application, and provider for a value that implements
cabin::Provider:
// Illustration: any executor polls these futures.
let mut client = cabin::Client::new(port);
client.set_level(api::Level::new(42).expect("in range")).await?;
let average = client.average(api::Window::new(10).expect("in range")).await?;
// On the provider side, `serve` settles calls until the handler port fails.
// Its output is `Result<Infallible, ProviderError>`: it never resolves to `Ok`.
let failure = cabin::serve(handler, &mut provider).await;
No executor is required either. The cabin program polls the same futures by
hand, the way a frame loop does. cx is a Context built from the waker
ridl_rt::task::noop_waker(), and poll_once is a function of the program
that polls a future once with it:
{
let mut serve = cabin::serve(&mut handler, &mut provider);
let mut call = client.set_level(api::Level::new(42).expect("42 is inside Level's range"));
// Step 1: the call was sent when `set_level` ran, and nothing has
// served it yet.
assert!(poll_once(&mut call, &mut cx).is_pending());
// Step 2: one pass of the provider side settles it.
assert!(poll_once(&mut serve, &mut cx).is_pending());
// Step 3: the acknowledgment is taken.
assert_eq!(poll_once(&mut call, &mut cx), Poll::Ready(Ok(())));
}
ridl_rt::task exists only under ridl-rt's std feature, which the
generated crate's own std feature turns on. A frame loop built without it
uses core::task::Waker::noop() instead, which Rust 1.85 and later provide.
A serve future settles at most 32 calls in one poll. When it reaches that
limit, it wakes its own waker and returns Pending, so that an executor can run
other tasks before it polls the serve future again. Under noop_waker that
wake is lost, so a frame loop that polls once per frame settles at most 32 calls
per frame.
The future of a command or a query call, and the serve future, panic when
they are polled again after they resolved. The next_event future does not:
polled again, it waits for the next occurrence.
Blocking calls
The blocking module holds the same Client and serve for code that waits on
a thread instead of polling. Each blocking call is
ridl_rt::task::block_on over the async call, so the two forms cannot differ
in what a call does; the blocking form only adds the wait and a timeout.
A blocking client takes its timeout with with_timeout (or set_timeout). The
default is no timeout. A call returns its outcome, or the timeout result when
the timeout passes first. A signal read and a subscribe_* call return at
once, as in the async client.
Blocking serve takes the handler, the provider and an optional timeout. It
returns Ok(()) when the timeout passes, so a thread that also does other work
can call it in a loop. SERVE_PASS is a Duration of 50 ms the program
declares:
/// Serves `provider` over `handler` on the calling thread until `done` is
/// set: `blocking::serve` returns `Ok(())` at each pass's timeout, and the
/// loop calls it again. A failure of the handler port ends the loop with it.
fn serve_until_done(
handler: &mut ridl_loopback::HandlerHandle,
provider: &mut Cabin,
done: &AtomicBool,
) -> Result<(), ridl_rt::error::ProviderError> {
while !done.load(Ordering::Acquire) {
cabin::blocking::serve(&mut *handler, &mut *provider, Some(SERVE_PASS))?;
}
Ok(())
}
The cabin program runs that loop on a second thread and calls the blocking
client on the first. DoneOnDrop is a type of the program that sets done
when it is dropped, so that the serving loop also ends when the first thread
panics:
let done = AtomicBool::new(false);
let served = std::thread::scope(|scope| {
let serving = scope.spawn(|| serve_until_done(&mut handler, &mut provider, &done));
let _ends_the_loop = DoneOnDrop(&done);
let acknowledged =
blocking_client.set_level(api::Level::new(43).expect("43 is inside Level's range"));
done.store(true, Ordering::Release);
assert_eq!(acknowledged, Ok(()));
serving.join().expect("the serving thread does not panic")
});
With no timeout, a blocking call returns only with its outcome, blocking
next_event only with an event or a read failure, and blocking serve only
with a failure. At the timeout, blocking next_event returns Ok(None).
Converting a payload between encodings
With [backend.rust] transcode = true in the manifest, the generated crate
carries one function for each ordered pair of distinct encodings that
[backend.rust] encodings lists, on each payload type that has a Payload
implementation under both. The manifest must list at least two encodings.
[backend.rust]
encodings = ["flatbuffers", "proto3"]
transcode = true
The function is named transcode_<s>_to_<d>, where <s> is the source
encoding and <d> the target, spelled as in the manifest with - replaced by
_. For a type Speed the crate holds:
impl Speed {
pub fn transcode_flatbuffers_to_proto3<'o>(
bytes: &[u8],
out: &'o mut [u8],
) -> Result<
ridl_rt::payload::Ref<'o, Self, ridl_rt::encoding::Proto3>,
ridl_rt::payload::TranscodeError,
>;
pub fn transcode_proto3_to_flatbuffers<'o>(/* the same parameters */)
-> Result</* a Ref in the FlatBuffers encoding */, /* the same error */>;
}
The function verifies bytes under the source encoding, decodes the value, and
encodes it under the target encoding into out. It calls
ridl_rt::payload::transcode, which allocates no buffer of its own; the
type's decode may allocate, as for a string field. An out that is too
small is TranscodeError::Encode with EncodeError::Capacity, and bytes that
do not verify are TranscodeError::Verify. A function exists only where
the type has a Payload implementation under both encodings of the pair.
A podbuf buffer starts on an 8-byte boundary. A caller passes an
out for a podbuf target, and bytes for a podbuf source, that starts on one:
the type's <T as Payload<Podbuf>>::BUFFER is an Aligned array, and
ridl_rt::podbuf::Aligned wraps any other array. An out that is not aligned
is EncodeError::Unaligned, and bytes that are not aligned do not verify.
A map is written in key order, so a value whose map is in another order
decodes from a podbuf source in key order.
Cargo features
The generated Cargo.toml declares two features, both on by default:
| Feature | Enables |
|---|---|
std | The blocking module of every face, the Timeout trait in the prelude of each interface that has a blocking module, and ridl-rt/std. With it off, only the async face remains. |
validate-pattern | The check of a typl match pattern in a constructor, through the regex crate. With it on, the crate links the standard library. With it off, new does not check patterns; range and length checks are not affected. |
With std and validate-pattern both off, the generated crate and ridl-rt
are both no_std and build for a target that has no standard library. Its
lib.rs declares this with
#![cfg_attr(not(any(feature = "std", feature = "validate-pattern")), no_std)].
The crate still needs an allocator: under the same condition its lib.rs
links alloc under the name std (extern crate alloc as std;), because the
package files name ::std::string::String and ::std::vec::Vec for strings,
bytes, arrays and maps. Single-file mode writes no lib.rs, so the crate that
includes the file decides. A crate that is always no_std declares
extern crate alloc as std; in its own root. A crate that is no_std only
under a condition declares it under the same condition: where the alias is
declared, ::std names alloc, so a path that only the standard library has,
such as ::std::sync::LazyLock, no longer resolves.
A target where regex is too large, or one that does not need the blocking
face, turns the defaults off and selects what it needs. This Cargo.toml
fragment is an illustration and is not compiled:
[dependencies]
veh_cabin = { path = "generated", default-features = false }
The generated crate depends on ridl-rt with one feature per encoding that the
manifest names under [backend.rust] encodings — flatbuffers, proto3,
podbuf, or a combination, which the codecs need — and no feature for a types-only build. An application that names ridl-rt itself, for example to use
ridl_rt::task::noop_waker or the error types, selects from its features:
ridl-rt feature | Default | Enables |
|---|---|---|
flatbuffers | off | The flatbuffers module the generated codec calls. |
std | off | The standard library; the task module: block_on, noop_waker and flag_waker; the face::Timeout trait; and the propagation hook of the trace module, set_propagation. |
proto3 | off | The proto3 module the generated proto3 codec calls. |
podbuf | off | The podbuf module the generated podbuf codec calls: the preamble check, the in-place reads, the writer and the Aligned buffer type. |
ridl-rt has no dependency in any feature combination. It builds for wasm32,
and its minimum Rust version is 1.88. The generated crate declares edition 2021
and rust-version = "1.88", and builds with Rust 1.88 or later with every
cargo feature on or with default features off. To keep Rust 1.88, resolve its
dependencies as Stability describes. When you build the
generated crate as its own workspace root, its edition 2021 selects resolver 2,
which by default does not read rust-version. Resolve with
CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback; cargo then selects the
dependency releases that Rust 1.88, the manifest's rust-version, supports, as
just compat-check does.
The wasm-exports feature
With [backend.rust] wasm = true in the manifest, or codec = true on another
backend table, which implies it, the generated Cargo.toml declares a third
feature, wasm-exports, off by default, which turns std on.
It compiles the exports of the crate's wasm module when the target is
wasm32, and nothing on any other target. The manifest declares no crate type,
so a Rust program that depends on the crate links no export. The WebAssembly
module is built from the crate's directory with:
CARGO_PROFILE_RELEASE_OPT_LEVEL=z CARGO_PROFILE_RELEASE_LTO=true \
CARGO_PROFILE_RELEASE_PANIC=abort \
cargo rustc --release --target wasm32-unknown-unknown --crate-type cdylib \
--features wasm-exports
The module exports ridl_alloc, ridl_free, ridl_catalog_hash,
ridl_verify and ridl_type_id. ridl_type_id returns the type id of a payload
type named by its qualified name. It returns -1 for a name the list does not hold,
an empty name, bytes that are not UTF-8, a null pointer with a length, a length
above isize::MAX, and a pointer and length whose sum overflows the address
space. A range past the end of the module's memory traps, as any read past it
does.
The three variables are the recommended size settings, and cargo reads them
from the environment. The module is
target/wasm32-unknown-unknown/release/<crate name>.wasm.
Failures
A call reports its failure as a value. ClientError, CallError, Contract,
Transport and ProviderError are in ridl_rt::error; the port errors, such
as SendError and ReadError, are in ridl_rt::port.
A command or query call resolves to Result<_, ClientError>:
ClientError::Send— the call was not sent. Arequireclause that is false is reported here, asSendError::Contract(PreconditionFailed), because the client evaluates therequireclauses before it sends anything.ClientError::Call— the call was sent, and its outcome is a failure.CallError::Contractis a contract error the provider side reported:InvalidValue(the payload breaks its typl constraints),PreconditionFailed,ContractBroken(anensureclause is false) orUnknownInteraction(the peers disagree on an interface number or an ordinal).CallError::Transportis an infrastructure failure:Timeout,Undelivered,Down,CorruptorBusy.ClientError::Read— the port failed while the reply was read.
When a call's max bound passes, a sent command resolves to
Transport::Undelivered and a sent query to Transport::Timeout. The bound is
measured on the port's clock, so it depends on the runtime. The clock of
ridl-loopback moves only when the program calls Loopback::advance, so over
it a bound passes only after the program advances the clock past it; until
then, a call that is never served returns only at the blocking client's own
timeout.
A signal read returns Result<Sample<T>, ReadError>. serve resolves to
ProviderError::Serve when the handler refuses the interface's members, which
it reports on the first poll, and to ProviderError::Claim when the handler
port fails while a call is read. The calls it settled before a failure stay
settled.
A catalog mismatch panics. Binding a Client or a Publisher, and starting
serve, compare the port's catalog — the unit name and the catalog hash —
with the CATALOG the face was generated from. When they differ, the face was
generated from a different version of the unit than the one the runtime
serves, and the bind panics with a message that names the interface and both
catalogs. A program that must not panic compares the two itself before it
binds. This snippet is an illustration and is not compiled; port stands for
the port the program is about to bind:
use ridl_rt::contract::Interface; // carries `CATALOG`
use ridl_rt::port::Attached; // carries `catalog()`
if port.catalog() != <api::Cabin as Interface>::CATALOG {
// refuse the port
}
The catalog and its hash are described in The catalog descriptor.
TypeScript
ridl build --emit typescript writes the domain types of a package as one
.ts file: branded scalars, interfaces, enums, enum sets, discriminated unions
and init functions. It emits no face and no feature selection, so there is no
async or blocking API to choose between.
With [backend.typescript] codec = true it also writes <base>.codec.ts, for
every package that has a payload root with a codec (its own, or one of another
package that its interfaces name): one
encode<Name> and one decode<Name> function per payload type, which take
and return the plain objects of <base>.ts and call the object API that
flatc generates; one table per interface, which gives each interaction its
interface number, its ordinal, its kind and the functions of its payloads; and
the catalog's name and hash, the payload type names and the rule texts that
the wasm codec takes at load. ridl does not run flatc. Build the files in
two steps:
ridl build --emit typescript --emit flatbuffers --out-dir <out-dir>
flatc --ts --gen-object-api -o <out-dir> <out-dir>/<base>.fbs
The codec file imports flatc's output from ./<package path>.js (for
veh.cabin, ./veh/cabin.js), so flatc's -o is the directory of the
TypeScript files. When a payload type of an interaction is a named scalar or
an enum set of another package (ridl.std.Name as the request of a query, for
example), the codec file goes through that package's own codec file,
<package>.codec.ts, which the build writes beside it; flatc then runs on
that package's schema too, in the same directory:
flatc --ts --gen-object-api -o <out-dir> <out-dir>/<base>.fbs <out-dir>/<package>.fbs
A package with no such payload root, and ridl.std when no codec file imports
its module, gets no codec file. The converters cover every typl kind: a union by
its kind and value, a tuple by its named fields, an array, a map by its
entries, an optional field (absent when the buffer leaves it out; an optional
array, bytes or map field reads back as empty, because the object API cannot
leave a vector out of the buffer), and an induced tuple of an interaction,
which the codec file exports as an interface (export type X = Record<string, never> for one with no field). An optional array element, map
key or map value is refused, as the Rust codec refuses it. A named scalar or an
enum set of another package converts by a type assertion, and a payload type of
another package that is one goes through that package's own codec file; a
payload type that reaches a struct, a union or an enum of another package is
refused with an error that names both. An internal payload type has no
encode/decode, because <base>.ts does not export its type.
The codec table design record gives the rules in full.
Documentation in the generated code
The doc comment of a declaration or a member (see Documenting your API) becomes the doc comment of the item generated from it: rustdoc in the Rust crate, TSDoc in the TypeScript module. The generated doc also lists the item's rules: the facts the schema states about the values the item admits and the way it is used. They are the same lines the editor's hover shows in its Contract list.
A generated doc holds these parts, in this order, and leaves out each part that is empty:
| Part | Rust | TypeScript |
|---|---|---|
| doc text | /// lines; each link an intra-doc link | the comment's text; each link {@link Name} |
| rules | a # Rules section, one bullet per rule | @remarks, then a Rules: list |
| typl tags | none | @unit, @range, @bounds |
@see | a # See also section, one bullet each | one @see tag each |
@since | one Available since <version>. line each | one @since tag each |
@deprecated | the #[deprecated] attribute | one @deprecated tag |
A link, or an @see target, whose target has no item in the same output is
written as a code span of the target's qualified name, such as
veh.common.Speed, so that no doc in the crate or the module that ridl build
emits holds a link that does not resolve. Both backends link only to a
declaration of the package itself or to a declaration of another package that
this package reaches through a type. A target that this package does not reach
through a type is written as a code span. In both languages, so is an
internal declaration, a constant the backend does not emit (such as a bytes
constant), and a member of a named scalar or of a constant, which has no member
item. In Rust it is also the case for an event, a fixed, an interface with no
face module, a target whose path Rust spells with a raw identifier (r#move),
and an item whose name the module of a child package takes, which a program
reaches only through the hidden __ridl_package module. In TypeScript it is
also the case for an interface, an interaction and a union arm, which the module
holds no item for, and for a declaration of another package that the module
does not import. @labels is not rendered.
A Rust link uses the short form [Speed] when that text is the target's Rust
name and the target is in the module that holds the doc. When a Markdown
reference definition uses the same label, the generated shortcut uses code
formatting to preserve the resolved target. Other links use an
absolute path from the crate root, such as crate::veh::common::Speed, which
resolves in the crate ridl build emits. A program that brings a generated file
in with include! inside a module of its own moves the items away from those
absolute paths, so rustdoc reports those links as unresolved.
In the Rust face, the doc of an interaction goes on these methods: for a signal,
the method of the async Client, of the blocking Client and of the
Publisher; for a command or a query, the method of the async Client, of the
blocking Client and of the Provider trait; for an event, the
subscribe_<event> method of the async and of the blocking Client, and the
method of the Publisher. The Client and Provider methods also carry the
interaction's #[deprecated]; a Publisher method and a subscribe_<event>
method carry the doc without #[deprecated]. The variant of an event in the
Event enum carries only a sentence the backend writes. A method's doc is the
interaction's doc text, then a sentence the backend writes about the method
itself, then the # Rules section, then, for a command or a query, a
# Parameters section (one bullet per parameter with its name and its doc, and
the parameter's rules as nested bullets), then the # See also section and the
Available since lines. The doc of an interface goes on its face module, in
the same order around the module's own sentence.
What the generated code checks
The rules lists state the contract, not what the code checks. On each item the
backend emits, every rule the schema declares is listed, in the same words in
both languages, whether or not the generated code checks it. The sentence the
backend writes about a method describes what that method does, its checks
included: the doc of a named scalar's new says that it returns an error for a
value that breaks a constraint the generated code checks, the doc of a Client
call method says what happens when a
require clause fails, and the doc of a query's Provider method says that
serve discards a reply that breaks an ensure clause.
This table records, for each kind of rule, the generated item that checks it:
| Rule | Rust | TypeScript |
|---|---|---|
| range | checked by new, TryFrom and verify | not checked |
| step | checked by new, TryFrom and verify | not checked |
| unit | not checked | not checked |
| length | new, TryFrom, verify; encode for anonymous fields | not checked |
| pattern | checked by new, TryFrom and verify, under a feature | not checked |
| collection bound | checked by encode and verify | not checked |
| timing | not checked | not checked |
| response bound | max checked by the future of a Client call | not checked |
| errors | not checked | not checked |
require | checked by the call methods of Client and by serve | not checked |
ensure | checked by serve | not checked |
In the Rust column:
newandTryFromare the constructors of a named scalar.verifyis the FlatBuffers codec's check of a received payload. It also checks a field whose type is written inline, which has no constructor.encodealso checks anonymous string and byte lengths and variable-length collection counts. Exact array counts are enforced by their Rust type. Other field constraints and map-key uniqueness are checked byverify.- The pattern is checked only when the crate is built with the
validate-patternfeature (see Cargo features). - A call's future, in the async and in the blocking
Client, resolves to a failure when the response bound'smaxpasses (see Failures). Itsminis not checked. - A query that declares an error type gets no face, so no generated item carries the errors rule.
- A
Clientcall method evaluates therequireclauses before it sends.serveevaluates them before it calls theProvider, and evaluates theensureclauses on the reply before it sends the reply. TheClientdoes not evaluateensure. verifyand the face exist only when the manifest namesflatbuffersorproto3under[backend.rust] encodings(see What the crate contains).
The TypeScript module holds types, constants, the <Name>Bits objects and init
functions only, and none of them checks a value.
An item that the generated code needs but a program does not name carries no
doc. In Rust it is pub(crate), such as the constraint check behind new and
the codec's functions for each table, or pub under #[doc(hidden)] where code
outside the crate must reach it, such as the descriptor of each interaction,
which a runtime reads. Two private Rust constants are notes rather than such
items: __RIDL_NO_FACE_<NAME>, left where an interface's face was not emitted,
and __RIDL_FB_NO_CODEC_<Name>, left where a declaration's FlatBuffers codec
was not emitted. Each carries a doc that says what was not emitted and why, for
a reader of the generated source; rustdoc does not show them.
Further reading
- The interaction face — the design record of everything the Rust backend emits for an interface.
- ridl-rt — the design record of the runtime library: its ports, features and error types.
Writing a port
This chapter is for the author of a runtime: a crate that implements the port
traits of ridl-rt for a platform or a transport, so that a generated face can
run over it. It names the traits by role, shows how to test an implementation
with the published conformance suite, and lists what a runtime that carries
frames over a transport must decide for itself. The exact signature of every
trait is in the ridl_rt::port documentation.
The runtime and the face
A generated face holds no runtime. Each Client, Publisher and serve of
the face runs over a value that implements some of the traits of
ridl_rt::port, and calls only those traits; it never names the runtime that
implements them (Using the generated Rust code).
A runtime is a separate crate that implements those traits.
Three properties hold for every port method. It returns without waiting: a face that waits registers its interest with the runtime and reads the port again when the runtime wakes it. It names no payload type: it carries an interface number, an ordinal and bytes, and the generated code encodes and decodes. It takes no current time: the runtime reads its own clock.
A runtime exposes one handle type for each port trait it implements, and it may
also offer an aggregate handle that implements every trait one interface's face
needs. ridl-rt already implements every port trait for &mut P, and the
traits whose methods all take &self also for &P, so a runtime does not
write those implementations. No port trait has Send or Sync as a
supertrait. A runtime whose handles are used from more than one thread makes a
handle Send + Sync when all its traits take &self, and Send when one of
them has a &mut self method, and checks this itself.
The port traits
| Trait | Role | Used by |
|---|---|---|
Attached | names the catalog the port serves | the face, when it binds; a supertrait of every interaction port |
Encoded | states the encoding of the bytes the port carries | the face: every port a face is bound to; a conformance fixture need not |
Clock | gives the time envelopes are stamped with | the runtime; a Client with calls, to measure a call's bound |
SignalReader | reads a signal's current value | the consumer: Client |
SignalWriter | stages signal changes and publishes them with commit | the producer: Publisher |
EventSource | subscribes to events and takes the next occurrence | the consumer: Client |
EventSink | raises an occurrence | the producer: Publisher |
Caller | sends a command or a query and reads its outcome | the consumer: Client |
Handler | serves members, takes each call, and settles it | the provider: serve |
FixedReader | reads a provisioned fixed value | the application, directly; the face has no method over it |
ScannableSignals | walks the signals that changed since a mark (extension) | a consumer that scans a store; no generated face calls it |
CoherentSignals | reads several signals from one publication (extension) | a consumer that needs one coherent set; no generated face calls it |
Wakeable | wakes a waiting task when a port may have changed (extension) | a Client with events or calls, and serve |
Attached and Clock. Attached has one method, catalog, which returns
the CatalogRef the port serves; every interaction port has it as a
supertrait. Clock has one method, now. A runtime has one clock and stamps
every envelope from it. Neither method returns an error.
Encoded. A port states the encoding of the bytes it carries through one
associated type:
use ridl_rt::encoding::FlatBuffers;
use ridl_rt::port::Encoded;
impl Encoded for MyPort {
type Encoding = FlatBuffers;
}
A generated face reads that encoding from the port it is bound to and names no
encoding itself, so every port a face is bound to implements Encoded. It is
not a supertrait of Attached: a port that never meets a face, such as a
fixture for the conformance suite below, is not forced to name an encoding. One
session carries one encoding, so a transport that serves several hands out one
port type per session, each typed with its own. ridl-loopback makes the choice
a type parameter: Loopback::new(catalog) carries FlatBuffers, and
Loopback::<E>::with_encoding(catalog) carries E.
Signals. SignalReader::read copies a signal's current value into a buffer
and returns its provenance, its freshness and its envelope. SignalWriter
stages a change with set, invalidate or touch, and commit publishes
every staged change with one timestamp per interface, so that several signals
of one interface updated together appear as one publication. A read fails with
ReadError, a staged change with WriteError; commit cannot fail.
Events. EventSource subscribes to and unsubscribes from the events of an
interface, and next takes the next occurrence of anything subscribed, with
its interface and its ordinal. EventSink::raise sends one occurrence; an
occurrence is not staged and has no commit. A subscription fails with
SubscribeError, next with ReadError, and raise with RaiseError.
Calls. Caller sends a command or a query and returns a Correlation, the
identifier of that call to its caller. ack reads a command's acknowledgment
and reply a query's reply, once each is known, and forget releases a
correlation whose outcome the caller no longer needs. Handler::serve starts
presenting the calls to the listed members, next_claim presents each
delivered call once as a Claim, and settle gives the claim its outcome:
the reply bytes, or the CallError the caller sees. A send fails with
SendError, a serve with ServeError, next_claim and reply with
ReadError, and a settlement with SettleError. The outcome of a call is a
CallError from ridl_rt::error, not a port error.
Fixed. FixedReader::read_fixed copies a provisioned value into a buffer.
It fails with ReadError. How a value is provisioned into the runtime is the
runtime's own API, not a port.
Extensions. A runtime may omit ScannableSignals and CoherentSignals,
because they describe mechanisms that some runtimes have.
ScannableSignals reports a generation counter for each interface and writes
the signals that changed since a Watermark; neither method returns an error.
CoherentSignals::read_coherent answers several ordinals of one interface from
one publication and fails with ReadError. A runtime whose transport delivers
each field separately cannot present it. Wakeable::wake_on stores a waker
for an Interest — a call's outcome, a free call slot, an event, or a claim —
and wakes it when that interest may have changed. A runtime that serves a
generated async client implements Wakeable, because every face future that
waits is bounded on it.
The documentation of the event and call traits also states how a runtime
delivers the trace context a sender passes. Every error enum of
ridl_rt::port is #[non_exhaustive].
Testing a port
The crate ridl-rt-conformance holds the port contract tests as functions
generic over a runtime. A runtime runs the whole suite from its own tests. Add
it as a dev-dependency at the same version as ridl-rt, because its public
surface follows the port contract of that version. A patch release can add
tests to the suite, so a runtime that wants a fixed set of tests pins the crate
with =. This Cargo.toml fragment is an illustration and is not compiled:
[dependencies]
ridl-rt = "0.14.0"
[dev-dependencies]
ridl-rt-conformance = "=0.14.0"
In the test crate, implement the Factory trait on a type of that crate — a
unit struct is enough, and the orphan rule forbids implementing it on the
runtime's own type from outside the runtime. Runtime is one value that
implements Attached, Clock, SignalReader, SignalWriter, EventSource,
EventSink, Caller and Handler: an aggregate handle. Source, Caller and
Handler are a second event source, caller and handler on the same runtime.
SLOTS is the number of calls the runtime holds at once, at least 1; the tests
fill the call table, so a runtime with no bound cannot run them. runtime
builds a runtime attached to a catalog, with a clock that moves only when
advance moves it. fail_next_settle makes the next settlement fail once.
Then call suite!, which writes one #[test] per test. The flags after the
semicolon add the tests of the extensions the runtime implements: scannable,
coherent and wakeable, and trace for a runtime that carries the trace
context. suite!(MyFactory) alone runs the tests of the ports every runtime
presents. This skeleton is an illustration and is not compiled; MyRuntime and
the handle types stand for the runtime's own types:
use ridl_rt::contract::CatalogRef;
use ridl_rt::sample::Duration;
use ridl_rt_conformance::Factory;
struct MyFactory;
impl Factory for MyFactory {
type Runtime = MyRuntime;
type Source = MySource;
type Caller = MyCaller;
type Handler = MyHandler;
const SLOTS: usize = MyRuntime::SLOTS;
fn runtime(catalog: CatalogRef) -> MyRuntime { /* ... */ }
fn source(runtime: &MyRuntime) -> MySource { /* ... */ }
fn caller(runtime: &MyRuntime) -> MyCaller { /* ... */ }
fn handler(runtime: &MyRuntime) -> MyHandler { /* ... */ }
fn advance(runtime: &mut MyRuntime, by: Duration) { /* ... */ }
fn fail_next_settle(runtime: &mut MyRuntime) { /* ... */ }
}
ridl_rt_conformance::suite!(MyFactory; scannable, coherent, wakeable, trace);
The wakeable flag also requires that the three role handles implement
Wakeable, and that the factory and the handles are 'static, because one
test runs on a thread of its own. The working example is
crates/ridl-loopback/tests/conformance.rs, which runs
every test of the suite, with all four flags, over ridl-loopback.
The suite does not test every case. The one list of what it leaves out is the section What the suite leaves out of the crate documentation; a runtime that wants one of those cases tested keeps its own test of it.
What a binding decides
A runtime that carries frames to a second process does so through a binding:
the frame specification carried over one transport. A
binding is written from that specification, the ridl-rt design record
for the exact shape of each name, and the projection record of the encoding its
path carries. Its own document states the
nine items of §10, each a
choice the frame leaves to the transport:
- How a consumer reaches a provider, what opens and ends the session, and what
an ended session is on that transport, so that
DetachedandTransport::Downhave a cause. - The byte layout, order and width of each header field, and how the frame's form is carried. An interface number or an ordinal keeps its 32-bit range, or the binding refuses it at attach time.
- Which payload encoding the path carries, as an encoding tag.
- Where one frame ends and the next starts on a stream, how a payload's length is carried, and what happens when that framing is lost.
- What the provider derives a caller's identity from for duplicate suppression, and whether that identity survives a reconnect.
- How long a settled response is kept for a call that has no response bound.
- Whether the frames of one commit cross as one unit, and so whether a runtime
over the binding presents
CoherentSignals. - On a path that maps a buffer instead of sending a frame, which header fields are in the mapped region's own layout and which are in a frame beside it. The control plane is framed on every binding.
- The answer to a call that arrives faster than its rate floor, when the binding enforces one.
A binding adds nothing to the frame. It does not change the field set, the meaning of a field, what a receiver does with a frame, the per-kind presence rules, the invalid payload rules, or the control-plane operations. §11 names the bindings, and §12 lists the sixteen statements a second implementation — a runtime, a binding, or both — shows with its own tests to conform.
One binding is built in this repository: the WebSocket binding of
§11.1, whose Rust side is
ridl-transport-ws, a server over a ridl-rt port set the embedding program
chose. On Android a runtime binds the ports over its own binder contract,
outside this repository. ridl-loopback runs in process and speaks no frame.
The frame chapter is a specification, and its As-built note says which of its
rules the transport applies; see the note at the top of
frame.
Attaching to a catalog
A port attaches to one catalog. The rule for attaching is §6.1 of the frame specification.
Further reading
ridl_rt::port— the exact shape of every port trait and error.ridl-rt-conformance— theFactorytrait, thesuite!macro, and what the suite leaves out.- ridl-rt — the design record of the runtime library, including its ports.
The catalog descriptor
The catalog descriptor is a small binary file, one per unit, that lists what a
runtime needs to route and check the traffic of that unit's interfaces: the
interface numbers, the members and their ordinals, the maximum encoded size of
each payload, the timing bounds, and the retired interfaces. A program reads it
without the compiler and without decoding the IR. A unit is one ridl.toml
with a [package] table and every source package in its directory tree; the
unit's name is the manifest's name, which is also the name of its root source
package (the design record).
What it is for
A process that carries interactions between other processes — a gateway, a broker, a bridge between two transports, or the engine of a runtime — does not link the generated crate of every unit it carries. It still has to answer questions such as:
- Which interface does the number in this frame name, and is that interface retired?
- Is this member a signal, an event, a command or a query, and which payload does it carry?
- What is the largest payload this member can send, so that a buffer can be sized in advance, and so that a larger payload can be refused? The size counts the payload alone, without the envelope and the framing a transport adds.
- What timing bound does this member declare?
- Do both peers speak the same version of the unit?
The catalog descriptor answers each of these from one file per unit, read at startup.
What is built. The compiler writes the descriptor, the ridl-descriptor
crate reads and verifies it, and ridl describe prints it. No gateway, broker
or engine that reads it exists in this repository yet; those are the consumers
it is designed for. The generated Rust face does not read the file either: it
carries the catalog name and hash it needs as constants (see
The catalog and the face). Codegen plugins do not
read it; the codegen model carries the same facts (see
Writing a codegen plugin).
Producing it
--emit catalog writes one <unit>.catalog.binfb file per unit that declares
at least one interface, or a service with an inline body, in any of its source
packages:
ridl build examples/cabin --emit catalog --out-dir out
# writes out/veh.cabin.catalog.binfb
A unit with no interface gets no file. The flag combines with the others, so one build can write the Rust crate and the descriptor together. The other emit targets stay one file per source package; only the catalog is per unit.
Reading it
ridl describe verifies a descriptor and prints it as JSON:
ridl describe out/veh.cabin.catalog.binfb
The top of the output for examples/cabin, with the 32 bytes of the hash left
out. toolchain is the version of ridl that wrote the file, so it changes
with each release; the excerpt shows it as <version>:
{
"compatible": [],
"hash": [ ... ],
"interfaces": [
{
"members": [ ... ],
"name": "Cabin",
"number": 1,
"provisional": true,
"reserved_ordinals": []
},
...
],
"name": "veh.cabin",
"retired": [],
"toolchain": "<version>",
"version": 1
}
The member average, a query, as it appears in members:
{
"kind": "Query",
"limit": null,
"name": "average",
"ordinal": 4,
"payloads": [
{
"max_sizes": [
{ "bytes": 46, "cause": "Unspecified", "encoding": "FlatBuffers", "state": "Bounded" }
],
"role": "request",
"type_name": "Window"
},
{
"max_sizes": [
{ "bytes": 44, "cause": "Unspecified", "encoding": "FlatBuffers", "state": "Bounded" }
],
"role": "response",
"type_name": "Average"
}
],
"timing": { "max_us": "200000", "min_us": null, "mode": "Range" }
}
The keys are in alphabetical order, and an absent timing bound or limit is
null. A file that is not a valid descriptor, or that has a schema version this toolchain
does not read, makes ridl describe exit with code 2. The
CLI reference has the details.
What it contains
- The catalog: the unit
name; thehash, which identifies this version of the unit (see below);toolchain, the version ofridlthat wrote the file; the schemaversion; theinterfaces; theretiredinterfaces, each with its name and number, so a reader can refuse a peer that still uses one; andcompatible, the hashes of earlier catalogs of the unit that the toolchain judged compatible or carried over unchanged with this one (see The compatible catalogs). - Each interface: its
name, qualified relative to the unit (Cabinfor an interface of the root source package,cluster.Speedfor one of the source packageclusterbelow it); itsnumber, which is one space per unit; andprovisional, which is true while the number is not yet recorded in the unit'sinterfaces.lock, the one lock file beside the unit'sridl.toml.Cabinabove is provisional becauseexamples/cabinhas no lock file; a number becomes permanent whenridl lockrecords it.reserved_ordinalslists the ordinals thatreservedtombstones hold. The ridl reference, section 11, is the rule for the names and the numbers. - Each member: its
name, itsordinal(its position in the interface body), itskind, itspayloads, itstimingwhen the member declares one, and itslimit, the payload limit in bytes that a[limits]entry of the unit'sridl.tomlgives it, absent when no entry applies. - Each payload: its
role(valuefor a signal or afixed,occurrencefor an event,requestfor a command or a query,responsefor a query's reply), itstype_name, and onemax_sizesrow per encoding. A row isBounded, withbytesthe maximum encoded size of the payload, envelope and framing excluded; orUnbounded, withbytes0 and acausethat says why the compiler cannot bound it in that encoding. For example, a FlatBuffers row isUnboundedwhen the payload's largest encoding would exceed 4 GiB, the most that FlatBuffers' 32-bit offsets can address. An encoding with no row has no size computed by this toolchain. A stream payload, a request of zero or of several parameters, and an inlineT | Ereply have no row in any encoding. Apodbufrow is present for a payload with a podbuf root, sized byridl_ir::projection::podbuf::max_size, and a proto3 row is present only for a payload that is a struct, a union, a named scalar, an enum or an enum set, the kinds the proto3 schema gives a message of their own. A named scalar, an enum and an enum set are sized as their root message<Name>Box.
The descriptor does not contain type layouts, field lists, constraints other than the ones that bound a payload's size, contract clauses, or initial values. A reader that needs a payload's shape reads the schema that the wire backend emits.
The compatible catalogs
The hash identifies one version of the unit exactly. compatible lists, as
32-byte hashes, the earlier versions of the unit that ridl diff's classifier,
scoped to the unit, judged compatible with this one, newest first. When the
current hash is the newest published one, the catalog did not change, and the
published list is carried whatever the classifier's verdict. The current hash
is never in it. A reader built for one of those versions can be accepted by a
provider that serves this one, which is the use the frame specification makes
of the list (section 6.1). No runtime in this repository accepts an
attach on the strength of the list.
The list comes from the published baseline. ridl baseline records, for each
unit with an interface, a <unit>.catalogs file beside the snapshots, and
ridl build reads it. The list is empty when there is no baseline, when the
unit has no file, and after a breaking change that changes the catalog, which
restarts the chain. A file is used only when its newest hash is the hash
recomputed from the snapshots published beside it; a file that does not
describe those snapshots, such as one left behind by a hand-edited baseline,
restarts the chain instead. A breaking change that leaves the catalog hash as
it was, such as the removal of a declaration no interface reaches, keeps the
chain. A change in another unit counts for this unit only when an interface of
this unit reaches the changed declaration. ridlc build reads no baseline and
writes an empty list. The CLI reference
states the file format and the rules.
The catalog and the face
The catalog hash is a SHA-256 hash over the unit's interfaces, from every
source package of the unit, their numbers, and every declaration they reach, in
the unit or in another one. Doc comments, labels and deprecated do not change
it; any other change to an interface or to a type it uses does, so a change in
any source package of the unit that an interface reaches changes the one hash.
A [limits] entry in the unit's ridl.toml that gives an interaction its
payload limit changes the hash too, so adding, removing or editing such an
entry changes the hash of the interaction's unit. The design record's
hash section has the exact input.
The generated Rust face carries the same unit name and hash as its CATALOG.
When a face binds to a port, it compares its CATALOG with the catalog of
that port, and panics when they differ. So a face generated from a different
version of the unit than the one its runtime serves fails at bind time,
instead of misreading payloads. The face compares itself with its own
port only; it does not compare catalogs with the party at the other end. The
failures section of "Using the generated Rust
code" shows what that check does.
Stability
The file is a FlatBuffers buffer with the file identifier RDLC. Its schema
evolves by appending only: a field is added at the end of its table, and no
field is removed, reordered or retyped. A reader built against an older schema
ignores the fields it does not know. The version field is the schema version,
now 1; a reader refuses a version other than the one it was built for.
A Rust program reads the file with the ridl-descriptor crate, published on
crates.io. Its verify function checks the whole buffer before it returns a
view, so a later read cannot fail on a malformed offset. With its default
features off the crate is no_std with alloc and keeps verify, the
accessors and finish; the parts that build a descriptor from the IR need the
standard library and come with the std feature. The default dedup feature
makes finish write each repeated vtable, string and byte vector once. With it
off as well, a reader links no hashing crate, and finish writes a larger but
valid file.
Not built yet
The design also defines a system descriptor, one per deployment. It is not built; the design record lists it with the other parts that are not built.
Further reading
- The catalog descriptor — the design record: the schema, the hash input, the size states, and the verifier.
Evolving an interface
An interface changes after it ships: a signal gains a sibling, a bound is
tightened, a payload type is replaced. This chapter shows how to change one so
that the toolchain can tell a consumer built against the old version from one
built against the new version, and what it records about each change. It covers
the three commands that carry the workflow — ridl lock,
ridl baseline and
ridl diff — a table of the verdicts, and the
list of compatible catalogs that the toolchain writes into the catalog
descriptor.
What identifies a version
Two identities are involved, and they answer different questions.
- The interface number says which interface. It is allocated once, by
ridl lock, and recorded in the unit'sinterfaces.lock. A rename keeps it and a retirement keeps it forever, so a number is never given to a different interface. The ridl reference, section 11, is the rule. - The catalog hash says which version of the unit. It is a SHA-256 hash over the unit's interfaces, their numbers, and every declaration they reach. The catalog descriptor describes it.
A unit is the package or workspace member that owns one ridl.toml, and it has
one interfaces.lock. The workflow below keeps the numbers stable and records
each published version's hash.
The workflow
The workflow has four steps. Steps 1 and 2 run once for a new unit; steps 3 and 4 repeat for every release.
- Record the numbers.
ridl lockgives every interface that has no entry the next free number and writesinterfaces.lock. Until it runs, an interface has a provisional number, which is no identity:ridl baselinerefuses to publish a provisional number (RIDL-411). - Publish a baseline.
ridl baselinewrites one<package>.ir.jsonsnapshot per source package, and one<unit>.catalogsfile per unit that has an interface, under.ridl/baseline/at the workspace root. Commit the directory: it is the record of what was released. - Change the source, then compare.
ridl diffcompares the published baseline with the working tree and prints a verdict.ridl checkalso reads.ridl/baseline/when it exists, and warns (RIDL-407) on a change that moves an interaction's position. - Publish again when the release is ready.
ridl baselinereplaces the snapshots and extends the<unit>.catalogsfile.
For a unit with one package, the first publication is:
ridl lock . && ridl baseline && find .ridl/baseline -type f | sort
allocated Cabin 1
.ridl/baseline/demo.cabin.catalogs
.ridl/baseline/demo.cabin.ir.json
Comparing with ridl diff
ridl diff takes the old and the new side as snapshots, a directory of
snapshots, or source. To compare the published baseline with the working tree,
pass the baseline directory and the tree:
ridl diff .ridl/baseline .
The report lists each change with its category and its verdict, then prints the
overall verdict as its first line. The exit code is 0 for identical and
compatible, 1 for breaking, and 2 when a side does not compile or an input
is missing. A pipeline that must not ship a breaking change can run the command
and stop on exit 1. Appending an event to the interface:
compatible
[compatible] interaction_appended demo.cabin/Cabin/doorLocked: (absent) -> event doorLocked
ridl diff --explain <category> prints the rule for one category, and the
CLI reference lists every category.
Renaming and retiring
A rename and a removal are not edits of the source alone, because the number records the identity.
- To rename an interface, change its name in the source and run
ridl lock . --rename Old=New. The number is kept, andridl diffreportsinterface_renamed, which is compatible on the wire. - To remove an interface, delete it from the source and run
ridl lock . --retire Name. The entry keeps its number with the wordretired, andridl diffreportsinterface_retired. Without the retirement, the build fails with RIDL-409 andridl baselinepublishes nothing. - To remove an interaction, replace it with a
reservedline that holds its position. A bare deletion is breaking, andridl baselinerefuses it (RIDL-408).
The verdicts
ridl diff gives each change one of two verdicts, compatible or breaking, and
the report's verdict is the worst of them. identical means there is no change
at all. A verdict says whether a consumer built against the old version still
works with the new one. It does not say whether the catalog hash changed, and
the two are separate questions: the hash identifies an exact version. A
compatible change that reaches the hash makes a new version, and a change that
leaves the hash unchanged makes none.
The one transport in this workspace is the WebSocket binding. The Rust crate
ridl-transport-ws serves it, and the TypeScript package
@driftsys/ridl-transport-ws (packages/ridl-transport-ws/) is its consuming
side, for Deno and the browser. The crate opens no descriptor file itself. Its
embedder may supply a member table that lists the compatible hashes, built by
hand or, with the crate's descriptor feature, from the bytes of the catalog
descriptor: with
one, the crate accepts an attach naming a listed earlier hash, as the last
column shows; without one, it accepts an exact match of the hash only. The last
column states what the frame specification
(section 6.1) specifies for an
older consumer, built against the version before the change.
| Kind of change | Verdict | The catalog hash | The list in the descriptor and in the history file | At attach, by §6.1 |
|---|---|---|---|---|
| None | identical | unchanged | unchanged | accepted: the hash is the provider's own |
A doc comment, a label or deprecated changes | compatible | unchanged | unchanged | accepted: the hash is the provider's own |
| A declaration is added that no interface reaches | compatible | unchanged | unchanged | accepted: the hash is the provider's own |
| A compatible change to an interface, or to a declaration that an interface reaches | compatible | changes | the earlier hashes stay, and the previous one joins | accepted: the earlier hash is in the list |
| A breaking change to an interface, or to a declaration that an interface reaches | breaking | changes | the descriptor's list is empty; the history file restarts with the new hash alone | refused with catalog_mismatch: the earlier hash is not listed |
| A breaking change to a declaration of the unit that no interface reaches | breaking | unchanged | unchanged: the hash did not change | accepted: the hash is the provider's own |
The "compatible" and "breaking" rows cover the categories that ridl diff
prints. The rules are in the
CLI reference, and
ridl diff --explain <category> prints the rule for one. These are the
common cases:
- Compatible: an interaction appended after every slot that was used before
(
interaction_appended); an interaction retired to areservedline in its own slot (interaction_retired); an interface added, renamed with its number kept, or retired (decl_added,interface_renamed,interface_retired); a scalar constraint widened (constraint_changed); a signal or event bound tightened (timing_changed); an interface joining or leaving a service's list (service_interface_added,service_interface_removed). - Compatible, and the hash may change: an enum body reordered with no
number changed (
enum_reordered), which changes the hash when an interface reaches the enum, and a provisional interface number frozen byridl lock(interface_frozen), which always changes it. - Breaking: an interaction inserted, removed without a tombstone or
reordered (
interaction_inserted,interaction_removed,interaction_reordered); a payload, a parameter list or a return shape changed (payload_changed,params_changed,return_changed); a scalar constraint narrowed or a width changed (constraint_changed,width_changed); a declared payload limit raised, lowered, added or removed (limit_changed); a signal bound loosened or removed; a struct field, a union arm or an enum value removed, an enum value renumbered, or an interface number changed (decl_removed,member_reordered,interface_number_changed). - Breaking under podbuf: a struct field appended to a struct that another
struct, a tuple or an exact-length array holds inline anywhere in the new
side, in a struct, in the argument struct a call with several parameters
induces, or in a tuple reply (
podbuf_inline_append). A podbuf head holds such a struct at a fixed offset, so the inline copy cannot grow without moving the fields after it; the other encodings hold no struct inline. The line names each inline use. A snapshot does not record the encodings a workspace builds, so the verdict followsridl diff --encoding podbuf: breaking with it, compatible without it, under the headingbreaking under podbufeither way. The search mirrors the heads the encoder lays out, a declaration and a named induced tuple: a struct that is a root of its own (a single named parameter, a named reply, a signal, event or fixed payload), that is used only as the element of a bounded array, the key or value of a map entry, or a union arm, or only inside an exact-length array that is itself a root (a fixed payload[S; 2..2], which has no head) can grow under podbuf, and such an append is compatible. A tuple inside such a root is a head, and a struct inside that tuple is a use. Also breaking under podbuf: an enum value, an enum-set flag or a union arm appended after which the podbuf integer of the enum or the enum set or the tag of the union is a different integer, by width or by signedness: a value above 255, a flag at bit 8 or an arm past ordinal 255 (u8tou16), a value above 65535 or a flag at bit 16 (u16tou32), or a value above 4294967295 or a flag at bit 32 (u32tou64). An enum's integer is the narrowest that holds every value, signed when a value is negative. A negative value appended to an enum that has values is an insert below its highest value, which is a breakingdecl_addedand draws no width line, so the integer turns signed only when an enum with no value gains values (-1givesu8toi8). The width change is reported once per declaration aspodbuf_width_changewith the old and the new integer, under the same heading and with the same verdict rule; every head that holds the enum, the enum set or the union lays out a different integer, and an append that keeps the integer draws no such line. Without--encoding podbufthe line is reported compatible, which holds under proto3; an enum-set flag that widens the integer also widens the enum set's FlatBuffers scalar, whichridl diff --encoding flatbuffersdoes not report yet (driftsys/ridl#1120). Also breaking under podbuf: an enum value or an enum-set flag appended. Every enum and enum set is strict, and theverifyof an older reader refuses a value or a bit its declaration does not name. It is reported once per declaration aspodbuf_strict_enum_append, under the same heading and with the same verdict rule, except that an enum or an enum set whose append changes the integer draws only thepodbuf_width_changeline. A field removed from a struct, its slot held by a tombstone, isdecl_removed, breaking under every encoding: the type is gone from the source, so its bytes cannot be kept.
An interface that joins the list of a service with a named interface list, or leaves it, is not part of the hash input, because the list is not hashed. Such a change is compatible, and it leaves the hash as it was.
The verdict that decides the chain is the verdict of one unit, not the
report's overall verdict. It is the worst verdict among the changes that
concern the unit: a change in one of its source packages, or in a declaration
that an interface of the unit reaches, in the unit or in another one. A breaking
change in a declaration of another unit that the unit does not reach leaves the
unit's chain as it was, although ridl diff still exits 1 for the report as a
whole. A breaking change in a declaration of the unit's own packages that no
interface reaches is breaking for the unit, although the hash stays as it was. The chain is then
carried, because the catalog did not change and every listed hash stays
compatible.
The compatible catalogs
The catalog descriptor of a unit carries, beside the unit's own hash, a
compatible list: the hashes of earlier versions of the unit that the
toolchain judged compatible with this one, newest first. The current hash is
never in it. The frame specification uses the list at attach: a provider may
accept an attach that names one of those hashes
(section 6.1 states the rule).
No runtime in this repository accepts an attach on the strength of the
list. The toolchain writes it, and a runtime that reads it is outside this
repository. The generated Rust face compares its own CATALOG with its own
port only, and does not read the list. This section describes what the toolchain
writes.
The chain file
ridl baseline keeps the history in .ridl/baseline/<unit>.catalogs, one file
per unit that has an interface, or a service with an inline body. The file is text: a first line that starts with
#, then one catalog hash per line as 64 lowercase hexadecimal characters,
newest first.
# catalogs of this unit's published baselines, newest first, back to the last breaking change
a5973898ecf39cce4b375906dfdbcafc795108832593b70dd49f3e75a90ca841
5ca96801a44963f58aca2dd05a73736c51fa199a2070cdf0a773360294a071db
The first hash is the catalog hash of the baseline being published, the same
hash that ridl build --emit catalog computes. The hashes after it are copied
from the replaced file when the change is compatible or the hash is unchanged,
and a breaking change that changes the hash restarts the file with the new hash
alone; the rules are in the
ridl baseline reference.
For a unit with a published snapshot, a file that cannot be read, or that has a
line that is neither a comment nor a hash, or that lists a hash twice, stops
ridl baseline with exit 2. ridl build also exits 2 when it writes a catalog
descriptor or generates code and the workspace compiles without an error
diagnostic. The message
names the file and the line, and says to repair the file or remove it to start
the unit's chain again.
The build writes the list
ridl build reads the baseline when it writes a catalog descriptor or generates
code. For each unit that has an interface and a package in the baseline, it
lists the hashes of the unit's <unit>.catalogs file, less the current catalog
hash, when the unit's verdict from the baseline to the working tree is
compatible or identical, or when the current hash equals the first hash of the
file. It writes the list into the descriptor, which
ridl describe prints as compatible, and into the catalog.compatible field
of the codegen model that a plugin receives. The recorded file is not changed by
a build; only ridl baseline changes it.
The list is empty in these cases:
- the workspace has no
.ridl/baseline/directory, or the directory holds no.ir.jsonsnapshot; - the baseline holds no package of the unit;
- the unit has no
<unit>.catalogsfile; - the unit's verdict from the baseline to the working tree is breaking and the current hash differs from the first hash of the file.
A build with an error diagnostic writes no list. A build whose only errors are
RSDL-7xx still writes its artifacts, with an empty list.
ridlc build reads no baseline, so the descriptors and the codegen requests it
writes carry an empty list. ridl build is the command that fills it.
So a working tree that adds a signal to a published interface is built with a
list that holds the published hash. After the next ridl baseline, the file
holds the new hash first and the published hash after it, and a later build of
the same tree lists the published hash and no other.
An example
The unit demo.cabin declares an interface Cabin with a signal
currentSpeed and an event doorChanged. After ridl lock and the first
ridl baseline, the file holds the one hash of that baseline:
# catalogs of this unit's published baselines, newest first, back to the last breaking change
5ca96801a44963f58aca2dd05a73736c51fa199a2070cdf0a773360294a071db
An event doorLocked, with a doc comment, is appended to Cabin. The change is compatible, and a
build of the tree writes a descriptor whose compatible list holds one hash,
the published 5ca96801.... Publishing again puts the new hash first:
ridl diff .ridl/baseline . ; echo "exit: $?"
ridl baseline && cat .ridl/baseline/demo.cabin.catalogs
compatible
[compatible] interaction_appended demo.cabin/Cabin/doorLocked: (absent) -> event doorLocked
exit: 0
# catalogs of this unit's published baselines, newest first, back to the last breaking change
a5973898ecf39cce4b375906dfdbcafc795108832593b70dd49f3e75a90ca841
5ca96801a44963f58aca2dd05a73736c51fa199a2070cdf0a773360294a071db
Next, currentSpeed changes its payload type from Speed to DoorState. The
change is breaking, so a build of the tree writes an empty compatible list.
ridl baseline does not refuse the publication, because it enforces the
numbers and the tombstones, not the verdict. It restarts the chain:
ridl diff .ridl/baseline . ; echo "exit: $?"
ridl baseline && cat .ridl/baseline/demo.cabin.catalogs
breaking
[breaking] payload_changed demo.cabin/Cabin/currentSpeed: Speed -> DoorState
exit: 1
# catalogs of this unit's published baselines, newest first, back to the last breaking change
4a83686ad26f656d469694a346b1eebb0f851becf05c95e173a4a29bc1496269
A consumer built against either of the two earlier versions is not on the new list, so the rule of section 6.1 would not accept it.
Further reading
ridl lock,ridl baselineandridl diffin the CLI reference, with the exit codes and the publication gate.- The catalog descriptor — the file that carries the
hash and the
compatiblelist. - The ridl reference, section 11 — the rule for numbers, retirement and tombstones.
- The catalog descriptor design record — how the hash and the chain are computed.
Documenting your API
A contract is read by more people than the ones who write it: the authors of each component that offers or requires it, the reviewers of a change, and the assistant that completes a line in an editor. A doc comment is where the contract says what a type, a field or an interaction means. This chapter covers where a doc comment goes, what it can hold, and the lints that check it. The rules are in the typl reference §14, and the decisions behind them in ADR-0026.
What is built. The checker reads the doc comment of every declaration and
every member, resolves the links in it, and stores the doc, the resolved links
and the tags in the IR. The language server shows them on hover and in
completion. The doc lints of this chapter are reported by ridl check,
ridl build, the language server and the MCP tool ridl_check, with the levels
of the project's [lints] table.
The Rust and TypeScript backends write the docs, their links, the @see,
@since and @deprecated tags and the item's rules into the generated code, as
Using the generated Rust code
describes.
What is not built. There is no ridl doc command that writes a
documentation site.
A documented package
A doc comment is written on the lines directly above the item it documents. Here is a small package with every item documented:
package docs.cruise
/// A vehicle speed over ground, as the brake controller computes it from the
/// four wheel speed sensors.
/// @since 1.0
type Speed: km/h [0.0..250.0 step 0.5]
/// The position the driver moved the cruise control lever to.
enum LeverCmd {
/// The lever is at rest.
NONE = 0
/// Raise the set speed by one step. The step is the `step` of [Speed].
UP = 1
/// Lower the set speed by one step.
DOWN = 2
/// Switch cruise control off. The set speed is kept for a later resume.
CANCEL = 3
}
/// The state that [CruiseControl] publishes after each lever command.
struct CruiseState {
/// Whether cruise control holds the vehicle at [CruiseState.setSpeed].
engaged: Engaged
/// The speed cruise control holds while it is engaged.
setSpeed: Speed
}
/// Whether cruise control is engaged.
type Engaged: boolean
/// The cruise control function of one vehicle.
///
/// The interface accepts lever commands from the steering column switch and
/// publishes the resulting state.
/// @see docs.cruise.LeverCmd
interface CruiseControl {
/// The current cruise control state. A consumer that subscribes late reads
/// the last published value.
signal status: CruiseState @[50ms..500ms]
/// Applies one lever command. The outcome is published on
/// [CruiseControl.status]; the command does not return it.
command setLever(
/// The lever position read from the switch.
cmd: LeverCmd
) @[..100ms]
}
The rest of this chapter takes the parts of this example one at a time.
House style
- Write doc comments as
///lines. The compiler also accepts the block form/** ... */with the same meaning, andridl fmtdoes not rewrite one form into the other. A project that wants only///turns on thedoc-comment-stylelint (see The doc lints). - Start with one sentence that says what the item is. Add a blank
///line and more paragraphs when the reader needs them. - Say what the declaration itself cannot say: what a value means, where it comes from, and what happens when it changes. Do not repeat the range, the unit or the timing. The editor shows those from the declaration itself, in the Contract part of the hover (see In the editor).
- Document an interface by its responsibility, and document each interaction on its own line. The interface doc does not list its members.
Where a doc comment goes
A doc comment documents the next named declaration or member. These are the positions that take a doc comment, called carriers:
| Carrier | Must have a doc |
|---|---|
type, const, struct, enum, enumset, union | yes, unless internal |
interface, service | yes, unless internal |
| struct field, enum value, enumset bit, union arm | yes, unless its parent is internal |
signal, event, command, query, fixed | yes, unless its parent is internal |
a parameter of a command or a query | no |
a reserved entry | no |
rsdl system, component, distribution, deployment, machine | yes |
an rsdl body line (offers, requires, or a bare reference) | no |
"Must have a doc" is what the missing-docs lint checks. A package needs no
doc.
A doc comment in any other position draws misplaced-doc-comment: before
package, before an import, before a return type or an attribute block, and
at the end of a file or of a body. Each of these positions either has no name to
document or belongs to the item around it.
No blank line may separate a doc comment from its carrier. A blank line draws
detached-doc-comment.
Parameters
A parameter takes a doc comment, as cmd does in setLever above, but never
needs one. Every parameter type is a named type, and the named type carries its
own doc and its own rules. When a parameter has no doc of its own, the editor
shows the doc of its type instead, under the line "From TypeName:".
rsdl
The five rsdl declarations are carriers, and so is each line of their bodies:
/// The cruise control computer in the front zone.
component CruiseEcu {
/// The function the driver controls with the steering column lever.
offers docs.cruise.control
}
The docs of the declarations, of the system and distribution member lines,
and of the offers and requires lines are stored in the system IR. A
placement line in a machine takes a doc too, and the editor shows it on hover,
but the system IR has no field for it.
What a doc comment holds
A doc is CommonMark: paragraphs, lists, emphasis, code spans and fenced code blocks.
Links
A name in square brackets is a link to a declaration or a member. Three forms are links:
| Form | Example | Text shown |
|---|---|---|
[Name] | [Speed], [docs.cruise.Speed] | the name |
[`Name`] | [`Speed`] | the name, as code |
[text][Name] | [the set speed][CruiseState.setSpeed] | the text |
The bracket content is a link only when it is a name: one or more dotted
segments. [0..250] and [see below] are prose. A name inside a code span or a
fenced code block is not a link. A [Name] that has a CommonMark link reference
definition ([Name]: https://...) in the same doc is an ordinary Markdown link.
A link resolves in the scope of the file that holds it, by the rules of a type reference:
- A bare
Nameis looked up as a type name is: a declaration of the package, then an imported name (an import alias leads to the declaration it names), thenridl.std. - A qualified
pkg.Namereaches any package that the current package can depend on — another member of the workspace, or a package of[imports]— whether or not the file imports it. A doc link never makes you add an import that the code does not use. A remote package of[imports]is reachable where animportof it would resolve: the package's own[imports], or the workspace[imports]for a member, must pin its unit. A link into a remote package no such pin reaches drawsbroken-doc-link. - One more segment names a member of the declaration: a field
(
[CruiseState.setSpeed]), an enum value ([LeverCmd.UP]), an enumset bit, a union arm, or an interaction ([CruiseControl.status]). - A declaration that is
internalin another package cannot be linked to, because documentation of the other package could not show it. A link to aninternaldeclaration of the same package resolves.
A link that does not resolve draws broken-doc-link, and the IR does not store
it.
Tags
A tag is a word that starts with @, at the start of a line of the doc. Four
tags exist:
| Tag | Value | Checked |
|---|---|---|
@see | one name, with an optional member, as a link takes | resolved like a link |
@since | a version: MAJOR.MINOR or MAJOR.MINOR.PATCH | its form only |
@deprecated | a reason in double quotes | a missing reason draws deprecated-without-reason |
@labels | comma-separated SCREAMING_SNAKE labels | passed through unchecked (typl §14.3) |
A tag may appear more than once; every value is kept. An @ that is not at the
start of a line is prose. A tag line is not part of the doc text: a doc made
only of tags has no text, and missing-docs treats it as missing.
@see and @since change only documentation. @deprecated and @labels are
stored in the IR beside the doc on a declaration, a struct field, an interface,
an interaction and a service, and a backend that emits deprecation or label
metadata reads them there. On an enum value, an enumset bit, a union arm, a
parameter and an rsdl declaration or body line the two tags are accepted and
dropped, with no diagnostic (ADR-0026 decision 4).
package docs.history
/// A request to change the gear.
/// @since 1.2
/// @see docs.history.GearBox.setGear
/// @labels SIL_2
struct GearRequest {
/// The gear to change to, from 1 to 6.
gear: Gear
}
/// A gear of the six-speed gearbox.
type Gear: integer [1..6]
/// The gearbox controller.
interface GearBox {
/// Requests a gear change.
command setGear(request: GearRequest) @[..20ms]
/// The previous gear request, kept for clients built against version 1.0.
/// @deprecated "use setGear"
command requestGear(request: GearRequest) @[..20ms]
}
Examples
Write a usage example under a # Examples heading, with a fenced code block.
This is a convention, not a tag: the compiler does not read it, and a name inside
the code block is not a link.
package docs.examples
/// A distance to the vehicle ahead.
///
/// # Examples
///
/// ```ridl,ignore
/// signal gap: Gap @[50ms..200ms]
/// ```
type Gap: m [0.0..250.0 step 0.1]
The doc lints
Each doc lint is a TYPL- code, because doc comments are part of typl and every
language of the family shares them. All of them are warnings; doc-comment-style
is allow by default, so it is reported only when a project sets its level. The
lints page explains levels and the [lints] table.
| Lint | Code | Default | Raised when |
|---|---|---|---|
broken-doc-link | TYPL-401 | warn | a link or @see target does not resolve, or is internal in another package |
detached-doc-comment | TYPL-404 | warn | a blank line separates a doc comment from its carrier |
deprecated-without-reason | TYPL-405 | warn | @deprecated has no reason |
missing-docs | TYPL-406 | warn | a carrier that must have a doc has none |
misplaced-doc-comment | TYPL-407 | warn | a doc comment is in a position that is not a carrier |
unknown-doc-tag | TYPL-408 | warn | a tag other than @see, @since, @deprecated and @labels |
malformed-doc-tag | TYPL-409 | warn | @see or @since has a missing or malformed value |
doc-comment-style | TYPL-410 | allow | a doc comment is written as /** */ |
The examples below each draw one documentation lint on purpose. Some also draw the design lints explained beside their source blocks.
missing-docs
missing-docs is reported once per item, at the item's name. Its quick fix in
the editor inserts an empty /// line above the item. Here Odometer has no
doc, and internal exempts Raw and its field:
package docs.missing
type Odometer: km [0.0..1000000.0 step 0.1]
/// A raw sensor sample, for the use of this package only.
internal struct Raw {
value: Odometer
}
The inconsistent-unit lint also reports value here. It uses km, while
fields named value in other book examples use km/h. The book checks its
whole-file examples as one workspace.
When you check from inside a workspace member, ridl check, ridl build,
ridl lock and the MCP tool ridl_check report only the diagnostics of files
under that member, so an undocumented item in a sibling member does not appear
in that report. The language server reports every consumer file it loaded, and
publishes no diagnostics for a file of a remote unit. A remote package of
[imports] is checked: its errors fail the run, and the language server shows
them in one window message per load; its warnings and infos, missing-docs
included, are dropped before any [lints] level is applied.
A project that does not want to document every item yet sets the level in its
ridl.toml:
[lints]
missing-docs = "allow"
broken-doc-link
package docs.broken
/// The heading in whole degrees, converted from a [Compass] reading.
type Heading: integer [0..359]
Compass is declared nowhere, so the link does not resolve.
detached-doc-comment
package docs.detached
/// The cabin air temperature.
type CabinTemperature: Cel [-40.0..85.0 step 0.5]
deprecated-without-reason
package docs.reason
/// The fan level of the first climate control version.
/// @deprecated
type FanStep: integer [0..7]
misplaced-doc-comment
A doc comment at the end of a body has no item after it:
package docs.misplaced
/// The wiper speed.
enum WiperSpeed {
/// The wipers are off.
OFF = 0
/// The wipers run once every few seconds.
SLOW = 1
/// The wipers run continuously.
FAST = 2
/// A fourth speed is planned.
}
unknown-doc-tag and malformed-doc-tag
package docs.tags
/// The tyre pressure of one wheel.
/// @author the chassis team
/// @since next
type TyrePressure: kPa [0.0..500.0 step 1.0]
@author is not one of the four tags, and next is not a version.
doc-comment-style
doc-comment-style reports every /** */ doc comment, so a project that sets
it to warn or deny requires ///:
[lints]
doc-comment-style = "deny"
Its quick fix in the editor rewrites the comment as /// lines. The lint checks
in one direction only: no setting requires /** */.
In the editor
The language server reads the docs from the same compile as the diagnostics, so
it shows what ridl check reads:
- Hover on a declaration, a member, a parameter, an rsdl declaration or body
line, or a use of any of them shows the signature, the doc, a Contract
list, and the
@sinceversions, the labels and the deprecation reason. The Contract list is read from the IR: the range, step, unit, length, pattern and collection bound of the type, the timing of asignalorevent, the response bound of acommandorquery, the error type of a falliblequery, and eachrequireandensureclause. A link in the doc is shown as a link to the target's file and line. Hover on a member used in a contract clause shows that member. - Completion items carry the doc of the item they insert, except the
member items offered after
.in a doc link. Inside a doc comment,[and.complete link targets, and@at the start of a line completes the four tags. - Go to definition, find references and rename work on doc links and
@seetargets in.typl,.ridland.rsdlfiles. Renaming a type updates every link and@seethat names it, in every member of the workspace. - Quick fixes:
missing-docsinserts a///line, anddoc-comment-stylerewrites a/** */comment as///lines.
There is no signature help: the language has no call expression for it to serve.
Writing a codegen plugin
A codegen plugin is a backend that is an executable rather than a part of
ridl. ridl build runs it once per package, writes one request to its
standard input, reads one response from its standard output, and writes the
files the response carries. A plugin can be written in any language that reads
and writes JSON.
This chapter is a guide. Each fact it summarizes is owned by another record, and the chapter links to that record:
docs/design/codegen-plugins.md— the plugin system as built: the contract, the process host, the reference plugins and the deployment section.- IR — encodings and stability — the IR specification: the canonical encoding, the nesting bound, the compatibility rule and versioning.
- ADR-0020 decisions 8 to 12 — the binding choices behind the plugin system.
- CLI reference — every flag of
ridl build.
Reading order
Read these in order before you write a plugin:
- The IR specification §7 and §8: read
schemafirst, parse leniently, and use a fileridlcwrote as a fixture. plugin.proto: the request and the response.model.proto: what the model carries, message by message.deployment.proto: the deployment section, if your plugin lays out memory for a deployment.crates/ridlc-gen-model/src/main.rs: a complete plugin.- The
ridl buildsection of the CLI reference: how the plugin is found and whatridldoes with the response.
Running a plugin
A plugin for the language kotlin is an executable named ridlc-gen-kotlin.
ridl build runs it when you pass --plugin:
--plugin kotlinlooks forridlc-gen-kotlinonPATH, inPATHorder. On Windows,ridlc-gen-kotlin.exeis also tried.--plugin kotlin=/opt/gen/bin/kt-genruns the executable at that path and skips the lookup. Messages still name the pluginridlc-gen-kotlin.
The flag repeats, once per plugin, and is the same on ridlc build. A plugin
runs beside the --emit targets, and --emit defaults to rust, so a build
with --plugin and no --emit also writes the Rust files. The plugin runs
once per package that the code emits are written for, ridl.std included when
a package uses it. --plugin-timeout <SECONDS> sets how long one run may take
before ridl kills it; the default is 60 seconds.
The plugin's standard error is passed through to the terminal. The error cases and their exit codes:
| Case | Exit |
|---|---|
a --plugin value that does not parse, such as =/x or a/b | 2, before anything is compiled |
no PATH directory holds the executable | 1, and the build writes no file |
| the executable cannot be started, exits non-zero, or is still running at the timeout | 1 |
standard output is not a CodegenResponse | 1 |
a file path in the response breaks the path rule: it is absolute, or holds .. or \, for example | 1, and no file of that response is written |
| the response carries an error-severity diagnostic | 1, and no file of that response is written |
--deployment NAME names no deployment the system declares | 2, unless the build already has an error, which exits 1 |
A plugin's failure exits 1, not 2, because the build completed and the failure is one of its findings (ADR-0010 decision 1). The messages of each case are listed in the design record, §4.
The protocol
There is no framing and no binary form. The request is one JSON document on
the plugin's standard input; the plugin reads until the end of input. The
response is one JSON document on its standard output. Both are canonical
protobuf JSON, pretty-printed: keys are the proto3 JSON names, so the field
artifact_base is the key artifactBase; enum values are written by name;
64-bit integers are decimal strings; and bytes fields are base64.
A plugin never writes files. It returns each file in the response as a
path, relative to --out-dir, and a text or binary content, and ridl
writes it. So --out-dir means the same for a plugin as for the rust emit.
The nesting depth. A request can nest deeply, because a type can nest. The
IR specification §4 and §8 state how many JSON levels a reader must accept: at
least 516, and 1,000 is recommended. Some JSON libraries stop below that by
default, serde_json at 128 for example, and need one configuration step.
The response is read strictly. This toolchain's reader rejects a key the
schema does not have, so a plugin must not add fields of its own to the
response. A diagnostic is a severity and a message; there is no source
span. An unset severity counts as an error.
The schemas
The schema of the request and the response is the protobuf package
ridl.codegen.v1, in three files in
crates/ridl-ir/proto/ridl/codegen/v1/:
plugin.proto—CodegenRequest,CodegenResponse,GeneratedFile,Diagnostic,BackendOption;model.proto— the lowered model;deployment.proto— the deployment section.
plugin.proto imports the other two. The repository publishes no schema
package of its own. Today an author copies the files from the release tag the
plugin is tested against, as driftsys/ridlc-gen-kotlin does: it keeps
the files unchanged and runs a check that fails when they differ from the
pinned tag. Copy every file that the tag's plugin.proto imports: where the
tag holds deployment.proto, that is all three files, and a plugin.proto
that imports deployment.proto does not compile without it. An older tag holds
only plugin.proto and model.proto.
Compatibility
The rules are the IR specification's §6 to §8. In short:
- Additive changes are made in place. A new field, a new enum value or a new
oneofmember can appear inridl.codegen.v1at any release. A plugin whose request reader is built on an olderridl-irstill rejects a request that carries a new enum value, so that plugin must be rebuilt against theridl-irthat adds the value. Any other change — a removal, a rename, a renumbering, a change of type or of meaning — is a new package,ridl.codegen.v2, compiled beside the old one. - A plugin reads the request leniently. It ignores keys it does not know,
so a request from a newer
ridlstill reads.ridl_ir::codegen::request_from_jsondoes this at every nesting level of the request, fromridl-ir0.6.0 on. It still rejects an unknown enum name, because that changes the meaning of a known field, so a new enum value is a change an older plugin reports as an error. A Rust plugin built against an earlierridl-irrejects a request that carries a key that release does not know, and must be rebuilt once. - A plugin refuses an unknown
schema.schemais the first field of the request. A plugin that does not know its value answers with an error diagnostic naming both values. A plugin never refuses ontoolchain, which only says which version ofridlwrote the request. - The toolchain reads the response strictly, as the protocol section says.
What the request carries
ridl build --emit codegen-request writes the whole request for each package
to <base>.codegen-request.json, so a plugin author can read what their plugin
would receive without running it. The emit takes its options from a
[backend.codegen-request] table, which is how a request carrying an option is
written. Every excerpt in this chapter was read from that artifact, built from
examples/cabin and called request.json below.
These values are the top-level fields of the request ridl build sends for
examples/cabin, with model and deployment left out of the excerpt.
toolchain is the version of ridl that wrote the request, so it changes with
each release; the excerpt shows it as <version>:
jq '{schema, toolchain, options, artifactBase, generatedMarker, header}' request.json
{
"schema": "ridl.codegen.v1",
"toolchain": "<version>",
"options": [],
"artifactBase": "veh.cabin",
"generatedMarker": "@generated by ridl from package veh.cabin. Do not edit.",
"header": ""
}
modelis the lowered model of one package: its declarations, with type references resolved and names in every case the name transform defines (ADR-0016), its interfaces and their members, its services, and its catalog. It is the same JSON value thatridl build --emit codegen-modelwrites to<base>.codegen.json, one indentation level deeper, so that file is a fixture for a plugin's tests.optionsis a list of{key, value}pairs sorted by key. It holds oneencodingsoption, the names joined by,in manifest order, when the root manifest's[backend.<language>]table names an encoding (the CLI reference describes the table), and is empty otherwise. No flag sets an option.artifactBaseis the nameridlgives this package's files: the package name, or the file stem in single-file mode. A plugin that lays its output out another way can ignore it.generatedMarkeris the line that marks a generated file as generated, without a comment token. The host composes it. See the marker below.headeris the project's file header from[codegen] header-file, without a comment token, or an empty string when the project sets none. Both keys are always present in the JSON, so an empty header is"header": "".deploymentis the deployment section, described below. It is absent when the build selects no deployment.
The marker and the header
A plugin is recommended, not required, to write generatedMarker as the first
line of each file it writes that can hold a comment, as a comment in that
file's syntax, and then header, one comment line per header line, then one
blank line. The marker comes first because rustfmt looks for @generated
only in the first 5 lines of a file. A plugin that writes in Rust can call
ridl_ir::codegen::comment_preamble(marker, header, "//"), which returns that
block; a plugin in another language writes the same layout, with an empty
header line as the bare comment token and no trailing space. A file that cannot
hold a comment, such as JSON, carries neither. Where the key comes from, and
how a project sets the header, is in the
CLI reference.
The documentation fields
Every message of the model that carries a doc carries four more fields beside
it: Declaration, Field, EnumValue, Arm, Interface, Interaction,
Service and Param. A plugin that writes doc comments reads them there:
links— the resolved links ofdoc, in source order. Each is aDocLink:textis the link text as written,offsetandlenare the link's byte position indoc, andtargetis the item it names. Replace thelenbytes at eachoffsetwith the plugin's link syntax, from the last link to the first, so that each earlier offset stays valid.see— the@seetargets, in source order, asDocLinks whoseoffsetandlenare 0.since— the@sinceversions, in source order.rules— the rules of the item. ARulehas atext, the line the editor's hover shows for it, and akind, aoneofof the structured fields:range,step,unit,length,pattern,collection_bound,timing,response_bound,errorsorcontract(arequireor anensureclause).rulesis filled on aDeclaration, anInteraction, aFieldand aParam, and is empty on the other carriers.
DocLink.target is a qualified name, pkg.Name with an optional .member,
not a spelling in any target language. A plugin maps it to its own path with
its own name transform (ADR-0016). The in-tree backends write a
target that has no item in their output as a code span of the qualified name,
so that the output holds no link that does not resolve.
A new kind of rule is a new oneof member, which is an additive change. A
plugin renders Rule.text for a kind it does not know, so its docs stay
complete: ridl_ir::codegen::request_from_json drops a member it does not
know and keeps text. Param carries the parameter's own doc as well. How
the in-tree backends render these fields is in
Documentation in the generated code.
The deployment section
The deployment section holds the facts of one deployment of the workspace's system that a plugin needs to lay out memory: the regions, one per catalog, with each catalog's hash and interfaces; the instances; one channel per member and producer instance, with its consumer links; and the transport bindings. Every request of one build carries the same section. The design record, §7, states what each part holds, its order, and the rules behind each value.
--deployment NAME selects the deployment. Without the flag, the request
carries the deployment when the system declares exactly one, and none when it
declares several or the workspace has no system. The
CLI reference states every case.
Each consumer link states its crossing and the encoding that follows from the
crossing: FlatBuffers on the same machine, proto3 to a different machine or
off-board. One consumer link of the warning event in examples/cabin:
jq '.deployment.channels[1].consumers[0]' request.json
{
"component": "veh.cabin.Panel",
"instance": "Unit",
"machine": "Hpc",
"crossing": "CROSSING_SAME_MACHINE",
"encoding": "ENCODING_FLATBUFFERS",
"depth": {
"value": 10,
"source": "VALUE_SOURCE_DERIVED"
},
"slotsSource": "VALUE_SOURCE_UNSPECIFIED",
"budgetSource": "VALUE_SOURCE_UNSPECIFIED"
}
Where a sizing value comes from. An event's depth, and a command's or a
query's slots and budget, each come with a source, written in the JSON as
VALUE_SOURCE_<NAME>:
| Source | Meaning |
|---|---|
DERIVED | computed by the toolchain: an event's depth is ceil(max / min) over its resolved timing |
DECLARED | written in the rsdl source, on the placement line of the consuming instance or on the deployment |
DEFAULT | no value was declared and the toolchain applied its default: 16 slots for a command or a query |
UNDERIVABLE | no value was declared and none can be derived, so the value is absent; for a depth, for example, when the timing has only one bound |
A source of UNSPECIFIED comes with an absent value: a field the member's kind
does not use, such as slots on an event link, or a budget that nothing
declares. The design record,
§7, lists every case in which a depth is underivable. The
rsdl reference §5 states the sizing keys, and Describing a system
shows them in use.
Sizes
The model states the maximum encoded size of every payload, of every command
and query request, and of every query reply, once per wire encoding: proto3,
FlatBuffers and podbuf. Each is bounded with a byte count, unbounded with a cause (for
FlatBuffers only), or absent with a cause. The model also sums them:
Interaction.reservation is the memory a call table reserves for one member,
and Interface.table_budget (the key tableBudget) is the memory for the whole interface, each as a
byte count or as unsized, naming the first payload or member that has no
bounded size. Each Declaration carries two more sizes. proto3 is the proto3
size state of the declaration's root message — its own message for a struct or
a union, its <Name>Box message for a named scalar, an enum and an enum set.
podbuf is the podbuf size state of the declaration as a payload root, the
bound ridl_ir::projection::podbuf::max_size computes. A constant has neither.
A codec reads them as the declaration's largest encoded size, as the Rust
backend does.
A plugin reads these sizes; it does not compute them. The sizes of the
warning event's payload in examples/cabin, and its reservation:
jq '.model.interfaces[0].slots[1].interaction.event.payload.sizes' request.json
{
"proto3": {
"bounded": 8
},
"flatbuffers": {
"bounded": 60
},
"podbuf": {
"bounded": 16
}
}
jq '.model.interfaces[0].slots[1].interaction.reservation' request.json
{
"proto3": {
"bytes": "8"
},
"flatbuffers": {
"bytes": "60"
},
"podbuf": {
"bytes": "16"
}
}
A reservation is a 64-bit integer, so its byte count is a string. The rules
behind each state and each sum are in
the catalog descriptor record, "The size states". A plugin that
sizes a socket message adds the frame header and the envelope of the transport
binding to the payload bound; the deployment section's bindings list carries
those values: the websocket row of
the frame specification §11.1, a data-plane
frame header of at most 20 bytes and an envelope of 16 bytes. A test of
ridl-transport-ws checks that row against the frames its codec writes.
The podbuf layout
Model.podbuf holds the layout of every podbuf head of the package, so that a
plugin that writes podbuf code never computes the layout law. Each head lists
its fields with the offset, the size, the alignment and the form of each, the
flag bit of an optional field, and the unused byte runs. A form says what the
bytes are: an integer, a float, an enum, an inline head (an index into
podbuf.heads), an exact-length array, or a descriptor to the tail with the
element type, the stride and the bounds. A union head lists the head of each
arm. A struct, union or tuple that cannot be laid out is listed in
podbuf.refused with its cause, and no head is written for it.
The codegen plugin record, "The podbuf projection" gives the order of the heads and the meaning of each field.
The catalog hash
The model's catalog holds the unit name, in the field package, and the
catalog hash, a 32-byte SHA-256 over the interfaces of every source package of
the unit and every declaration they reach (ADR-0014 decision 15).
The field keeps its name package for the generated accessors of every
plugin; its value is the unit's name, which is CatalogRef.name. In the JSON,
the hash is base64. Each region of the deployment section carries the same
hash for its catalog.
Embed the name and the hash in the generated code, and check them when the generated code binds to a port. The Rust face does this: each generated interface carries its catalog's name and hash, and binding to a port whose catalog differs panics with a message that names both catalogs (ADR-0023 decision 8; the face's catalog check). Code generated from one contract then refuses a port of another contract when it binds, instead of exchanging bytes that one side reads wrongly.
A provisional interface number. An interface that has no entry in its
unit's interfaces.lock has a provisional number, and the model marks it
provisional. A provisional number can change: adding an interface before it
in the source can move it, and recording it with
ridl lock changes the catalog hash, because the
hash covers each interface's number and its provisional flag. A provisional
number is fine during development. Never rely on it across parties that are
built separately; run ridl lock and commit interfaces.lock first.
ridl diff reports the number that ridl lock
freezes as interface_frozen, and a provisional number that moves as
interface_number_changed.
The catalog descriptor
ridl build --emit catalog writes <unit>.catalog.binfb, a FlatBuffers file
of a unit's interfaces, their members, their payload size bounds and the
catalog hash. The unit is the set of source packages that one ridl.toml
declares, and the hash is computed over that unit. One file is written per unit
that declares an interface or a service with an inline body; a unit that
declares only types writes none. It is for a run-time engine that is not
compiled against the unit and reads the file when a party attaches; no such
engine is in this repository.
ridl describe prints it as JSON.
A plugin does not read the catalog descriptor: the model carries the same facts. The chapter The catalog descriptor describes the file for its readers, and the catalog descriptor record gives its byte-level details.
A system descriptor, a file per deployment, is not built (catalog descriptor record, "Not built"). The request's deployment section carries those facts to a plugin at build time.
What is not available yet
podbufon a consumer link. Every payload has apodbufsize state and the model carries the podbuf layout (see "The podbuf layout" above), but no consumer link carriesENCODING_PODBUF: no rsdl fact selects it.- Members whose depth or size cannot be derived. An event's depth is absent
with the source
UNDERIVABLEwhen its timing gives no bound. The build warns with RSDL-806 when a consumer link consumes such an event with no declareddepth(see Lints); it does not warn when no link consumes the event (driftsys/ridl#735). A stream payload, a request of zero or of several parameters, and an inlineT | Ereply have no size in any encoding, so the reservation of such a member isunsized. No single issue tracks these cases; each is recorded in the design record, §7, and in the catalog descriptor record.
Examples
crates/ridlc-gen-model/src/main.rsis a complete plugin in one file of under 80 lines, its module comment included. It reads the request, refuses an unknownschema, writes the model back as one file, and writes the response.crates/ridlc/tests/layout.rsholds a test plugin that reads the deployment section. It computes the byte layout of each region of theexamples/cabindeployment and the maximum size of each message that crosses a socket, from the request alone. Its module comment states the layout rule, which belongs to the test and not to the toolchain. It runs in process, through the sameBackendtrait the in-tree backends implement.- driftsys/ridlc-gen-kotlin is a plugin outside this repository,
written in Kotlin. It runs through
ridl build --plugin kotlinand generates Kotlin value objects, codecs and faces from the model.
CLI reference
Two command-line binaries ship from this repository: ridl, the porcelain
facade, and ridlc, the plumbing compiler underneath it. ridl hosts the
language server as the ridl lsp subcommand and the Model
Context Protocol server as ridl mcp, both over stdio (see
What is built). The VS Code extension spawns
ridl lsp. Build both with:
cargo build --release
as Getting started describes.
This page assumes target/release/ridl and target/release/ridlc are on your
PATH, or called by path.
Only typl and ridl have a toolchain — see What is built. Every command and transcript below runs against a workspace built from those two layers; rxdl, rmdl, and rsdl accept no command here.
Every synopsis and transcript on this page is the literal output of the
binaries built from this repository, run against constructed fixtures. The
fixtures themselves are not reproduced here — this page documents command
behavior, not how to write a .ridl file, and a fixture built for one
transcript is often a one-line edit of the fixture built for the transcript
before it. Getting started is the tutorial that shows
complete, worked files; treat a transcript below as a faithful record of what
the tool printed against some input shaped as described, not as a listing you
can paste in whole. Every exit code stated below was observed by running the
command and reading its exit status, not read out of a help string or a
specification.
ridl --version and its short form ridl -V (and ridlc --version) print the
binary's own name and version and exit 0:
ridl --version
ridl X.Y.Z
X.Y.Z stands for the build's version: the crate version for a local build,
or the release tag when the release workflow sets RIDL_BUILD_VERSION. The
version does not name a commit, so it cannot answer "which commit is this" —
recorded as a known gap in ADR-0010 decision 8, which also records
a deferred fix.
ridl
ridl --help
The RIDL toolchain
Usage: ridl <COMMAND>
Commands:
check Type-check a file, package, or workspace (defaults to the current directory)
baseline Publish the current workspace as a baseline: one `<pkg-name>.ir.json` snapshot per package and one `<unit>.catalogs` file per unit with an interface, written to `.ridl/baseline/` at the workspace root
build Compile to the selected artifacts (defaults to the current directory)
test Run the property suite over a workspace: the range self-corpora and the contract-clause sampling (ridl §13). Exit 0 when every run passes, 1 on a self-corpus failure or an evaluation error, 2 on a compile error
fmt Reformat `.typl`, `.ridl` and `.rsdl` files in place (defaults to the current directory)
diff Compare two IR snapshots or source trees and classify the change: exit 0 compatible or identical, 1 breaking, 2 error
lock Allocate a number to every interface that has none and write each unit's `interfaces.lock`; with `--rename` or `--retire`, rewrite one unit's entries in place instead. Exit 0 when the file is written or nothing changes, 1 on a diagnostic error, 2 on a bad flag or a path or I/O failure. `ridl lock merge` is the git merge driver for the file
lsp Run the language server over stdio: exit 0 after `shutdown` then `exit`, 1 when the client ends the session without `shutdown`, 2 on a transport error. Editors spawn this. Stdio is the only transport
mcp Run the MCP server over stdio for an agent host: exit 0 on a clean shutdown, 2 on a transport error. It takes no flag of its own
init Scaffold a new unit in a directory that exists (defaults to the current directory): a package, or with `--workspace` a workspace and one member. Writes `ridl.toml` and one source file, and exits 2 without writing anything when a target file exists, the name is not a legal package name or holds a reserved word, or a `ridl.toml` above or below the directory would make a manifest inside a unit (MANI-013)
new Create a directory, with its parents, and scaffold a new unit in it, as `ridl init` does. Exits 2 without writing anything when the path exists
describe Print a catalog descriptor as strict JSON, after verifying it
vendor Copy every remote unit of the workspace's `[imports]` into `vendor/` at the manifest root, so a `--frozen` build needs no network and no cache. Every directory in `vendor/` that no `[imports]` key names is removed; files and hidden entries are left. Exit 0 when `vendor/` was written or there was nothing to change, 1 on a diagnostic error (`vendor/` is left untouched; `ridl.lock` may be updated), 2 on a missing or unreadable path, a fetch failure, or an I/O failure. With `--check`, compare `vendor/` with the imports and `ridl.lock` instead, writing nothing
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
Running ridl with no subcommand at all prints this same text to stderr
and exits 2; ridl --help prints it to stdout and exits 0 — the two
routes carry identical text but are not interchangeable in a script that
checks the exit code or reads the right stream.
ridl lsp and ridl mcp are the two stdio servers this binary hosts — the
language server an editor drives, and the Model Context Protocol server an
agent drives. ridl mcp takes no argument or flag; ridl lsp takes only the
two flags editor clients pass, which change nothing. Each is documented in its
own section below, ridl lsp and ridl mcp, and
each exits 0 when the client shuts it down and 2 when the transport ends
before the handshake or otherwise fails.
ridl check
ridl check --help
Type-check a file, package, or workspace (defaults to the current directory)
Usage: ridl check [OPTIONS] [PATH]
Arguments:
[PATH] [default: .]
Options:
--frozen Verify remote imports against `ridl.lock` without fetching or regenerating it (CI mode, ADR-0002 §7)
--baseline <DIR|FILE> Compare the checked workspace against a published baseline — a directory of `.ir.json` snapshots or one snapshot file — and warn (RIDL-407) on every interaction whose ordinal moved and every struct field or union arm change `ridl diff` gates on that concerns an ordinal: a member inserted, one removed, one moved in an edit that added or removed no member, and one appended beside such a change or to a result union. An append that is breaking only for its field's type moves no ordinal and draws no warning. Without the flag, `.ridl/baseline/` at the workspace root is used when it exists
--format <FORMAT> Output format for the report: text renders to stderr (the default); json goes to stdout instead — see the CLI reference (docs/book/cli-reference.md) for its schema; sarif writes one SARIF 2.1.0 log to stdout, for code-scanning viewers [default: text] [possible values: text, json, sarif]
-h, --help Print help
PATH is a .typl, .ridl or .rsdl file, a package directory, or a
workspace root, and defaults to the current directory. A .rxdl or .rmdl
file is refused as PATH when no ridl.toml is at or above it, as the next
paragraph says. A PATH inside a workspace member loads the whole workspace
whose members lists the member (ADR-0002 §4): the root's
[lints], the [defaults] keys and [imports] apply, imports of sibling members
resolve, and the report holds only the diagnostics of files under the member.
ridl build on a member also writes the whole workspace. An error in another
member makes it exit 1 with one error that says so; it then writes nothing, or,
for an RSDL-7xx error, leaves that deployment out of the system. The workspace
root's .ridl/baseline/ and ridl.lock serve a member entry too. --frozen is the same flag
ridlc check --frozen documents, forwarded unchanged; the two
commands share one implementation and render byte-identical diagnostics on the
same input. It now carries this description word for word under ridl too —
before ADR-0010, ridl check --help rendered it with a blank line
where the description belongs.
The loader compiles the .typl, .ridl and .rsdl files of a package
directory. A .rxdl or .rmdl file there is not compiled, because the rxdl
and rmdl profiles have no implementation (driftsys/ridl#68): each one draws the
warning RIDL-417 (unsupported-source-file) on the file itself, and its
declarations are not in the build. A bare .rxdl or .rmdl file given as the
entry with no ridl.toml at or above it is refused: every command that loads
source through the loader, such as ridl check, ridl build and ridl diff,
exits 2 with an error that names the extension as unsupported, and compiles
nothing.
It writes ~/.ridl/cache and ridl.lock, but only when the manifest
declares [imports]. ridl check loads, resolves, and checks the
workspace. When the checked-out manifest (or a package in it) has an
[imports] table, it also materializes each remote unit. It reads the unit from
vendor/<key>/ at the manifest root when that directory exists and ridl.lock
pins a tree for the unit's URL (see ridl vendor); otherwise
from ~/.ridl/cache (ADR-0002 §7, indexed by URL and by the fetched artifact's
SHA-256) or, on a cache miss outside --frozen, fetches it. Where ridl.lock
pins no tree for the unit, the cache or the fetch pins it first, and a
vendored directory, if present, is then verified against that pin and read in
place of the cache entry. It verifies the
tree hash of the tree it read, which for a vendored tree that no longer
matches is MANI-107, with a message that names ridl vendor; loads the unit
as a read-only dependency; and on a clean run regenerates ridl.lock at the
workspace root with both hashes. A remote unit's errors fail the check, and its
warnings are not shown. Every other fixture on this page has no [imports], so
none of them reach either write. The transcript below is an illustration, not a
recorded run: the URL names a local HTTP stub, and the two hashes are
placeholders that stand for the values a run computes.
ridl check && cat ridl.lock
# ridl.lock — generated by ridlc; do not edit by hand.
# Regenerated on every successful resolution (ADR-0002 §7).
[entries."http://127.0.0.1:8934/remotepkg.tar"]
sha256 = "<the SHA-256 of the fetched artifact>"
tree = "<the hash of the unpacked tree>"
ridl build and ridl baseline materialize remote units the same way, and
ridl build generates their packages beside the workspace's own. No IR dump,
catalog, baseline snapshot or ridl lock entry includes a remote unit.
--frozen verifies against the existing ridl.lock instead of fetching or
regenerating it, exactly as its description says: with no ridl.lock on disk
yet, ridl check --frozen over the same workspace fails closed (MANI-103,
exit 1) rather than writing one. A ridl.lock written before the tree hash
existed has no tree; under --frozen that is MANI-108, and a run without
--frozen adds the hash.
ridl lock, ridl test, ridl diff, ridl lsp and ridl mcp read
vendor/<key>/ and then the cache the way --frozen does: they never fetch and
never write ridl.lock. In a fresh clone, run ridl check once first, or run
ridl vendor; otherwise these commands report MANI-103 (no ridl.lock entry)
or MANI-104 (the pin is neither in vendor/ nor in the cache).
A ridl.lock written before the tree hash existed gives them MANI-108
too, and the same ridl check adds the hash.
Exit codes. 0 when nothing is wrong — a clean run prints nothing at all, to either stream. 1 when a diagnostic is an error, for example a range whose minimum exceeds its maximum:
ridl check
error[TYPL-104]: range minimum 250 is greater than maximum 0
┌─ ./demo.ridl:3:19
│
3 │ type Speed : km/h [250.0..0.0 step 0.5]
│ ^^^^^^^^^^^^^^^^^^^^^
The same run with --format json prints the JSON diagnostic contract, also
produced by ridl_core::diag::to_json, as a bare array:
ridl check --format json
[
{
"code": "TYPL-104",
"severity": "error",
"message": "range minimum 250 is greater than maximum 0",
"span": {
"path": "./demo.ridl",
"start": {
"line": 3,
"column": 19
},
"end": {
"line": 3,
"column": 40
}
},
"labels": [],
"fixes": []
}
]
A diagnostic whose code is a lint carries one more field, lint,
which holds the lint name, for example "lint": "unbounded-float" next to
"code": "TYPL-102". The field is absent for a code that is not a lint and
for a diagnostic with no code. severity is always the effective severity,
after the levels in the [lints] table of ridl.toml are applied, so a lint
set to deny reports "severity": "error" and a lint set to allow is not in
the array. The text report names the lint in a note after the snippet
(= lint: ...), as the examples below show.
--format sarif writes one SARIF 2.1.0
log to stdout, for code-scanning viewers such as GitHub code scanning. The log
holds one run. Its tool.driver.rules lists every catalogue code, errors
included, with the lint name as the rule name when the code is a lint and the
default level as defaultConfiguration.level. Each result has the code as its
ruleId, the effective level (error, warning, or note for an info), the
message, and one location; each label becomes a related location. Columns
count Unicode code points (columnKind is unicodeCodePoints). The log
carries no fix-its, which the JSON output carries, and no helpUri. A
diagnostic with no source file, such as a lockfile or remote-fetch diagnostic
(MANI-1xx), has no location. An uncoded diagnostic has no ruleId and no
ruleIndex. An artifact URI is relative to the current working directory,
with / separators and every segment percent-encoded; it carries uriBaseId
%SRCROOT%, which the run's originalUriBaseIds resolves to the working
directory as a file:// URI. A file outside the working directory is an
absolute file:// URI with no uriBaseId. The comparison is lexical, not
through the filesystem: a relative path is joined onto the working directory,
and an absolute path is compared with the working directory as the operating
system spells it, so an absolute path that reaches the working directory
through a symbolic link (/tmp on macOS, for example) counts as outside it.
Run ridl check --format sarif from the repository root, with the path to
check given relative to it, so that every URI is relative to the root, which
is what a code-scanning upload expects. The exit code is the same as for the
other formats.
2 when the workspace itself cannot be found:
ridl check
error: no `ridl.toml` found at or above `.`
An explicit --baseline naming a path that does not exist is the same exit
2, for the same reason — asking for a baseline that is not there is a mistake
worth reporting, while a missing default baseline is silently skipped:
ridl check --baseline ./nope
error: the baseline `./nope` does not exist
A baseline directory that holds IR artifacts but no .ir.json snapshot —
named with --baseline or auto-discovered at .ridl/baseline/ — is also
exit 2, with a message naming an artifact it found: a baseline stays
.ir.json (ADR-0014 decision 5), so such a directory is a
baseline in a refused encoding, not the silently skipped "no baseline
published yet" state. An empty directory is still that silent state when
auto-discovered; named explicitly with --baseline, it is a different
refusal (below). A snapshot-named entry inside the directory that cannot be
stat'ed — a symlink to a file that is gone — is exit 2 under either, naming
the entry, because skipping it would read the directory as one snapshot
short.
So is a directory whose .ir.json snapshots sit one level below it rather
than inside it — --baseline .ridl where .ridl/baseline was meant. The
message names the subdirectory it found and gives the path to pass instead.
Snapshots are read from one directory and never from the directories below
it, so a directory of this shape is a path aimed one level too high, not a
layout to descend into.
An explicit --baseline that yields no snapshot at all — an empty directory,
or one whose snapshots sit two or more levels down rather than one — is the
same exit 2: the message names the directory, says to point --baseline at
the directory that holds the snapshots (ridl baseline publishes them to
.ridl/baseline/ at the workspace root), and only then offers
ridl baseline --out <directory> for publishing a first baseline into that
directory. The offer is left out when the directory is a source tree — it
holds a ridl.toml, or a .typl, .ridl or .rsdl file, directly inside
it — since publishing a baseline there would write snapshots next to the
sources. Auto-discovery asserts nothing about a baseline being present, so a
.ridl/baseline/ directory found this way, empty or absent, keeps the
silent skip the two paragraphs above do not touch.
The baseline desk check. With .ridl/baseline/ present at the workspace
root — written by ridl baseline — ridl check compares
the workspace against it and warns (RIDL-407) on every interaction whose
declaration order moved, and every struct field or union arm change ridl diff gates on that concerns an ordinal. At its default level the warning does
not change the exit code; RIDL-407 is the lint ordinal-changed, so
ordinal-changed = "deny" in [lints] reports it as an error,
which makes the run exit 1, and ordinal-changed = "allow" removes it. The
warning for a field or arm
follows the verdict the gate reads, so the two agree: a member inserted, one
removed, one moved in an edit that added or removed no member, and one
appended beside such a change each draw one warning, and an append alone
draws none — not even one the gate reports breaking because the field's type
excludes 0 (driftsys/ridl#598) — except an arm added to a result union, whose arms are its
transport identity (ADR-0008 decision 4). A removal draws one whether or not
a reserved tombstone keeps the slot, because the diff does not yet read a
composite body's reserved entries. The diff reports no reorder beside an
addition or a removal, so the warning for the added or removed member names
the siblings whose ordinal changed. Reordering two events in a published
interface:
ridl check
warning[RIDL-407]: `doorOpened` has moved in `VehicleStatus` since the published baseline (position 1 there, position 2 here). Declaration order is the wire identity of an interaction (ridl §11), so a consumer built against the baseline would now bind this slot to a different interaction — put the declarations back in the baseline's order and add new ones at the end
┌─ ./demo.ridl:7:3
│
7 │ event doorOpened : DoorState @[100ms..1s]
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
│
= lint: `ordinal-changed` (set its level in `[lints]` in ridl.toml)
warning[RIDL-407]: `doorClosed` has moved in `VehicleStatus` since the published baseline (position 2 there, position 1 here). Declaration order is the wire identity of an interaction (ridl §11), so a consumer built against the baseline would now bind this slot to a different interaction — put the declarations back in the baseline's order and add new ones at the end
┌─ ./demo.ridl:6:3
│
6 │ event doorClosed : DoorState @[100ms..1s]
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
│
= lint: `ordinal-changed` (set its level in `[lints]` in ridl.toml)
That run exits 0: two RIDL-407 warnings and an otherwise clean compile stay
clean. The desk check runs only after a compile with no error diagnostic other
than RIDL-409 — a live interfaces.lock entry with no declaration, which
leaves nothing out of the IR the desk check compares — and other than a lint
raised to deny. A workspace with any other error draws no RIDL-407 warning in
addition to that error: it exits 1, exactly as it would with no baseline
present. Entered at a workspace member, the run reports only the diagnostics of
files under the member (ADR-0026 decision 10), and the desk check compares the
whole workspace: an error in another member is not reported, the desk check
does not run, and the run exits 0, exactly as it would with no baseline
present. Run ridl check at the workspace root to see that error and, once it
is fixed, the member's RIDL-407 warnings. A workspace whose only errors
are RIDL-409 still exits 1, and the desk check runs over it: when exactly one
declaration without an entry has the published shape of the orphan entry's
interface, the desk check adds a label to that RIDL-409 naming the
ridl lock --rename command to run. A lint raised to deny in
[lints] is an error for the exit code but does not stop the desk check: the
run reports the denied lint and the RIDL-407 warnings together, and exits 1.
ridl baseline
ridl baseline --help
Publish the current workspace as a baseline: one `<pkg-name>.ir.json` snapshot per package and one `<unit>.catalogs` file per unit with an interface, written to `.ridl/baseline/` at the workspace root
Usage: ridl baseline [OPTIONS] [PATH]
Arguments:
[PATH] [default: .]
Options:
--out <DIR> Write the snapshots and the catalog files here instead of `.ridl/baseline/`
-h, --help Print help
It writes one <package-name>.ir.json file per package in the workspace,
under .ridl/baseline/ at the workspace root or under --out when given —
and, exactly like ridl check, ridl.lock at the workspace
root when the manifest declares [imports] (ridl baseline builds through
ridlc build, non-frozen, so the same materialization step runs). Publishing
the snapshots is wholesale: the target directory ends up holding exactly the
snapshots the workspace declares now. Nothing else in that directory is touched
except the *.catalogs files, which publication replaces as
the catalog files below describes. The snapshots stay one
per source package; ridl diff groups them by
unit when it compares them. The workspace's interface numbers must be recorded first: a
provisional number is refused (RIDL-411, under the publication gate below), so
a package with interfaces runs plain ridl lock before its
first publication. A two-member workspace with no [imports], its locks
written, publishes two files and no ridl.lock:
ridl baseline && find .ridl/baseline -type f | sort
.ridl/baseline/veh.cluster.ir.json
.ridl/baseline/veh.common.ir.json
The catalog files. Beside the snapshots, ridl baseline writes one
<unit>.catalogs file per unit that has at least one interface shape, named
after the unit's manifest name, in the same directory. A file is a text file:
a first line that starts with #, then one catalog hash per line as 64
lowercase hexadecimal characters, newest first. An empty line and a line that
starts with # are skipped, and a hash is listed once. A line may end with
CRLF, as a checkout with core.autocrlf writes it; a line with any other
padding, such as a space, is refused. The first hash is the
catalog hash of the baseline being published, computed as
ridl build --emit catalog computes it. The hashes that follow
are the ones of the file being replaced, copied as they are and never
recomputed from a snapshot. They are carried over when ridl diff's
classifier, scoped to the unit, judges the change from the replaced snapshots to
the fresh ones compatible or identical, and when the new hash is the first hash
of the replaced file: the catalog did not change, so every hash the file lists
still applies, whatever the verdict. The scope is the unit's own source
packages and every declaration its interfaces reach, in the unit or in another
one, so a breaking change in a package that the unit does not reach leaves the
unit's chain as it was. A breaking change that changes the catalog restarts the
chain: the file then holds the one new hash. The file being replaced is carried
only when its first hash is the hash recomputed from the snapshots published
beside it; a file that does not describe those snapshots, as a partially
hand-edited baseline leaves behind, restarts the chain instead of carrying a
hash that may reach past a breaking change. A unit with no published snapshot,
or with no <unit>.catalogs file in the replaced baseline, also starts a chain
with the one new hash, and a file left under the name of a unit that had no
published snapshot is not carried, nor read. The file of a unit with a published
snapshot that cannot be read, or has a line that is neither a comment nor a
hash, or lists the same hash twice, stops the publication with exit 2: the
message names the file and the line, and says to repair the file or remove it
to start the unit's chain again.
Publication replaces the files in this order, so that an interrupted run never
leaves a history beside a snapshot it does not describe: every published
*.catalogs file is removed, then the fresh snapshots move in, then the fresh
*.catalogs files move in, and last the snapshots of packages the workspace no
longer declares are removed. A file left by a unit that no longer has an
interface shape is removed by the first step and not written again. The
histories are written only after the publication gate below has passed, so a
refused run leaves every published file as it was.
The publication gate. Before replacing the published snapshots,
ridl baseline compares the snapshot it is about to publish against the one
already there, and refuses to replace it when, in an interface body both
declare, the replacement breaks the tombstone rule of ridl §11 — RIDL-408,
exit 1, the published snapshots left as they were (the compile has already
run, so ridl.lock is written as on any other run). The refusal covers four
shapes: an interaction is gone from the source with no reserved line at
all; the source retires it with a reserved line, but at an ordinal other
than the one the interaction held; the baseline already retired the
interaction with a reserved line and the source has dropped that line; or
the source declares a live interaction under a name the baseline retires.
The interface level of the gate is the lock's: an interface whose number is
provisional — a declaration with no entry in the unit's interfaces.lock
— is refused (RIDL-411, exit 1, nothing published), on a first publication as
on a replacement, until plain ridl lock records the number; and a number the
published baseline holds that the fresh snapshot neither carries nor retires
is refused too (RIDL-412). That covers a lock line deleted by hand, since a
live entry with no declaration already fails the build with RIDL-409. It also
covers a package deleted, without retiring its numbers, from a unit that still
has other packages, and a whole unit deleted from the workspace. For a package
the message names the line to restore in the unit's interfaces.lock. For a
whole unit the lock left with it, so the message names the deliberate override
instead: delete the snapshots of that unit from .ridl/baseline/, so that the
next publication holds no number to lose. A published snapshot written before
the IR recorded the unit carries no unit; the gate compares it in the unit of the fresh package
of the same name, so the migration to one interfaces.lock per unit — delete
the per-package lock files, run ridl lock, run ridl baseline — passes
RIDL-412 when the unit's new numbering reaches every published number. When
it does not, because a legacy lock held retired entries, remove
.ridl/baseline/ and run ridl baseline again: a first publication is not
compared against a published number, and no flag overrides the gate. A whole
service removed from the source is reported by ridl diff as breaking but is
not refused here (ridl §17.14), and a named-form service's list is a set the
gate does not read. Deleting doorClosed outright, with doorOpened and
doorLocked still declared:
ridl baseline
error[RIDL-408]: `doorClosed` is gone from the source but the baseline being replaced still declares it in `VehicleStatus` (ordinal 2). Publishing would free its ordinal for a later interaction to reuse, with nothing left to record that it was ever taken. Retire it in place with `reserved doorClosed`.
┌─ ./demo.ridl:3:11
│
3 │ interface VehicleStatus {
│ ^^^^^^^^^^^^^
A first publication — the output directory absent, or present and holding no
.ir.json snapshot yet — has no published snapshot to compare against, so
RIDL-408 and RIDL-412 never refuse it. This is the same empty-directory shape
ridl check --baseline refuses when it is named explicitly:
ridl check --baseline refuses it because the user named the directory as a
baseline to compare against, while ridl baseline --out treats it as a first
publication because it is the directory being written. RIDL-411 still refuses
a first publication that holds a provisional interface number, because
RIDL-411 reads the fresh snapshot alone.
Exit codes. 0 on a clean publish. 1 when a diagnostic is an error, or when the publication gate above refuses the replacement — in both cases the existing baseline is left exactly as it was, because a re-publish must never destroy a good baseline with a broken one:
ridl baseline
error[TYPL-011]: unknown type name `Nope`
┌─ ./demo.ridl:4:22
│
4 │ event doorClosed : Nope @[100ms..1s]
│ ^^^^
2 when the workspace itself cannot be found, the same as ridl check, when
the output directory cannot be read or written, when a published .ir.json
snapshot fails to parse, when two published snapshots declare one package, or
when an entry of the output directory named like a snapshot cannot be
stat'ed:
ridl baseline /nonexistent/ridl/workspace
error: `/nonexistent/ridl/workspace` does not exist
A published snapshot that cannot be parsed is left untouched rather than
silently overwritten: replacing it would destroy whatever ordinal record it
held without any report. ridl cannot tell a damaged file from a snapshot
that a toolchain with a different IR schema wrote — the reader rejects an
unknown field (ADR-0014 decision 14) and a snapshot carries no
schema marker — so the message names a remedy for each cause, and neither
remedy discards the record unread:
ridl baseline
error: ./.ridl/baseline/veh.cluster.ir.json: the IR snapshot is not valid IR v2 JSON: expected value at line 1 column 1; the file is left as it is, because a record that cannot be read cannot be shown safe to replace. If the file is damaged, restore it from version control or resolve the merge conflict left in it. If a toolchain with a different IR schema wrote it, check the source against it with that toolchain (`ridl check --baseline`), then remove the file and run `ridl baseline` with this one
The same parse error on a ridl diff input, or on a file or directory named
with ridl check --baseline, is reported without that remedy: only the
published baseline that ridl baseline is about to replace is the record the
remedy protects.
Two published snapshots that declare one package are two records of the same
ordinals, and the gate has no rule for choosing between them, so it refuses
rather than compare against either. Both files are left as they are; the
message names the package and both files. Here zz-copy.ir.json is a copy of
the published snapshot left beside it:
ridl baseline
error: two published snapshots declare the package `veh.cluster`: `./.ridl/baseline/veh.cluster.ir.json` and `./.ridl/baseline/zz-copy.ir.json`; both files are left as they are, because the gate cannot tell which one is the published record. Remove the copy that is not the published record (restore the directory from version control if unsure), then run `ridl baseline` again
This refusal is ridl baseline's alone: ridl check --baseline and
ridl diff read such a directory as a snapshot set, matching packages by
name.
An entry of the output directory named like a snapshot whose metadata cannot
be read — a symlink to a file that is gone — is also exit 2, naming the entry.
Skipping it would read the directory as one snapshot short, which is a first
publication to the gate, and the publication would then replace the entry
with a snapshot that had dropped the ordinal record. The entry is left as it
is. ridl check reports the same entry the same way when it finds it under
.ridl/baseline/ by auto-discovery, and so does ridl diff over a directory
side:
ridl baseline
error: cannot read the snapshot ./.ridl/baseline/veh.cluster.ir.json: No such file or directory (os error 2)
ridl build
ridl build --help
Compile to the selected artifacts (defaults to the current directory)
Usage: ridl build [OPTIONS] [PATH]
Arguments:
[PATH]
[default: .]
Options:
--out-dir <OUT_DIR>
[default: out]
--emit <EMIT>
Possible values:
- rust: Idiomatic Rust source, written to `<base>.rs`
- ir-json: The lowered IR v2 as exact-decimal JSON, written to `<base>.ir.json`, and the lowered system to `<pkg.Name>.system.json`
- ir-text: The lowered IR v2 as prototext, written to `<base>.ir.txtpb`, and the lowered system to `<pkg.Name>.system.txtpb`
- ir-binary: The lowered IR v2 as protobuf binary, written to `<base>.ir.binpb`, and the lowered system to `<pkg.Name>.system.binpb`
- typescript: Idiomatic TypeScript source, written to `<base>.ts`, and the codec module `<base>.codec.ts` with `[backend.typescript] codec = true`
- proto: The proto3 schema, written to `<base>.proto`
- flatbuffers: The FlatBuffers schema, written to `<base>.fbs`
- podbuf: The podbuf C header, written to `<base>.h`
- codegen-model: The lowered codegen model (`ridl.codegen.v1`) as canonical protobuf JSON, written to `<base>.codegen.json`
- codegen-request: The whole codegen request a build hands a backend, as canonical protobuf JSON, written to `<base>.codegen-request.json`
- catalog: The catalog descriptor an engine reads, written to `<unit>.catalog.binfb` for every unit that declares an interface or a service with an inline body: a FlatBuffers file of the unit's interfaces, their members and their catalog hash
[default: rust]
--plugin <LANGUAGE[=PATH]>
A codegen plugin to run beside the emits, once per package: `<LANGUAGE>` runs `ridlc-gen-<LANGUAGE>` from PATH, `<LANGUAGE>=<PATH>` runs the executable at PATH. Repeatable
--plugin-timeout <SECONDS>
Seconds a plugin may run per package before it is killed
[default: 60]
--frozen
Verify remote imports against `ridl.lock` without fetching or regenerating it (CI mode, ADR-0002 §7)
--deployment <NAME>
The deployment to carry in each codegen request. With one deployment in the workspace it is selected without this flag; with several, none is carried unless named
-h, --help
Print help (see a summary with '-h')
--emit accepts a comma-separated list, so one invocation can write several
targets. --frozen is the same flag as on ridl check: it is
ridlc build --frozen, documented word for word since
ADR-0010.
--deployment NAME picks the deployment of the workspace's system that every
codegen request carries. Without it, the request carries the deployment when
the system declares exactly one, and none when it declares several or the
workspace has no system. A single declared deployment that an RSDL-7xx error
removed from the system is not carried either: the build reports that error and
exits 1. A NAME that the system does not declare exits 2 and
names the deployments it does declare, unless the build already has an error of
its own: that error takes precedence, and the build reports it and exits 1.
When no name can be right, the message says which reason it is — the workspace
declares no system, or the system declares no deployment. A deployment that an
RSDL-7xx error removed from the system is not unknown either: the build
reports that error and exits 1.
The compatible catalogs. When the build writes a catalog descriptor or
generates code, ridl build reads .ridl/baseline/ at the workspace root, as
ridl check finds it, and writes into the unit's descriptor and
into the catalog.compatible field of the unit's codegen model the earlier
catalog hashes it judges compatible with the current catalog. The list comes
from the unit's <unit>.catalogs file (see ridl baseline),
in the file's order, newest first, and it never holds the current catalog's own
hash. It is empty when the workspace has no baseline directory or the directory
holds no .ir.json snapshot, when the baseline holds no package of the unit,
when the unit has no <unit>.catalogs file (or the baseline has none at all), and when ridl diff's classifier,
scoped to the unit, judges the change from the baseline to the tree breaking
and the current catalog hash is not the file's first hash. When the two hashes
are equal the catalog did not change, and the list holds the file's other
hashes whatever the verdict. The file is used at all only when its first hash
is the hash recomputed from the baseline's own snapshots of the unit; a file
that does not describe those snapshots gives an empty list. The recorded file
is not changed by the build. An
IR dump reads no baseline. A baseline with at least one <unit>.catalogs file that cannot be loaded, or
a <unit>.catalogs file that cannot be read, is exit 2; a baseline with no
<unit>.catalogs file is not loaded. A compile with an error diagnostic gets no
list, and its baseline is not read. When the workspace names [imports] and a
list is computed, the build prints a note on stderr that a unit's list covers
the workspace's own packages only.
rust is a language backend, and it writes the whole generated surface of
a package in one file: the domain types (a struct, an enum, an enum set, a
union and a named scalar, each with its typl constraints enforced at
construction) always, and, when [backend.rust] encodings in the manifest names
flatbuffers or proto3, the codec of each encoding named — encode,
verify, decode and a MAX_SIZE bound — the interaction descriptors, with
one size table per encoding, and the interaction face. With no encodings list
the file holds the types only. Each
interface gets the parts its own interactions need: a
Client for the consumer side when it carries any interaction at all; a
Publisher for the producer side when it carries a signal or an event; and a
Provider trait the application implements plus a serve function whose
future settles the claims waiting on a port, when it carries a command or a
query; and, when it carries an event, a command or a query, a blocking
module under the crate's std feature, on by default, holding the same
Client, and the same serve when there is one, as blocking calls with a
timeout, each waiting call ridl_rt::task::block_on over the async form. So a
signal-only interface gets
a Client and a Publisher and nothing to settle with and nothing to block
on, a command-only interface gets a Client, a Provider, a serve and a
blocking module and no Publisher, and an interface carrying only fixed
declarations gets no face module at all, and neither does an internal
interface (the design record
gives the rule). Beside the per-package files it
writes a lib.rs crate root and a Cargo.toml naming ridl-rt with the
encoding's feature, and with ridl-rt/std behind the crate's own std.
Using the generated Rust code shows how a program uses
the crate, and lists its Cargo features.
Generated files are marked. The .rs, .ts, .proto and .fbs files, and
the lib.rs and Cargo.toml of the crate root, begin with a line comment that
says the file is generated:
// @generated by ridl from package veh.cabin. Do not edit.
veh.cabin is the dotted package name, also in single-file mode, where it is
the package the file declares and not the file stem. The lib.rs and
Cargo.toml of a standalone package build name that package in the same way.
Those of a workspace build belong to no one package, so their line reads
@generated by ridl. Do not edit.. Cargo.toml uses # as the comment token.
The marker is the first line because rustfmt looks for @generated only in
the first 5 lines of a file. It carries no version and no timestamp, so a
regeneration with the same inputs gives the same bytes. The TypeScript file has
/* eslint-disable */ after the comment block. The IR dumps (JSON,
prototext and binary), the <base>.codegen.json file and the
<unit>.catalog.binfb file carry no marker.
A licence header goes after the marker. [codegen] header-file in the
workspace root's ridl.toml (or in the ridl.toml of a standalone package)
names a text file, and ridl build writes its lines after the marker in every
file that carries one, then one blank line:
[codegen]
header-file = "LICENSE-HEADER.txt"
The path is relative to the manifest that declares it. The file holds plain
text without comment tokens, and each target gets its own comment syntax. ridl
accepts CRLF line ends and a lone CR as a line break, removes trailing
whitespace from each line and drops leading and trailing blank lines, so a file
with no text left means no header. A blank line inside the text becomes a bare
comment token. Single-file mode has no manifest and writes no header. MANI-011
is an error when the file cannot be read, when it is not UTF-8, and when it
contains a control character, which means a C0, DEL or C1 character (U+0085
included), U+2028 or U+2029, other than a tab and a line break; ridl check
reports it too. MANI-012 is an error when the manifest of a workspace member
sets the key: only the root sets it. MANI-013 is an error when a ridl.toml
sits inside the tree of a unit, and MANI-014 is an error when two units declare
the same source package.
A build overwrites a lib.rs or Cargo.toml in the output directory only when
the file begins with this marker, or with the marker an earlier release wrote
(// Generated by ridlc. Do not edit. and # Generated by ridlc. Do not edit.).
Any other file is taken to be hand-written and is not touched.
Mark the output in version control. ridl build does not write a
.gitattributes file. To collapse the generated files in review, add a line
such as this one, adapted to your output directory:
generated/** linguist-generated=true
The crate root allows two lints. The emitted lib.rs carries
#![allow(clippy::derivable_impls, clippy::module_inception)], after the
attribute that makes the crate no_std and before the line that links alloc
under the name std (Cargo features
says when each applies, and what a single-file consumer declares). These are the
two lints that the emitter draws on the examples/cabin crate under
cargo clippy: module_inception from the package and interface naming
(veh.cabin and Cabin), and derivable_impls. The list is closed for that
crate: the repository's just demo replaces allow with expect and runs
clippy with -D warnings on the generated crate for examples/cabin, so a new
lint, or a listed lint that no longer fires on it, fails that check. Single-file
mode writes no lib.rs. A consumer that includes the generated file with
include! puts #[allow(clippy::derivable_impls, clippy::module_inception)] on
the mod that wraps the include!. Explicit crate-root links in the generated
docs can be unresolved when the file is included inside another module
(Documentation in the generated code).
Two things it may leave out, each with a note in the source it writes. A
type whose size the compiler cannot bound — one that reaches itself, or a
stream — carries no codec, and gets a __RIDL_FB_NO_CODEC_<NAME> constant
whose documentation names the member and the reason. An interface the face
cannot carry — a parameter with no declared type, a stream in either
position, a query whose reply is a fallible T | E, or a contract clause
outside the form the translator accepts — is skipped along with its
descriptors, and gets a
__RIDL_NO_FACE_<NAME> constant naming the interface and the reason, and,
when an issue tracks the limit, that issue. An internal interface gets no
face in any case
(the design record
gives the rule). When its descriptors are refused, its note says that the
descriptors are absent and gives the reason. Neither is an error: the rest of
the package is emitted, and the build succeeds.
The face names the ridl-rt port traits and nothing else: the crate carries no
runtime and opens no socket, so an application supplies the ports. The one
runtime in this workspace is ridl-loopback, which runs in process.
examples/cabin/ is a worked example — a schema, and a consumer program
against the crate built from it — and just demo generates that crate and runs
the program, which prints one round trip per interaction kind through the
async client and the command and the query again through the blocking one.
The manifest says which encodings a backend emits. There is no flag for the
payload encoding. A [backend.<name>] table in the manifest holds an
encodings list for the backend of that name, and ridl build sends each
backend its own list:
[package]
name = "veh.cabin"
version = "1.0.0"
[backend.rust]
encodings = ["flatbuffers"]
The names are flatbuffers, proto3 and podbuf. An absent table, an absent
key and an empty list all mean types only. The Rust backend builds
each of the three, alone or together. A podbuf codec buffer starts on an
8-byte boundary, and the generated size tables hold Aligned buffers for it.
The generated Cargo.toml enables one ridl-rt
feature per named encoding, and none for types only. A single-file build has no
manifest, so it is types only. Each emitted codec names its encoding, as
::ridl_rt::encoding::FlatBuffers or ::ridl_rt::encoding::Proto3; the face
takes its encoding from its port
type instead, so a consumer that binds a generated face to a runtime it did not
write names an encoding only where it builds that runtime — over
ridl-loopback, Loopback::new for FlatBuffers and
Loopback::<E>::with_encoding for another. A program that writes its own port
names the encoding in that port's Encoded implementation instead
(Writing a port).
[backend.rust] wasm = true adds a module wasm to the generated crate,
written to __ridl_wasm.rs. The module lists the payload types that have a
FlatBuffers codec in WASM_PAYLOAD_TYPES, sorted by qualified name
(veh.cabin.Temperature; an induced tuple by its Rust name), and the typl rule
names in WASM_RULES, sorted. A type_id is an index into the list of the same
build. When the target is wasm32 and the generated crate's feature
wasm-exports is on, the module exports ridl_alloc, ridl_free,
ridl_catalog_hash, ridl_verify and ridl_type_id, and no other function.
ridl_type_id returns the type id of a payload type named by its qualified
name, or -1 for a name the list does not hold.
ridl_verify(type_id, ptr, len) returns 0 when the bytes verify as the type at
index type_id, 1 for a malformed buffer, 2 plus the index of the broken rule
in WASM_RULES, and -1 for an unknown type_id. A null ptr with a length,
and a range past the end of memory, return 1. ridl_alloc returns 0 when the
length is 0 or the allocation fails. The generated Cargo.toml declares the
feature wasm-exports = ["std"] and no crate type, so a Rust consumer of the
crate links no export. The wasm-exports feature gives the
command that builds the module. The module writes the catalog hash of one unit: the root
package's unit, or the one unit of the workspace's own packages when the
manifest is a [workspace]. A build with no root package and other than one
unit is refused. The option needs encodings to name flatbuffers, and the
Rust backend refuses it otherwise. A package whose first segment is wasm
refuses it too. A build with the option off removes a __ridl_wasm.rs that an
earlier build wrote, and a build with it on refuses to overwrite a
__ridl_wasm.rs that does not start with the generated marker. Single-file
mode has no manifest, so it has no wasm module.
[backend.typescript] codec = true writes <base>.codec.ts beside the
TypeScript module of every package that has a payload root with a codec (its
own, or one of another package that its interfaces name), and turns the wasm
module on when [backend.rust] writes
no wasm key. A written wasm = false, and a manifest with no
[backend.rust] table, are MANI-019. [backend.rust] encodings must still name
flatbuffers, and the Rust backend refuses an implied wasm module otherwise.
[backend.rust] transcode = true asks the Rust backend for the transcoding
functions between the encodings of the same table. The table must write at least
two encodings elements; every written element counts, valid or not. Fewer than
two written elements, or an absent encodings key, is MANI-020, reported on the
transcode value. A written element that is unknown or repeated is also
MANI-015. An encodings value that is not an array is MANI-015 only. The
ridlc request carries a transcode option with the value true for that
table, and no transcode option otherwise.
MANI-015 is an error when the encodings value is not an array of the known
names or repeats a name, when wasm, codec or transcode is not a boolean,
when a [limits] value is not an integer of 0 or more, or when [limits],
[limits.kind] or [limits.interaction] is not a table, and MANI-016 is an error when the manifest of a workspace member holds
a backend key or [backend.<name>] table: only the workspace root, or a
standalone package, sets it. Any other key under [backend.<name>] is
MANI-005. The table is not the backend. attribute namespace of an rsdl
declaration. The lint RSDL-807 link-encoding-not-emitted warns when a link of
the selected deployment carries an encoding that the emitted backend's list
does not name. It is compared once per emitted backend that can carry a codec:
the rust emit and each --plugin, and a backend named by both an --emit
and a --plugin is compared once. A schema emit (proto, flatbuffers), the
podbuf emit, the typescript emit and codegen-model are not compared, because their
backends take no encodings option, and codegen-request is not compared
either, because it writes its options into the request rather than emitting a
codec from them. A [backend.<name>] table whose
encodings list is empty declares that the backend emits no codec, and it is
not compared either; a table written with no encodings key at all is the same
declaration, as the manifest records the two forms alike. A backend with no
table at all is compared, so a build that declared no encodings is still
reported. An empty list is how a
schema-only plugin silences the lint for its own backend, where the [lints]
level silences it for every backend at once.
[limits] gives the interactions of a unit their payload limits, in bytes of
encoded payload (ADR-0030
decision 12). A unit sets it in its own ridl.toml; the table of a workspace
root does not apply to its members, and a package of a unit's subdirectory
takes the unit's table.
[limits]
default = 4096 # every kind with no entry below
[limits.kind]
signal = 256 # signal, fixed, event, command or query
event = 2048
[limits.interaction]
"Climate.temperature" = 64 # Interface.member, quoted because of the dot
The table of a remote unit that [imports] names is read too, so its
interactions record the owner's limits and the consumer computes the owner's
catalog hash. The consumer reports no RIDL-151 or RIDL-152 for it.
The limit of an interaction is the first that is found of: its entry in
[limits.interaction], the entry of its kind in [limits.kind], default,
and the payload ceiling of 16,256 bytes. No limit is above the ceiling. A key
of [limits.interaction] is the short name of the interface, or of the inline
service (the last segment of its dotted name), then ., then the member;
neither the package nor a dotted service name is part of it. When two
interfaces or inline services of one unit share a short name, a key that uses
the name is ambiguous.
An unknown kind in [limits.kind] is MANI-021, and a value above 16,256 is
MANI-022. A key that names no interaction of the unit, or that is ambiguous, is
RIDL-152, reported on the key. RIDL-151 is reported on an interaction whose
payload has a largest encoded size, in an encoding that a [backend.<name>]
table enables (FlatBuffers when no table names one), above the limit that an
entry of [limits] gives it, or no finite bound. A command or query
checks its request and its reply against its one limit, and a fixed is sized
too. Both are errors, so they have no name and no level in [lints]. A payload
above the ceiling under a declared limit draws RIDL-150 as well; where no entry
gives a limit, only RIDL-150 applies. A package with an error that stops
ridl build writing artifacts, or that depends on one, is not checked, and a
unit with such a package reports no RIDL-152. An error in a manifest stops
RIDL-150 in every package of the workspace, and RIDL-151 and RIDL-152 only in
the packages beneath that manifest.
A [limits] entry that applies to an interaction is recorded in the IR, so
adding, removing or editing one changes the catalog hash of the interaction's
unit. An interaction that no entry reaches records no limit, and a unit with no
[limits] table keeps its hash.
proto is a wire backend (ADR-0013 decision 2): it emits the typl
surface — structs, enums, enum sets and unions, projected to proto3 messages
and enums, with named-scalar constraints carried as comments — plus the
interaction identity table, one enum per interface giving each signal, event,
command, query and fixed its ordinal. It emits no service block, no call
face, and no value store; store and dispatcher generation is not part of this emit
(ADR-0018 decision 16).
flatbuffers is the second wire backend, with the same two tiers and the
same ceiling. Its projection rules differ from proto3's where the targets
differ — a union is isolated in a wrapper table, every struct is a table, a
map is a vector of generated entry tables with no (key), and enum values are
not prefixed because FlatBuffers scopes them inside their enum — and are
recorded in ADR-0019.
podbuf is the third wire backend: it writes the C header, <base>.h,
through which a C consumer reads a podbuf buffer that the gate has verified,
with no library. The header holds the preamble and descriptor structs, one head
struct per type with its pad members written out and _Static_assert checks
on every sizeof and offsetof, the constants of the enums and the union
tags, and inline accessor functions that read through the preamble's head size
and answer 0 for a field past it. It refuses a big-endian target with #error.
It takes no option. A package that lays out a struct, tuple or union of another
package inline defines that head again behind a guard macro, so the headers of
two packages can be included in one translation unit.
codegen-model is not a backend at all: it writes the lowered codegen
model — one package, resolved over the scope the build read, with the scalar
classes, the pinned name transforms, the resolved type references, the typl
init and closure rules, the tombstones in their slots, the FlatBuffers
projection and the interaction facts computed once — as canonical protobuf
JSON, in the schema ridl.codegen.v1. It is the payload a codegen request
carries (ADR-0020 decisions 8 and 9), the same JSON value as the request's
model, so a plugin's fixture is a file ridlc wrote. The rust
backend reads the model and nothing else. The other three backends above still
read the IR, and a drift test in each of them asserts that what the backend
derives and what the model states are the same fact.
codegen-request writes the whole request, to
<base>.codegen-request.json: the same canonical protobuf JSON a plugin reads
on its standard input, with schema, toolchain, model, options,
artifactBase, generatedMarker, header and the deployment section. It is
the artifact a plugin author debugs against — the request their plugin would
receive for this package, written without running the plugin. Its model is
the value codegen-model writes, so the two emits state one fact. Unlike
codegen-model, it accepts the options of its own [backend.codegen-request]
table and writes them into the request's options, because those options are
part of what a plugin receives and an emit that refused them could never show a
request carrying any.
--plugin runs a codegen plugin beside the emits — a backend that is an
executable rather than part of ridl, over the contract
generate(CodegenRequest) → CodegenResponse (ADR-0020 decisions 9
and 10; the as-built record is
docs/design/codegen-plugins.md).
The value is a language, or a language and a path:
--plugin kotlinrunsridlc-gen-kotlin, found by walkingPATHin order and taking the first directory that holds a file of that name (on Windows,ridlc-gen-kotlin.exeis also tried). No directory holding it is an error namingridlc-gen-kotlin, and — like a compile error — a build that writes nothing.--plugin kotlin=/opt/gen/bin/kt-genruns the executable at that path and skips the lookup.ridlstill reports it asridlc-gen-kotlin, with the path beside the name.
The flag repeats, once per plugin. Each plugin runs once per package the code
emits are written for — ridl.std included, under the same rule as above — and
receives on its standard input one ridl.codegen.v1.CodegenRequest in canonical
protobuf JSON: schema ("ridl.codegen.v1") and toolchain (this ridl's
version) first, then model (the same JSON value as the package's
codegen-model artifact, one indentation level deeper), options (one
encodings option when the root manifest's [backend.<language>] table names
an encoding, as described above; empty otherwise; no flag sets one),
artifactBase, the <base> of the
emit list above, generatedMarker (the marker line) and header (the header
text, an empty string when there is none), both always present, and
deployment, the deployment section — the one --deployment
names, or, with no flag, the one the source declares when it declares exactly
one — absent when no deployment is carried. It answers on its standard output with one
CodegenResponse: files,
each a path relative to --out-dir with a text or binary content, and
diagnostics, each a severity and a message. ridl writes the files; the
plugin never touches the filesystem, so --out-dir means for a plugin exactly
what it means for rust. A path that is absolute, has a .. or . or empty
component, or contains \ is refused, and no file of that response is written.
A diagnostic is reported prefixed with the plugin's name, at its severity; an
error-severity one is a build that exits 1 with none of that response's files
written, as an in-tree backend's failure is. A non-zero exit, a response that
does not parse, a plugin that cannot be started, and a plugin still running
after --plugin-timeout seconds (60 by default; the plugin is killed) are each
one error naming the plugin, and the build exits 1. The plugin's standard error
is passed through.
This repository builds two plugins for its test suite, and no release
installs either: ridlc-gen-model is --emit codegen-model as a process, and
ridlc-gen-rust is --emit rust as a process. A parity test runs each one
through this path and compares its output with the in-process backend's, byte
for byte. The TypeScript, proto3 and FlatBuffers backends run in process only.
Writing a codegen plugin is the guide for a plugin
author.
It writes one file per package per --emit target, under --out-dir
(out by default), and — exactly like ridl check —
ridl.lock at the workspace root when the manifest declares [imports],
non-frozen. The exception is catalog, which writes one file per unit, named
<unit>.catalog.binfb after the unit's manifest name, when any source
package of the unit declares an interface or a service with an inline body,
and no file for a unit that declares neither. <base> in the --emit list
above is the package name when PATH is a package directory or a workspace
root, and the input file's stem in single-file mode; the catalog of a single
file is named after the file's package, which is its own unit.
When a package names a type from ridl.std, the standard package is written
beside your own as one more file per --emit target — ridl.std.rs,
ridl.std.ts, ridl.std.codegen.json — because generated code refers to
standard types by package path and does not compile without it, and the model
is lowered over the same scope. ridl.std.codec.ts joins them under
[backend.typescript] codec = true only when a package's codec module imports
it, for a payload type that is a ridl.std scalar. The three IR targets —
ir-json, ir-text, ir-binary — get no such file: a direct IR dump
records the packages the workspace declares, and ridl.std ships with the
compiler rather than with the workspace (ADR-0007 decision 15).
catalog gets no such file either: ridl.std declares no interface, so no
ridl.std.catalog.binfb is written.
When the workspace declares a system (rsdl reference §3.1), each of the three
IR targets also writes the lowered system — the closure, and every deployment
with its placements, links, routes and surface set (rsdl reference §13) — as
one more file named after the system's qualified name:
veh.topology.Vehicle.system.json for system Vehicle in package
veh.topology under --emit ir-json, .system.txtpb and .system.binpb for
the other two.
Building a workspace of two packages that name no standard type, with no
[imports]:
ridl build --out-dir out && find out -type f | sort
out/Cargo.toml
out/lib.rs
out/veh.cluster.rs
out/veh.common.rs
The Cargo.toml and the lib.rs are the crate root: the per-package files
are flat, and the crate root is what gives them the module paths the generated
code refers to each other by. Single-file mode writes neither, because one
file is not a crate.
and a single .typl file with --emit rust,ir-json:
ridl build speed.typl --emit rust,ir-json --out-dir out && find out -type f | sort
out/speed.ir.json
out/speed.rs
Exit codes. 0 when the build compiles clean and every requested artifact is written. 1 when a diagnostic is an error — nothing is written for a package that fails to compile. An RSDL-7xx error is the one exception: it blocks only its own deployment (rsdl reference §13), so the build writes every artifact, leaves that deployment out of the lowered system, and still exits 1:
ridl build --out-dir out
error[TYPL-011]: unknown type name `Bogus`
┌─ ./demo.ridl:4:25
│
4 │ signal currentSpeed : Bogus @10ms
│ ^^^^^
2 when the workspace itself cannot be found:
ridl build /nonexistent/ridl/workspace --out-dir out
error: `/nonexistent/ridl/workspace` does not exist
ridl test
ridl test --help
Run the property suite over a workspace: the range self-corpora and the contract-clause sampling (ridl §13). Exit 0 when every run passes, 1 on a self-corpus failure or an evaluation error, 2 on a compile error
Usage: ridl test [OPTIONS] [PATH]
Arguments:
[PATH] [default: .]
Options:
--samples <SAMPLES> Random parameter tuples drawn per `require` clause (minimum 1). Each clause also runs its parameters' boundary corpus, which is drawn first and is not counted here, so the total per clause is larger [default: 256]
--format <FORMAT> Output format for the report [default: text] [possible values: text, json]
-h, --help Print help
ridl test runs two checks per package: every constrained named type's
boundary and violation corpus against the constraint validator (a
self-consistency check of the toolchain, not of the model), and every
require clause's satisfiability, sampled over each parameter's boundary
values plus --samples random draws. ensure clauses are listed as observer
stubs and never evaluated — there is no runtime to produce the result they
need. The report goes to stdout; a compile error's diagnostics go to
stderr instead, and no report prints at all.
It writes nothing, ever. The report is printed, never saved. ridl test
runs the plain compile only — never the materialization step that makes
ridl check write ridl.lock — so pointing the same
[imports]-declaring workspace at ridl test produces no lockfile.
A clean run over an interface with two require clauses:
ridl test --samples 8
package cli.demo
ranges
Speed ok — 4 boundary accepted, 2 violations rejected
requires
Cruise.setTargetSpeed.require[0] ok — 3 boundary + 8 random of 12 satisfied (speed > 0.0)
Cruise.setTargetSpeed.require[1] ok — 4 boundary + 8 random of 12 satisfied (speed <= 250.0)
ensures
(none)
summary — requires: 2 total, 2 evaluated; ensures: 0 listed
The same run with --format json prints one JSON object per package, on one
line:
ridl test --samples 8 --format json
[{"contracts":[{"boundary_samples":4,"detail":null,"discarded_samples":0,"id":"Cruise.setTargetSpeed.require[0]","random_samples":8,"samples":12,"satisfied":11,"source":"speed > 0.0","status":"ok"},{"boundary_samples":4,"detail":null,"discarded_samples":0,"id":"Cruise.setTargetSpeed.require[1]","random_samples":8,"samples":12,"satisfied":12,"source":"speed <= 250.0","status":"ok"}],"package":"cli.demo","ranges":[{"boundary":4,"status":"ok","type":"Speed","violations":2}],"summary":{"ensures_listed":0,"nothing_evaluated":false,"requires_constant_false":0,"requires_errored":0,"requires_evaluated":2,"requires_skipped":0,"requires_suspect":0,"requires_total":2}}]
Exit codes. 0 above. 1 when a range self-corpus fails, or — the case a
require clause reaches in practice — evaluating one raises an error, such as
a division by zero every sampled input hits:
ridl test
package cli.demo
ranges
Divisor ok — 4 boundary accepted, 2 violations rejected
requires
I.c.require[0] ERROR — division by zero while evaluating `100 / (d - d) > 1`
ensures
(none)
summary — requires: 1 total, 0 evaluated, 1 errored; ensures: 0 listed
WARNING: no require clause was evaluated — this run tested no precondition (0 skipped of 1)
2 when the workspace fails to compile, when the workspace cannot be found, or
when --samples 0 is given — sampling zero values is refused rather than
silently clamped, because it would report every clause as unsatisfiable and
call that a finding:
ridl test
error[TYPL-104]: range minimum 250 is greater than maximum 0
┌─ ./demo.ridl:2:20
│
2 │ type Broken : km/h [250.0..0.0]
│ ^^^^^^^^^^^^
warning[TYPL-102]: `float` without both a range and a `step`
┌─ ./demo.ridl:2:15
│
2 │ type Broken : km/h [250.0..0.0]
│ ^^^^
│
= lint: `unbounded-float` (set its level in `[lints]` in ridl.toml)
ridl test --samples 0
error: `--samples` must be at least 1
Note the overloaded meaning: exit 1 here is a test failure (the toolchain
found a problem worth failing the run over), not the breaking verdict
ridl diff uses the same code for, and not the diagnostic error ridl check uses it for. A suspect finding — no sampled input satisfies a
precondition — is reported in the same output but does not fail the run;
only a self-corpus failure or an evaluation error does.
ridl fmt
ridl fmt --help
Reformat `.typl`, `.ridl` and `.rsdl` files in place (defaults to the current directory)
Usage: ridl fmt [OPTIONS] [PATH]
Arguments:
[PATH] [default: .]
Options:
--check Do not write; exit 1 if any file would change
-h, --help Print help
It writes every .typl/.ridl/.rsdl file under PATH back to itself in
canonical form, unless --check is given or the file fails to parse. The walk
skips vendor/ at every manifest root it meets, including one below a PATH
that has no ridl.toml; ridl vendor fills that directory with
copies of remote units that belong to their own owners. A PATH that is such a
directory or lies under it is refused with exit 2. An .rsdl
file takes the file layout (the header, one blank line between declarations).
Systems, components, distributions, deployments and machines use canonical
brace bodies, references and attributes. Deployments keep for before header
attributes and nest machine blocks; source blank lines between machines remain.
Interfaces and inline services use the canonical brace layout
and member spacing. Named services keep commas between shapes, remove the
optional trailing comma, and break overlong shape lists after the colon. Interaction timing precedes attribute blocks. Predicate attributes use block
form; flag and assignment attributes stay inline when their code line fits.
Layout and line width. Formatted blocks use two spaces per indentation level. Parameter lists, tuple types and attribute value lists break into one item per line with commas between items when their code line exceeds the configured width. Overlong attribute blocks use one attribute per line; named service shape lists break after the colon. The default width is 100 characters. Width counts Unicode scalar values, including indentation. Trailing comments do not count towards the limit, and unbreakable code can exceed it. A comment embedded inside a single-line construct keeps that construct's source layout. Comments between interaction annotations stay with the preceding source annotation when timing moves first. If a moved annotation line comment would consume another trailing member comment, the whole member keeps its source layout.
A leading comment block is kept. A block of line comments before the
package line, such as a copyright and licence notice, stays where it is, with
or without a blank line between it and package. The formatter does not insert
or remove that blank line.
EditorConfig example. Editors that support these properties can use this
section in .editorconfig for the family's source files:
[*.{typl,ridl,rmdl,rsdl,rxdl}]
indent_style = space
indent_size = 2
max_line_length = 100
The CLI resolves max_line_length from .editorconfig for each file. The
language server resolves it from the document path and formats the current
buffer. An integer sets the width, off disables breaking, and absent, unset
or invalid effective values use 100. Indentation remains two spaces; the
formatter ignores indent_size, indent_style and client formatting options.
Compiler profiles for .rmdl and .rxdl are still pending; the glob includes
them for editor settings.
Exit codes. 0 when nothing needed rewriting, or the rewrite (without
--check) succeeded. 1 under --check when a file would change, without
writing it. Starting from speed.typl holding
type Speed : km/h [0.0..250.0 step 0.5] on one line, with no blank line
after the package declaration:
ridl fmt --check speed.typl; echo "exit: $?"
exit: 1
Reformatting it for real, then checking again:
ridl fmt speed.typl && cat speed.typl
package veh.common
type Speed: km/h [0.0..250.0 step 0.5]
ridl fmt inserted the blank line and tightened the colon; the shown text is
cat's, since ridl fmt itself printed nothing. A second --check pass is
now a fixed point:
ridl fmt --check speed.typl; echo "exit: $?"
exit: 0
and 1 — with or without --check — when a file has a parse error; the file
is never rewritten, and the parse diagnostics render to stderr:
ridl fmt broken.typl
error[FORM-101]: expected `]`
┌─ broken.typl:2:22
│
2 │ type X : integer [0..10ms]
│ ^^^^
error[TYPL-302]: duration literal in typl context
┌─ broken.typl:2:22
│
2 │ type X : integer [0..10ms]
│ ^^^^
2 when an existing file cannot be read or written — a permissions error, for example:
ridl fmt unreadable.typl
error: cannot read unreadable.typl: Permission denied (os error 13)
A missing PATH, or a directory the walk cannot read, is the same exit 2 —
naming the cause. This is a fix, recorded as ADR-0010 decision 6
and decision 1: before it, ridl fmt /nonexistent walked the missing path,
found nothing, and reported success, and an unreadable directory reached
partway through a larger walk was silently treated the same way — the one
subcommand on this page that did not fail closed on a bad path. Both now
report the directory read_dir failed on and the OS error:
ridl fmt /nonexistent/ridl/workspace
error: cannot read /nonexistent/ridl/workspace: No such file or directory (os error 2)
and, for a tree holding one ordinary file next to a subdirectory the walk cannot read into:
ridl fmt --check .
error: cannot read ./sub: Permission denied (os error 13)
Of the eight subcommands ADR-0010 decision 6 examined on 2026-07-27,
it found ridl fmt is the only one that reliably names the actual unreadable
path this way in every case it was tested against. ridl describe, added later,
names the path in every exit-2 message about a path it was given. ridl check,
ridl build, ridl baseline, ridlc check, and ridlc build still exit 2 on
the same inputs, but with the wrong cause or none: an unreadable workspace
root reports error: no `ridl.toml` found at or above `<path>` — confirmed
directly against this build — and an unreadable subdirectory nested inside an
otherwise-readable workspace reports a bare error: Permission denied (os error 13), naming no path at all — also confirmed directly. The other two
subcommands decision 6 examined name a path only in part: ridl diff names the
unreadable directory only when it is the argument given, and ridl test names
the workspace root, not the subdirectory that failed. Tracked as
issue driftsys/ridl#196, not fixed as of this page.
ridl diff
ridl diff --help
Compare two IR snapshots or source trees and classify the change: exit 0 compatible or identical, 1 breaking, 2 error
Usage: ridl diff [OPTIONS] [OLD] [NEW]
Arguments:
[OLD] The baseline: an `.ir.json` snapshot, a `.typl`/`.ridl` file, a package directory, or a workspace root
[NEW] The candidate, in the same forms as the baseline
Options:
--format <FORMAT> Output format for the report [default: text] [possible values: text, json]
--explain <CATEGORY> Print the classification rule for one change category and exit, instead of comparing snapshots. Takes a category exactly as the report prints it, e.g. `timing_changed`
--encoding <ENCODING> An encoding the change is judged for, as `[backend.<name>] encodings` names it; repeat the flag for several. A snapshot does not record the encodings a workspace builds. Only `podbuf` has a rule of its own: with it, a change under the heading "breaking under podbuf" is breaking; without it, compatible [possible values: flatbuffers, proto3, podbuf]
-h, --help Print help
OLD and NEW each take one of three forms: an .ir.json snapshot, a
directory of them (the shape .ridl/baseline/ takes), or source — a file, a
package directory, or a workspace root — compiled in process. Only IR
artifacts are recognised by name, and three inputs are refused rather than
compiled (exit 2): an .ir.txtpb or .ir.binpb file, because diffs and
baselines read .ir.json only (ADR-0014 decision 5); a directory
that holds IR artifacts but no .ir.json snapshot and no source — no
ridl.toml and no .typl/.ridl file — which is a snapshot directory in a
refused encoding, not a source tree; and a directory with no source whose
.ir.json snapshots sit one level below it, which is a path aimed one level
too high. A directory is also exit 2, naming the entry, when an entry inside
it named like a snapshot cannot be stat'ed — a symlink to a file that is gone
— because skipping the entry would compare against a set one snapshot short;
this is checked before the directory is read as a source tree, so a source
directory holding such an entry is refused too. Every other input reaches
the source compiler. Omit both and pass --explain <CATEGORY> instead to print that
category's classification rule without comparing anything.
--encoding <ENCODING> names an encoding the change is judged for, with the
names a manifest's [backend.<name>] encodings list takes (flatbuffers,
proto3, podbuf); repeat the flag for several. A snapshot does not record
the encodings a workspace builds, so the command does not read them from a
manifest. Only podbuf has rules of its own, three of them. A struct field
appended to a struct that another struct, a tuple or an exact-length array
holds inline anywhere in the new side — in a struct, in the argument struct a
call with several parameters induces, or in a tuple reply — is
podbuf_inline_append, breaking with --encoding podbuf and compatible
without it, because the inline copy cannot grow without moving the fields
after it. An enum value, an enum-set flag or a union arm appended after which
the podbuf integer of the enum, of the enum set or the tag of the union is a
different integer, by width or by signedness — a value above 255, a flag at
bit 8 or an arm past ordinal 255 widens a u8 to a u16, a value above 65535
or a flag at bit 16 a u16 to a u32, and a value above 4294967295 or a flag
at bit 32 a u32 to a u64; an enum's integer is signed when a value is
negative, and since a negative value appended to an enum that has values is an
insert below its highest value, a breaking decl_added, the integer turns
signed only when an enum with no value gains values — is
podbuf_width_change, reported once per declaration with the old and the new
integer, breaking with --encoding podbuf because every head that holds the enum, the enum set or the union lays out a
different integer; an append that keeps the integer draws no such line. Without
the flag the line is reported compatible, which holds under proto3; an enum-set
flag that widens the integer also widens the enum set's FlatBuffers scalar,
which ridl diff --encoding flatbuffers does not report yet
(driftsys/ridl#1120). An enum value or an enum-set flag appended is
podbuf_strict_enum_append, reported once per declaration with the appended
names, breaking with --encoding podbuf and compatible without it, because
every enum and enum set is strict and the verify of an older reader refuses
a value or a bit its declaration does not name; an enum or an enum set whose
append changes the integer draws only the podbuf_width_change line. The
podbuf_inline_append line names each inline use, a struct field as
<package>/<struct>/<field> and an interaction's request, reply or fixed
payload as <package>/<interface>/<interaction>, and sits under the heading
breaking under podbuf either way, so that a workspace that does not build
podbuf still sees it. The search mirrors the heads the encoder lays out, a
declaration and a named induced tuple: a struct that is a root of its own (a
single named parameter, a named reply, a signal, event or fixed payload), that
is used only behind a descriptor (the element of a bounded array, the key or
value of a map entry, a union arm), or only inside an exact-length array that
is itself a root (a fixed payload [S; 2..2], which has no head) draws no
such line: the root or the descriptor's stride gives the producer's size. A
tuple inside such a root is a head, and a struct inside that tuple is a use.
A name outside the three is a usage error,
exit 2. With Reading held
inline by Bounds and a field appended to it:
ridl diff --encoding podbuf v1.typl v2.typl
breaking
[compatible] decl_added veh.cluster/Reading/quality: (absent) -> quality
breaking under podbuf:
[breaking] podbuf_inline_append veh.cluster/Reading/quality: (absent) -> nested inline in veh.cluster/Bounds/range
It writes nothing. The report goes to stdout; a compile error's diagnostics go to stderr instead and no report prints.
Two identical sources:
ridl diff old.ridl old.ridl
identical
An appended interaction — compatible:
ridl diff old.ridl compatible.ridl
compatible
[compatible] interaction_appended veh.cluster/VehicleStatus/doorClosed: (absent) -> event doorClosed
A signal's payload type changed — breaking:
ridl diff old.ridl breaking.ridl
breaking
[compatible] decl_added veh.cluster/Speed2: (absent) -> type
[breaking] payload_changed veh.cluster/VehicleStatus/currentSpeed: Speed -> Speed2
A named-form service's list is a set of interfaces: an interface joining it
is service_interface_added, one leaving it service_interface_removed, a
reorder no change, and both are compatible, because an interface's number
comes from its unit's interfaces.lock and the routing key does not
contain the service. A removal is still visible in source — the
service.member addresses of that interface stop resolving under the service
— so the text report lists it under a heading, shared with interface_renamed, printed once as a
line ending in a colon, after every change that has no heading. The JSON
report carries the category word and no heading field. With interface K
added and J dropped from service veh.cluster.dash : I, J:
ridl diff dash-old.ridl dash-new.ridl
compatible
[compatible] decl_added veh.cluster/K: (absent) -> interface
compatible on the wire, visible in source:
[compatible] service_interface_removed veh.cluster/veh.cluster.dash/J: J -> (removed)
An interface is matched by its number from the unit's interfaces.lock,
within the unit, not by its name — a declared interface and a service's
inline shape alike. A
rename that keeps its number, recorded with ridl lock <unit dir> --rename Old=New,
is interface_renamed: compatible on the wire, because the number is the
routing identity, and visible in source, because the generated identity-table
names change, so it shares the heading above; the path carries the new name,
and a change inside the renamed interface is reported under the new name too.
A number gone from the new side is interface_retired when that side's lock
retires it, and decl_removed, breaking, otherwise. Against an old side whose
numbers are frozen, a declaration with no lock entry carries a provisional
number, which is no identity: it is decl_added, and it is never matched to an
old interface, so a rename the lock does not record is decl_removed plus
decl_added. Against a provisional old side, interfaces are matched by name,
as described below. Two sides with no lock
file — two bare source trees, or a snapshot published before the lock existed —
are matched by name. Interfaces are matched within the unit, and the names on
both sides of a change are catalog names, so an interface moved between two
sibling packages of one unit keeps its number and is reported as
interface_renamed, from the old catalog name to the new one. With J renamed to Jay on its number, the lock beside
each file recording it, and Jay no longer listed in
service veh.cluster.dash : I, J:
ridl diff old/dash.ridl new/dash.ridl
compatible
compatible on the wire, visible in source:
[compatible] interface_renamed veh.cluster/Jay: J -> Jay
[compatible] service_interface_removed veh.cluster/veh.cluster.dash/J: J -> (removed)
An interface matched by name across a provisional old side has its number and
its provisional flag compared, because the catalog hash covers both. When the
number is the same on both sides and only the new side is frozen — the number
ridl lock froze in place — the change is interface_frozen: compatible,
because the routing key did not move, and listed under the heading
"compatible on the wire, may change the catalog hash", which it shares with
enum_reordered, because the catalog hash changes and a generated face refuses
a port whose catalog hash is another one. With old a unit before ridl lock
and new the same unit after it:
ridl diff old new
compatible
compatible on the wire, may change the catalog hash:
[compatible] interface_frozen veh.cluster/Doors: 1 (provisional) -> 1
A number that differs between the two sides is interface_number_changed,
breaking, because the number is the routing key. Between two builds with no
lock, an interface added before Doors in byte order of the name moves its
provisional number:
ridl diff old new
breaking
[breaking] interface_number_changed veh.cluster/Doors: 1 (provisional) -> 2 (provisional)
[compatible] decl_added veh.cluster/Aux: (absent) -> interface
A number 0 on either side, from a snapshot written before the lock existed,
is not compared. A published baseline never holds a provisional number,
because ridl baseline refuses one (RIDL-411), so both categories are seen
between two builds, or in a reversed comparison whose old side is a build.
An enum value or an enum-set bit takes its identity from the explicit number
it declares, so a body whose difference is only order — its values or bits
reordered with no number changed, an enum's reserved list reordered with no
entry changed, or both — moves nothing on the wire. The change is
enum_reordered, compatible, on the container's path and under the same
heading. The catalog hash covers only the declarations an interface reaches:
when an interface reaches the enum or enum set, the order of its values or
bits, and of an enum's reserved list, is part of the hash, so the hash
changes then. With the values of enum GearPosition reordered:
ridl diff old.ridl new.ridl
compatible
compatible on the wire, may change the catalog hash:
[compatible] enum_reordered veh.cluster/GearPosition
A value or bit whose number changed is member_reordered, breaking, with the
old and the new number in the detail.
The breaking comparison above with --format json:
ridl diff old.ridl breaking.ridl --format json
{
"verdict": "breaking",
"changes": [
{
"path": "veh.cluster/Speed2",
"category": "decl_added",
"verdict": "compatible",
"before": null,
"after": "type"
},
{
"path": "veh.cluster/VehicleStatus/currentSpeed",
"category": "payload_changed",
"verdict": "breaking",
"before": "Speed",
"after": "Speed2"
}
]
}
At the system. When both sides are source whose workspace declares a
system, ridl diff also lists the changes to the lowered system that are not
contract changes, under two headings and with no verdict (rsdl reference §14):
placement changed for a deployment or a machine added or removed, a machine
made external, or an instance moved to another machine; composition
changed for a component added to or removed from the system, an offers or
requires line added or removed, instances changed, a component made
external, a distribution added or removed, or a member line added to or
removed from a distribution. The verdict and the exit code are the contracts'
alone.
Moving Panel from machine Front to machine Rear in deployment Desk:
ridl diff old new
identical
placement changed
Desk/Rear: (absent) -> machine
Desk/Front: machine -> (removed)
Desk/veh.demo.Panel.Unit: Front -> Rear
With --format json the headings are the keys placement_changed and
composition_changed, each a list of {"path", "before", "after"}, present
only when they hold a change. An .ir.json snapshot, or a directory of them,
carries no system — ridl baseline publishes package snapshots only — so a
diff against .ridl/baseline/ lists no system change.
--explain for one category:
ridl diff --explain payload_changed
payload_changed
A signal, event, or fixed payload type changed.
breaking any direction, a stream added or removed included
Exit codes. 0 for identical and compatible above. 1 for breaking
above — the verdict, not a tool failure. 2 when a side fails to compile:
ridl diff old.ridl broken.ridl
error[TYPL-011]: unknown type name `NoSuchType`
┌─ broken.ridl:4:25
│
4 │ signal currentSpeed : NoSuchType @10ms
│ ^^^^^^^^^^
2 when an input path does not exist:
ridl diff old.ridl does-not-exist.ridl
error: does-not-exist.ridl: `does-not-exist.ridl` does not exist
2 when neither both inputs nor --explain are given:
ridl diff old.ridl
error: `ridl diff` needs both an old and a new input, or `--explain <CATEGORY>`
and 2 for an --explain category that is not one of the categories the tool
knows, which the error lists in full:
ridl diff --explain not_a_real_category
error: unknown change category `not_a_real_category`
the categories `ridl diff` reports are:
decl_added
decl_removed
interface_renamed
interface_retired
interface_frozen
interface_number_changed
member_reordered
enum_reordered
interaction_appended
interaction_inserted
interaction_reordered
interaction_removed
interaction_retired
kind_changed
payload_changed
return_changed
params_changed
timing_changed
rpc_bound_changed
limit_changed
contract_changed
interaction_class_changed
width_changed
constraint_changed
init_changed
reserved_name_redeclared
service_changed
service_interface_added
service_interface_removed
doc_only
visibility_changed
podbuf_inline_append
podbuf_width_change
podbuf_strict_enum_append
ridl lock
ridl lock --help
Allocate a number to every interface that has none and write each unit's `interfaces.lock`; with `--rename` or `--retire`, rewrite one unit's entries in place instead. Exit 0 when the file is written or nothing changes, 1 on a diagnostic error, 2 on a bad flag or a path or I/O failure. `ridl lock merge` is the git merge driver for the file
Usage: ridl lock [OPTIONS] [PATH]
ridl lock <COMMAND>
Commands:
merge The git merge driver for `interfaces.lock`: a three-way merge over entries matched by number, written to OURS. Exit 0 when the merge is clean, 1 when entries disagree (they are left between conflict markers of MARKER_SIZE, and the file is RIDL-410 until resolved), 2 when an input cannot be read or does not parse (OURS is left as it was). Register it with `.gitattributes` and `git config` as the CLI reference documents
help Print this message or the help of the given subcommand(s)
Arguments:
[PATH] A unit directory, a workspace root, or a file. A directory named `merge` is spelled `./merge`, since the bare word is the subcommand [default: .]
Options:
--rename <OLD=NEW> Rewrite the live entry OLD to hold the key NEW, keeping its number (repeatable). NEW must be a declaration without an entry
--retire <NAME> Mark the live entry NAME retired, keeping its line and its number (repeatable). NAME must no longer be declared
-h, --help Print help
It writes one interfaces.lock per unit, in the directory of the unit's
ridl.toml — the line table that gives every interface of the unit its number
(ridl §11): a # header, next N, then one entry per interface, Name N,
with the word retired after the number when the interface is gone. An
interface of a subpackage is keyed by its name relative to the unit, such as
cluster.Speed. A service's inline shape is an interface too and is keyed
service: followed by the service's full dotted name. Only ridl lock writes the file: the
compiler reads it beside the sources, and ridl fmt never touches it.
Plain ridl lock is the only form that allocates. Every declared interface
whose name has no live entry gets the next free number, in byte order of the
name, and the file is written; a declaration that already has its entry is
left as it is. Over a unit holding interface Zone and interface Cabin
and no lock file yet:
ridl lock . ; echo "exit: $?"
allocated Cabin 1
allocated Zone 2
exit: 0
# interfaces.lock — written by ridl lock; do not edit by hand.
next 3
Cabin 1
Zone 2
Run again with nothing to allocate, it prints nothing, writes nothing and
exits 0. Over a workspace it writes each unit's own file, and each output
line is prefixed with the unit directory relative to PATH and a colon:
hvac: allocated Cabin 1. A PATH inside a workspace member compiles the
whole workspace and allocates in, edits and reports on that member only.
Until ridl lock has run, a declaration with no entry compiles with a
provisional number, which carries no identity. The catalog hash covers each
number and its provisional flag, so ridl lock changes it: ridl diff
reports a number frozen in place as interface_frozen, compatible, and a
number frozen as another one as interface_number_changed, breaking.
The reverse case — a live entry whose interface is gone from the source — is
RIDL-409 from the compiler, and plain ridl lock refuses to allocate over it:
nothing is written, exit 1. The two flags are that diagnostic's fix, and they
run with RIDL-409 present:
--rename OLD=NEWrewrites the entryOLDto hold the keyNEWin place, keeping its number, whenNEWis a declaration without an entry — the same interface under a new name. Printed asrenamed Old New N.--retire NAMEmarks the entry retired, keeping its line and its number, when nothing declaresNAMEany more. Printed asretired Name N.
Both are repeatable, neither allocates, and PATH must resolve to exactly one
unit. OLD, NEW and NAME are dotted names relative to the unit, such as
cluster.Old, so a rename across sibling packages is
ridl lock <unit dir> --rename cluster.Old=cluster.New. An interfaces.lock in
a subdirectory of a unit is not read and draws the warning RIDL-416
(lock-in-subdirectory). A rename keeps the number because the number, not the name, is the
interface's wire identity; the old name is then free for a later, unrelated
interface. Starting from the file above with interface Zone renamed to
interface Lane in the source:
ridl check . ; echo "exit: $?"
error[RIDL-409]: `Zone` is a live entry of `interfaces.lock` with no declaration in the unit: run `ridl lock . --rename Zone=New` when a declaration without an entry, `New`, is this interface under a new name, or `ridl lock . --retire Zone` when the interface is gone
┌─ ./interfaces.lock:4:1
│
4 │ Zone 2
│ ^^^^^^
exit: 1
ridl lock . --rename Zone=Lane ; echo "exit: $?"
renamed Zone Lane 2
exit: 0
The merge driver. Two branches that each edited interfaces.lock merge
through ridl lock merge, a three-way merge over entries matched by number
rather than over lines. git runs it when the repository registers it: one
versioned line in .gitattributes, and one git config line per clone,
which git does not version.
interfaces.lock merge=ridl-lock
git config merge.ridl-lock.driver "ridl lock merge %O %A %B %L"
ridl lock merge --help
The git merge driver for `interfaces.lock`: a three-way merge over entries matched by number, written to OURS. Exit 0 when the merge is clean, 1 when entries disagree (they are left between conflict markers of MARKER_SIZE, and the file is RIDL-410 until resolved), 2 when an input cannot be read or does not parse (OURS is left as it was). Register it with `.gitattributes` and `git config` as the CLI reference documents
Usage: ridl lock merge <BASE> <OURS> <THEIRS> <MARKER_SIZE>
Arguments:
<BASE> The common ancestor's file (`%O`); an empty file reads as `next 1`
<OURS> The current branch's file (`%A`); the result is written here
<THEIRS> The other branch's file (`%B`)
<MARKER_SIZE> The length of a conflict marker line (`%L`, 7 by default)
Options:
-h, --help Print help
At each number the driver applies git's own three-way rule: a change on one
side is taken, the same change on both sides is kept once, and two different
changes to one entry — a retire against a rename, or two renames — are left
between conflict markers, with every other entry written plain. One rule is
the driver's own: when both sides allocated one number to two different
interfaces, ours keeps the number and theirs is renumbered to the next free
one, which is safe because a branch never allocates. A live name on two
numbers — each side allocated the same interface on its own number — is a
conflict too. next is the maximum of the three sides plus one per
renumbered entry, so it is never lowered. With A 1, B 2 as the base, ours
retiring B and theirs renaming it to Bee:
# interfaces.lock — written by ridl lock; do not edit by hand.
next 3
A 1
<<<<<<< ours
B 2 retired
=======
Bee 2
>>>>>>> theirs
The file is malformed (RIDL-410) until an author keeps one side and deletes
the markers, and ridl check refuses it until then. git's union driver is
not a substitute: it discards the base, and resurrects a renamed or retired
entry silently. An empty BASE — what git passes when both branches created
the file — reads as next 1 with no entries. A package directory named
merge is spelled ./merge: the bare word is the subcommand.
Exit codes. 0 when the file is written or there is nothing to change. 1
on a diagnostic error over the source, with nothing written: a live entry with
no declaration when plain ridl lock is asked to allocate (RIDL-409), a
malformed lock file — a git conflict left in it included — (RIDL-410), or any
other compile error, in any package of the workspace. 2 when the path is
missing or unreadable, on a bad flag — --rename naming no live entry or a
NEW that is not a declaration without an entry, --retire naming an
interface that is still declared, either flag over more than one package — or
on an I/O failure writing the file. Every cell is confirmed against the built
binary by crates/ridl/tests/lock_cli.rs. ridl lock merge exits 0 when the
three sides merge clean and OURS is written; 1 when entries disagree — OURS is
written with the conflict markers and is malformed until resolved; 2 when an
input cannot be read or does not parse (OURS is left as it was), when
MARKER_SIZE is not a number from 1 up, or on an I/O failure writing OURS.
Every cell is confirmed by crates/ridl/tests/lock_merge.rs, which also
registers the driver in a temporary repository and merges two branches
through git.
ridl lsp
ridl lsp --help
Run the language server over stdio: exit 0 after `shutdown` then `exit`, 1 when the client ends the session without `shutdown`, 2 on a transport error. Editors spawn this. Stdio is the only transport
Usage: ridl lsp [OPTIONS]
Options:
--stdio Select the stdio transport. Accepted because editor clients pass it, as the LSP specification recommends; omitting it changes nothing
--clientProcessId <PID> The editor's process id, which the LSP specification recommends a server accept. Ignored: the `initialize` request carries it too
-h, --help Print help
ridl lsp hosts the language server: behavior lives in crates/ridl-lsp, and
this subcommand only wires the stdio transport. An editor spawns it and speaks
the Language Server Protocol over its stdin and stdout.
The server never fetches. It reads the remote units of [imports] from
vendor/ at the manifest root and then from the cache, as ridl check --frozen
does, so run ridl check or ridl vendor once in a fresh clone.
It loads the remote units once, when it loads the workspace, and does not
reload them when ridl.lock changes: after a ridl check that fetches a unit
or rewrites ridl.lock, restart the server. It publishes no diagnostics for a
file of a remote unit; it shows the errors of a remote unit once per load, in
one window message.
Flags. --stdio and --clientProcessId=<PID> are accepted and change
nothing. The LSP specification (3.17, "Implementation Considerations")
recommends that a server accept both, and editor clients pass them: the VS
Code client runs the server as ridl lsp --stdio. Stdio is the only
transport, so --pipe and --socket are refused with exit 2. The client's
process id also arrives in the initialize request.
Exit codes. They follow the LSP specification (3.17, "Exit
Notification"). 0 on a clean shutdown — the client sends shutdown then
exit. 1 when the client sends exit with no shutdown before it, or closes
stdin after the handshake with no shutdown. 2 when the transport ends before
the initialize handshake, or fails for any other reason. All four outcomes are
confirmed directly against the built binary by crates/ridl/tests/servers.rs.
ridl mcp
ridl mcp --help
Run the MCP server over stdio for an agent host: exit 0 on a clean shutdown, 2 on a transport error. It takes no flag of its own
Usage: ridl mcp
Options:
-h, --help Print help
ridl mcp serves the Model Context Protocol over stdio with nine tools:
ridl_checkchecks workspace files or a source text.ridl_explainexplains a diagnostic code or diff category.ridl_resolveresolves a declaration and its location.ridl_describe_typedescribes a non-interface declaration.ridl_list_interactionslists an interface's interactions.ridl_referenceslists declarations and interactions using a declaration.ridl_dependencieslists package dependencies and dependents.ridl_metricsreports package coupling and interface cohesion groups.ridl_diffcompares source workspaces or IR snapshots.
Behavior lives in crates/ridl-mcp; this subcommand builds the Tokio runtime
and wires the stdio transport. An agent host spawns it and speaks MCP over stdin
and stdout. The crate README documents input
and output schemas, tool errors and the read-only workspace contract. The command
takes no flag of its own.
Exit codes. 0 on a clean shutdown: the host closes the server's stdin,
which is how a stdio host ends an MCP session. 2 when the Tokio runtime fails
to build, when the transport ends before the initialize handshake, or when a
task the SDK runs for the session fails after it. A read failure on stdin
after the handshake is not distinguishable from the host closing stdin — the
SDK logs it and ends the session the same way — so it exits 0. There is no
exit 1: ridl mcp answers no question that can come back negative. The clean
shutdown and the transport ending before the handshake are confirmed directly
against the built binary by crates/ridl/tests/servers.rs; a session-task
failure cannot be provoked through the server, so its mapping is tested on its
own in crates/ridl-mcp.
ridl init
ridl init --help
Scaffold a new unit in a directory that exists (defaults to the current directory): a package, or with `--workspace` a workspace and one member. Writes `ridl.toml` and one source file, and exits 2 without writing anything when a target file exists, the name is not a legal package name or holds a reserved word, or a `ridl.toml` above or below the directory would make a manifest inside a unit (MANI-013)
Usage: ridl init [OPTIONS] [PATH]
Arguments:
[PATH] The existing directory to scaffold into [default: .]
Options:
--workspace Scaffold a workspace root and one member instead of a package
--name <NAME> The package name (the member name with `--workspace`); the directory name by default
-h, --help Print help
Writes the files of a new unit into a directory that already exists, and
nothing else. The default is one standalone package: a ridl.toml that holds a
[package] table with the version 0.1.0, and <name>.ridl in the same
directory, which declares package <name>, one unit-carrying type and one
interface with one signal. ridl check and ridl fmt --check draw nothing on
it. On success the command prints Created package <name> to stdout.
--workspace writes a root ridl.toml with [workspace] members = ["<name>"],
and the package above in the directory <name> under it. The command then
prints Created workspace <name>, and ridl check passes from the root and
from inside the member.
The name is the directory's basename when that is already a legal package name
(lowercase dot-separated segments, each a letter followed by letters or
digits). The basename is taken from the path as written, so a symbolic link is
not followed to find it. The command never rewrites a name: a basename that is
not legal exits 2 with an error that asks for --name, and so does a --name
value that is not legal. A name with a reserved word as a segment, such as
type or veh.signal, is also refused, because a source file that declares it
does not parse. With --workspace, --name names the member and the root
directory keeps its own name.
Exit codes. 0 when the files are written. 2, with an error: line on
stderr and nothing written, when a target file already exists, the path is not a
directory, the name is not legal or holds a reserved word, or a ridl.toml
above or below the package directory would make a manifest inside a unit
(MANI-013): the directory sits inside another unit or inside a workspace, or it
already holds a ridl.toml in a subdirectory that ridl check would also
reject. The search below is the loader's own: it skips directories whose name
starts with a dot and does not follow symbolic links. This command does not add a member
to an existing workspace. The search upward does not stop at a .git directory,
because MANI-013 does not, and it follows symbolic links. Every target is
checked before any file is written, the member files are written before the
workspace root manifest, and a write that fails removes the paths this command
created. There is no exit 1. The cases are confirmed against the built binary by
crates/ridl/tests/scaffold_cli.rs.
ridl new
ridl new --help
Create a directory, with its parents, and scaffold a new unit in it, as `ridl init` does. Exits 2 without writing anything when the path exists
Usage: ridl new [OPTIONS] <PATH>
Arguments:
<PATH> The directory to create; it must not exist
Options:
--workspace Scaffold a workspace root and one member instead of a package
--name <NAME> The package name (the member name with `--workspace`); the directory name by default
-h, --help Print help
Creates the directory at PATH, with its parents, and scaffolds into it exactly
as ridl init does, with the same flags, the same output and the
same refusals. A PATH that already exists exits 2 and leaves the file system
as it was. The name defaults to the last component of PATH.
ridl new vehicle-demo --name demo
ridl describe
ridl describe --help
Print a catalog descriptor as strict JSON, after verifying it
Usage: ridl describe <PATH>
Arguments:
<PATH> The `<unit>.catalog.binfb` file `ridl build --emit catalog` wrote
Options:
-h, --help Print help
Prints a catalog descriptor written by ridl build --emit catalog as JSON after
verifying it; a file that is not a descriptor, or is malformed, is rejected as a
whole with exit code 2.
The transcript below is abridged: it is the output for the test corpus in
crates/ridl/tests/baseline-corpus, with each ... line standing for lines
that were removed. The keys print in alphabetical order. First comes
compatible, the earlier catalog hashes the descriptor lists as compatible with
this one, each as its 32 bytes; it is empty here, because the corpus's
published baseline holds no corpus.baseline.catalogs file. Then come the 32
bytes of the catalog hash, one entry per interface, the unit name, the retired
numbers, the toolchain version that wrote the file, and the descriptor's schema
version.
ridl build crates/ridl/tests/baseline-corpus --emit catalog
ridl describe out/corpus.baseline.catalog.binfb
{
"compatible": [],
"hash": [
105,
123,
139,
...
],
"interfaces": [
{
"members": [
{
"kind": "Signal",
"limit": null,
"name": "currentSpeed",
"ordinal": 1,
"payloads": [
{
"max_sizes": [
{
"bytes": 46,
"cause": "Unspecified",
"encoding": "FlatBuffers",
"state": "Bounded"
}
],
"role": "value",
"type_name": "Speed"
}
],
"timing": {
"max_us": "10000",
"min_us": "10000",
"mode": "StrictPeriodic"
}
},
...
],
"name": "VehicleStatus",
"number": 1,
"provisional": true,
"reserved_ordinals": []
},
...
],
"name": "corpus.baseline",
"retired": [],
...
"version": 1
}
Exit codes. 0 when the descriptor was printed. 2 for a missing or
unreadable path, a file that is not a catalog descriptor, a version this
toolchain does not read, a malformed buffer, or an I/O failure writing to
stdout (a pipe whose reader has gone); the message names the path and the
cause. There is no exit 1: ridl describe answers no question that can come
back negative. The exit-0 outcome, the missing path, the foreign file, a
malformed buffer (three fixtures), a version the toolchain does not read, and the write
failure on stdout are confirmed against the built binary by
crates/ridl/tests/describe_cli.rs.
ridl vendor
ridl vendor --help
Copy every remote unit of the workspace's `[imports]` into `vendor/` at the manifest root, so a `--frozen` build needs no network and no cache. Every directory in `vendor/` that no `[imports]` key names is removed; files and hidden entries are left. Exit 0 when `vendor/` was written or there was nothing to change, 1 on a diagnostic error (`vendor/` is left untouched; `ridl.lock` may be updated), 2 on a missing or unreadable path, a fetch failure, or an I/O failure. With `--check`, compare `vendor/` with the imports and `ridl.lock` instead, writing nothing
Usage: ridl vendor [OPTIONS] [PATH]
Arguments:
[PATH] A unit directory, a workspace root, or a file; the manifest root above it is the one vendored [default: .]
Options:
--check Compare `vendor/` with `[imports]` and `ridl.lock` instead of writing: no network, no write. Exit 0 when `vendor/` matches, 1 on drift or another diagnostic error, 2 on a missing or unreadable path
-h, --help Print help
ridl vendor makes a project build with no network and no cache; the
Remote units and ridl vendor chapter is the guide. PATH is a
unit directory, a workspace root or a file, and defaults to the current
directory; the command acts on the manifest root above it, found as ridl check
finds it, so vendor/ is at the workspace root whichever member the command
runs from. It then does three things:
- It takes the union of the
[imports]of the root manifest and of every member. - It materializes each unit as
ridl checkdoes, from the cache or by a fetch, and rewritesridl.lock. It reads the cache and nevervendor/, so a URL that changed is fetched and its directory replaced. - It brings
vendor/to exactly that set. Each unit is written whole into a staging directory and renamed into place under its[imports]key, with the bytes of its files unchanged. Every directory directly undervendor/whose name is no[imports]key is removed, whether or notridl vendorwrote it, so that, after a run, no directory other than a remote unit's remains among the non-hidden entries ofvendor/; a file, a symbolic link and a hidden entry (a name that starts with.) are left alone. Nothing invendor/changes when the run has an error.
Each changed unit prints one line, vendored <key> or removed <key>; a run
that changes nothing prints vendor/ is up to date; nothing to change. A unit's
own vendor/ directory is copied with the unit, because it is part of the tree
hash, and is skipped when the unit is read.
The tree hash is computed over the bytes of each file, so a checkout that
converts line endings changes the bytes of a vendored file and makes its unit
MANI-107. Mark the directory -text in .gitattributes with the line
vendor/** -text, which stops line-ending conversion.
ridl vendor
After that, ridl check --frozen reads vendor/<key>/ first, only when
ridl.lock pins a tree for the unit. For any other unit it reads the cache
first, which gives the pin, and a vendored directory, if present, is then
verified against that pin and read in place of the cache entry. A vendored tree
that no longer matches its tree hash is MANI-107 and names ridl vendor.
ridl vendor exit codes.
| Exit | Meaning |
|---|---|
| 0 | vendor/ was written, or there was nothing to change |
| 1 | a diagnostic error, including MANI-102, MANI-105, MANI-106, MANI-107 and a source error in a remote unit; vendor/ is left untouched, while ridl.lock may be updated, as it is by every command that reads remote units without --frozen: it is rewritten when every remote unit was fetched and verified, even if a source error follows |
| 2 | a missing or unreadable path, no ridl.toml at or above it, a bad flag, an I/O failure, or a fetch failure (MANI-101) |
ridl vendor applies no [lints] levels, and a lint that is allow by default
is left out of its output, as for ridl lock.
ridl vendor --check compares vendor/ with the imports and ridl.lock.
It fetches nothing, reads no cache and writes nothing, so it is the check to run
in CI. It reports one diagnostic per unit:
MANI-109, an imported unit with no directory invendor/;MANI-110, a directory invendor/that no[imports]key names;MANI-107, a vendored tree that does not match itstreehash;MANI-103orMANI-108, a unit thatridl.lockdoes not pin or pins with notree, because its directory cannot be verified.
A manifest that does not parse is reported too, so a broken manifest is never a
clean vendor/.
ridl vendor --check exit codes.
| Exit | Meaning |
|---|---|
| 0 | vendor/ matches |
| 1 | drift (MANI-107, MANI-109 or MANI-110), a unit that ridl.lock does not verify (MANI-103 or MANI-108), or another diagnostic error |
| 2 | a missing or unreadable path, no ridl.toml at or above it, or a bad flag |
ridlc
ridlc --help
The RIDL family compiler (plumbing)
Usage: ridlc <COMMAND>
Commands:
check Type-check a file, package directory, or workspace
build Compile to the selected artifacts
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
ridlc has no humane defaults: PATH is required on both subcommands, and
build's --out-dir is required rather than defaulted to out. Both are
usage errors — exit 2 — courtesy of clap, not application code:
ridlc check
error: the following required arguments were not provided:
<PATH>
Usage: ridlc check <PATH>
For more information, try '--help'.
ridlc check
ridlc check --help
Type-check a file, package directory, or workspace
Usage: ridlc check [OPTIONS] <PATH>
Arguments:
<PATH> The `.typl` file, package directory, or workspace root
Options:
--frozen Verify remote imports against `ridl.lock` without fetching or regenerating it (CI mode, ADR-0002 §7)
-h, --help Print help
It writes ridl.lock under the same condition ridl check does — the
manifest declares [imports], the run is non-frozen, and materialization
found no error — because ridl check calls this very function
(ridlc::run_check) directly, forwarding its own PATH and --frozen.
Otherwise it reads the workspace, renders diagnostics to stderr, and touches
no file.
Exit codes. 0 clean, 1 a diagnostic is an error, 2 an input/output or
usage error — identical in shape to ridl check, and, on the same input,
identical in rendered text:
ridlc check .
error[TYPL-104]: range minimum 250 is greater than maximum 0
┌─ ./demo.ridl:3:19
│
3 │ type Speed : km/h [250.0..0.0 step 0.5]
│ ^^^^^^^^^^^^^^^^^^^^^
run over the same directory as the type Speed : km/h [250.0..0.0 step 0.5]
example under ridl check above — byte for byte the same
diagnostic, confirmed by diffing the two runs' stderr. A workspace ridlc
cannot find is the same exit 2 as ridl check:
ridlc check /nonexistent/ridl/workspace
error: `/nonexistent/ridl/workspace` does not exist
ridlc build
ridlc build --help
Compile to the selected artifacts
Usage: ridlc build [OPTIONS] --out-dir <OUT_DIR> <PATH>
Arguments:
<PATH>
The `.typl` file, package directory, or workspace root
Options:
--out-dir <OUT_DIR>
The directory to write generated artifacts into
--emit <EMIT>
The artifacts to emit: `rust` (default), `ir-json`, `ir-text`, `ir-binary`, `typescript`, `proto`, `flatbuffers`, `podbuf`, `codegen-model`, `codegen-request`, `catalog`
Possible values:
- rust: Idiomatic Rust source, written to `<base>.rs`
- ir-json: The lowered IR v2 as exact-decimal JSON, written to `<base>.ir.json`, and the lowered system to `<pkg.Name>.system.json`
- ir-text: The lowered IR v2 as prototext, written to `<base>.ir.txtpb`, and the lowered system to `<pkg.Name>.system.txtpb`
- ir-binary: The lowered IR v2 as protobuf binary, written to `<base>.ir.binpb`, and the lowered system to `<pkg.Name>.system.binpb`
- typescript: Idiomatic TypeScript source, written to `<base>.ts`, and the codec module `<base>.codec.ts` with `[backend.typescript] codec = true`
- proto: The proto3 schema, written to `<base>.proto`
- flatbuffers: The FlatBuffers schema, written to `<base>.fbs`
- podbuf: The podbuf C header, written to `<base>.h`
- codegen-model: The lowered codegen model (`ridl.codegen.v1`) as canonical protobuf JSON, written to `<base>.codegen.json`
- codegen-request: The whole codegen request a build hands a backend, as canonical protobuf JSON, written to `<base>.codegen-request.json`
- catalog: The catalog descriptor an engine reads, written to `<unit>.catalog.binfb` for every unit that declares an interface or a service with an inline body: a FlatBuffers file of the unit's interfaces, their members and their catalog hash
[default: rust]
--plugin <LANGUAGE[=PATH]>
A codegen plugin to run beside the emits, once per package: `<LANGUAGE>` runs `ridlc-gen-<LANGUAGE>` from PATH, `<LANGUAGE>=<PATH>` runs the executable at PATH. Repeatable
--plugin-timeout <SECONDS>
Seconds a plugin may run per package before it is killed
[default: 60]
--frozen
Verify remote imports against `ridl.lock` without fetching or regenerating it (CI mode, ADR-0002 §7)
--deployment <NAME>
The deployment to carry in each codegen request. With one deployment in the workspace it is selected without this flag; with several, none is carried unless named
-h, --help
Print help (see a summary with '-h')
It writes the same artifacts as ridl build — and ridl.lock under the
same [imports] condition — under the --out-dir you must now name
explicitly. --plugin, --plugin-timeout and --deployment are the same three
flags as on ridl build, spelled and documented identically.
ridlc reads no baseline, so its descriptors and codegen requests carry an
empty compatible list; ridl build is the command that fills it:
ridlc build . --out-dir out && find out -type f | sort
out/Cargo.toml
out/cli.demo.rs
out/lib.rs
Exit codes. 0/1/2 in the same shape as ridl build — clean, a diagnostic
is an error, or an input/output error such as a missing --out-dir (usage,
exit 2) or a workspace that cannot be found (exit 2).
How ridl and ridlc relate
ridlc is the plumbing: two subcommands, check and build, both with a
required PATH and no defaults — stable flags meant for CI and build
scripts. ridl check and ridl build call straight into ridlc's own
library functions and add nothing to the compile itself; what they add is
humane defaults (PATH defaults to ., ridl build's --out-dir defaults
to out) and, on check only, the baseline desk check described
above, which has no ridlc equivalent. On identical input the
two render byte-identical diagnostics, confirmed earlier on this page.
ridl baseline, ridl test, ridl fmt, ridl diff, ridl lock,
ridl describe, and ridl vendor have no ridlc counterpart at all — ridlc's surface is check and build, full stop, as
its own --help shows. Reach for ridl unless you are scripting the
compiler directly and want its stable, default-free flags.
Exit codes across the toolchain
| Command | 0 | 1 | 2 |
|---|---|---|---|
ridl check / ridlc check | clean (warnings included) | a diagnostic is an error | the workspace cannot be found, or — for ridl check only — a --baseline problem: absent, wrongly encoded (not .ir.json), unreadable, a snapshot-named entry inside it that cannot be stat'ed, its snapshots nested one level too deep, empty when named explicitly, or a snapshot that fails to parse |
ridl build / ridlc build | clean, every requested artifact written | a diagnostic is an error, nothing written — except for an RSDL-7xx error, which leaves only its deployment out of the lowered system | the workspace cannot be found, or (for ridlc build) a missing --out-dir |
ridl baseline | clean, snapshot(s) published | a diagnostic is an error, or the publication gate refuses: the replacement under the tombstone rule (RIDL-408), a provisional interface number (RIDL-411), or a published number the fresh snapshot neither carries nor retires (RIDL-412); the existing baseline is left untouched | the workspace cannot be found, the output directory cannot be read or written, a published .ir.json snapshot fails to parse, two published snapshots declare one package, or a snapshot-named entry in the output directory cannot be stat'ed |
ridl test | every range self-corpus and sampled require passed | a self-corpus failure, or a clause raised an evaluation error | the workspace fails to compile, cannot be found, or --samples 0 |
ridl fmt | nothing under --check would change, or the rewrite succeeded | a file under --check would change, or has a parse error | the path does not exist, is vendor/ at the manifest root or lies under it, or a directory the walk reaches is unreadable — named in the message, unlike ridl check, ridl build, ridl baseline, ridl lock, ridlc check and ridlc build, which name no path at all |
ridl diff | the change is compatible, or the two sides are identical | the change is breaking | a side fails to compile, an input is missing, or neither --explain nor both inputs were given |
ridl lock | the file is written, or there is nothing to change | a diagnostic error over the source, nothing written: a live entry with no declaration under plain ridl lock (RIDL-409), a malformed lock file (RIDL-410), or any other compile error | the path is missing or unreadable; a bad flag — --rename naming no live entry or a NEW that is not a declaration without an entry, --retire naming a still-declared interface, either flag over more than one package; an I/O failure writing |
ridl lock merge | the three sides merge clean, and the result is written to OURS | entries disagree: OURS is written with conflict markers around only the disagreeing entries, and is malformed (RIDL-410) until resolved | an input cannot be read or does not parse (OURS is left as it was), MARKER_SIZE is not a number from 1 up, or an I/O failure writing OURS |
ridl init / ridl new | the files are written | a target file exists, new was given an existing path, the path is not a directory, the name is not a legal package name or holds a reserved word, or a ridl.toml above or below the package directory would make a manifest inside a unit (MANI-013); nothing is written | |
ridl describe | the descriptor was printed | a missing or unreadable path; a file that is not a catalog descriptor; a version this toolchain does not read; a malformed buffer; an I/O failure writing to stdout | |
ridl vendor | vendor/ was written, or there is nothing to change; with --check, vendor/ matches the imports and ridl.lock | a diagnostic error (including MANI-102, MANI-105, MANI-106, MANI-107 and a source error in a remote unit), with vendor/ left untouched and ridl.lock possibly updated; with --check, drift (MANI-107, MANI-109, MANI-110) or a unit that ridl.lock does not verify (MANI-103, MANI-108) | a missing or unreadable path, no ridl.toml at or above it, a bad flag, an I/O failure writing vendor/, or a fetch failure (MANI-101) |
This table is this repository's own taxonomy, recorded in
ADR-0010: 0 succeeded, or the verdict is affirmative; 1 a
real answer that happens to be negative; 2 the tool could not answer at
all. clig.dev, which the ADR quotes rather than paraphrases, says
nothing about a multi-valued exit code — it treats exit status as a binary
success/failure signal. ridl diff's breaking-change exit 1,
ridl fmt --check's would-reformat exit 1, and ridl test's evaluation-fault
exit 1 rest on the external diff(1)/grep(1) convention of a specific
non-zero code being part of the answer, not on any clig endorsement of it —
clig is silent here, not permissive.
Exit code 1 means something different in almost every row: for ridl diff it
is the breaking verdict, a statement about the change, not a failure of the
tool; for ridl test it is a test failure — a self-corpus problem or an
evaluation error, where a suspect finding is not enough to fail the run; for
ridl check, ridl build, and ridl baseline it is an ordinary diagnostic
error; for ridl fmt it is would change or unparsable. Reading a bare
$? of 1 without knowing which subcommand produced it says nothing on its
own.
Exit code 2 is the one genuinely shared meaning — an input the command could
not use at all, including ridl fmt's missing or unreadable path since
ADR-0010 closed the gap documented under
ridl fmt above. A clap-level usage error — a missing
required argument, an unrecognized flag, or no subcommand at all — is also
exit 2 on both binaries, which happens to agree with the applications' own
convention rather than being one of their exit paths:
ridl check --bogus-flag
error: unexpected argument '--bogus-flag' found
tip: to pass '--bogus-flag' as a value, use '-- --bogus-flag'
Usage: ridl check [OPTIONS] [PATH]
For more information, try '--help'.
--help itself, on any subcommand of either binary, always exits 0 — and so
does --version/-V, covered above.
Seven of the subcommands that take a path also share a lesser-known gap:
issue driftsys/ridl#196 records that when the workspace root
itself is unreadable, ridl check, ridl build, ridl baseline,
ridl lock, ridlc check, and ridlc build all report
error: no `ridl.toml` found at or above `<path>` — exit 2 is right, the
cause is wrong, confirmed directly against this build — and when a
subdirectory nested inside an otherwise-readable workspace is unreadable,
the same six report a bare error: Permission denied (os error 13), naming
no path at all, also confirmed directly. ridl test reaches the same code
path and wraps it with the top-level path it was given
(error: <path>: no ridl.toml found… in the first case,
error: <path>: Permission denied… in the second — also confirmed directly),
so it names a path but the wrong one in the second case: the workspace
root, not the subdirectory that actually failed. ridl fmt is the one
subcommand that names the actual cause in both cases (see
above); ridl diff names it only when the unreadable directory
is the argument passed to it directly, not one nested inside a readable
argument.
Lints
A lint is a diagnostic that a project can turn off, report at a different
severity, or turn into an error. Every warning and every info diagnostic in the
catalogue is a lint: each has a stable kebab-case name, and its catalogue
severity is its default level, unless the table at the end of this page gives
its default as allow. A lint that is allow by default is reported only when
a project sets its level. An error diagnostic is never a lint, and its severity
cannot be changed.
The lint name appears in every report of a lint diagnostic. The JSON report of
ridl check --format json carries it in the
lint field, and the text report adds a note after the snippet:
warning[TYPL-102]: `float` without both a range and a `step`
┌─ ./demo.ridl:3:15
│
3 │ type Broken : km/h [250.0..0.0]
│ ^^^^
│
= lint: `unbounded-float` (set its level in `[lints]` in ridl.toml)
Levels
A lint has one of four levels:
allowremoves the diagnostic from the report.inforeports the diagnostic as an info.warnreports the diagnostic as a warning.denyreports the diagnostic as an error. Inridl check,ridl build,ridlc checkandridlc build, an error makes the command exit 1, so a lint atdenyfails the run the same way any other error does.
No lint defaults to deny.
The [lints] table
A level is set in the [lints] table of a ridl.toml. Each key is a lint name
and each value is one of the strings allow, info, warn or deny:
[lints]
missing-timing = "deny"
shared-error-type = "allow"
A package manifest and a workspace manifest both accept the table. There is no command-line flag that changes a level and no source attribute that changes a level: a CI that wants stricter checks commits the levels to the manifest, so the editor, the command line and an agent see the same levels for the same entry point.
The level of a lint for a package is resolved key by key, and a later source replaces an earlier one:
- the default level from the table below;
- the
[lints]table of the workspace root manifest, when the package is a workspace member; - the package's own
[lints]table.
A diagnostic takes the levels of the package whose directory contains the file
of its primary span. In a workspace, the root's [lints] table covers the
whole directory tree of the root: a file under the workspace root that no
member contains, and the root ridl.toml itself, take the root's levels. A
file outside the directory tree of the entry point (the standard library, a
fetched import, an editor buffer outside the project) uses the default levels,
so a project's [lints] never applies to code outside its directory. When there
is no manifest at all, for example ridl check on a single file outside any
package, the default levels apply.
A member loads its workspace. Running ridl check on a workspace member
or on a file inside a member, opening an editor on a member, or passing a
member as the path of the MCP tool ridl_check loads the workspace whose
members lists the member. The workspace root's [lints] table applies to the
member, and the member's own table is applied over it, as in a check from the
root. The [defaults] keys and [imports] behave the same way. The command
reports only the diagnostics of files under the member; check from the
workspace root to see every member's diagnostics. A package that no workspace
lists stays standalone, and the search for a workspace stops at the first
[workspace] manifest and at a directory that holds .git.
Known limitations of the language server. The language server reads the
[lints] tables once, when it loads the workspace: at start, or when the first
file is opened. An edit to a [lints] table takes effect in the editor after
the server restarts. The language server publishes the diagnostics of every
loaded member, also when the editor is opened on one member.
Where levels apply
Levels apply where diagnostics are reported to a person or an agent:
ridl check, including the desk check against a baseline;ridl build, which stops before it writes any artifact when a lint is atdeny;ridlc checkandridlc build;- the language server;
- the MCP tool
ridl_check.
Every other command reports the severities the emit sites chose: ridl diff,
ridl test, ridl baseline, ridl lock, ridl vendor, the MCP tool
ridl_diff and the MCP lookup tools. A lint at deny never makes one of them
fail. The text note that names the lint still appears in their output. A lint
that is allow by default is left out of their output, as it is everywhere
else.
Mistakes in [lints]
A mistake in the table never stops a check. Each of these raises MANI-010
(unknown-lint, a warning) on the entry, and the entry is ignored:
- the key is not a lint name, which includes a diagnostic code written as a key
(
RIDL-100 = "deny") and the name of an error code, which has none; - the value is not a string;
- the value is a string other than
allow,info,warnordeny, which includes a level with the wrong case ("Deny"); lintsis not a table.
MANI-010 is itself a lint, so the table can set its level.
The lints
| Lint | Code | Default | Summary |
|---|---|---|---|
unused-import | TYPL-007 | warn | unused import |
unneeded-import-alias | TYPL-008 | warn | import alias without an actual collision |
unbounded-integer | TYPL-101 | warn | integer without a range constraint |
unbounded-float | TYPL-102 | warn | float without both a range and a step |
unbounded-length | TYPL-103 | warn | string/bytes without explicit bounds |
no-init-value | TYPL-115 | info | type has no derivable init value and no declared = value |
bound-above-transfer | TYPL-116 | warn | string/bytes bound above the advised transfer limit |
duplicate-reserved | TYPL-211 | warn | duplicate reserved entry |
inconsistent-unit | TYPL-222 | info | one field name used with different units |
duplicate-shape | TYPL-224 | info | duplicate declaration shape |
broken-doc-link | TYPL-401 | warn | doc link or @see target that does not resolve |
detached-doc-comment | TYPL-404 | warn | blank line between a doc comment and its carrier |
deprecated-without-reason | TYPL-405 | warn | @deprecated doc tag without a reason string |
missing-docs | TYPL-406 | warn | item without a doc comment |
misplaced-doc-comment | TYPL-407 | warn | doc comment in a position that is not a carrier |
unknown-doc-tag | TYPL-408 | warn | doc tag other than @see, @since, @deprecated and @labels |
malformed-doc-tag | TYPL-409 | warn | @see or @since with a missing or malformed value |
doc-comment-style | TYPL-410 | allow | doc comment written as /** */ |
missing-timing | RIDL-100 | warn | signal or event without a timing annotation |
degenerate-timing-range | RIDL-108 | warn | degenerate timing range @[X..X] |
missing-response-bound | RIDL-112 | warn | command or query takes the default response bound |
payload-above-ceiling | RIDL-150 | warn | payload above the payload ceiling or with no finite bound |
podbuf-padding-order | RIDL-156 | info | struct field order wastes podbuf padding |
error-typed-parameter | RIDL-304 | warn | error-typed or result-union parameter on a command or query |
ensure-without-result | RIDL-305 | warn | ensure clause that never references result |
contract-error-name-in-enum | RIDL-307 | warn | contract-error category name declared in an error enum |
named-result-union-in-query | RIDL-308 | warn | named result union in query return position |
query-named-like-mutation | RIDL-404 | warn | query named like a mutation |
shared-error-type | RIDL-405 | info | one error type shared across unrelated failure domains |
redeclared-envelope-metadata | RIDL-406 | info | payload struct re-declares envelope metadata |
ordinal-changed | RIDL-407 | warn | interaction, struct field, or union arm ordinal changed against the published baseline |
low-cohesion-interface | RIDL-414 | info | interface members form disconnected type-sharing groups |
lock-in-subdirectory | RIDL-416 | warn | interfaces.lock in a subdirectory of a unit is not read |
unsupported-source-file | RIDL-417 | warn | .rxdl or .rmdl file is not compiled: its profile is not supported |
redundant-provider-set | RSDL-409 | warn | a requires resolves to a redundant provider set |
unclaimed-backend-key | RSDL-804 | warn | a backend key whose namespace no configured backend claims |
depth-below-bound | RSDL-805 | warn | a declared depth is below ceil(max / min) for an event a covered link consumes |
depth-underivable | RSDL-806 | warn | an event whose contract bound is not derivable is consumed by a link with no declared depth |
link-encoding-not-emitted | RSDL-807 | warn | a link of the selected deployment carries an encoding the emitted backend's encodings list does not name |
unknown-manifest-key | MANI-005 | warn | unknown manifest key |
unknown-lint | MANI-010 | warn | [lints] entry names no lint, or its value is not a level |
vendor-source-not-loaded | MANI-018 | warn | a source file under the root's vendor/ is not loaded |
vendor-source-not-loaded (MANI-018) is raised in standalone mode only; a
workspace has no such warning.
bound-above-transfer (TYPL-116) reports a string bound above 4,032
characters or a bytes bound above 16,128 bytes, on the bound.
payload-above-ceiling (RIDL-150) reports a payload whose largest encoded size,
in an encoding that the workspace manifest enables, is above 16,256 bytes or has no finite
bound. It reads the encodings from every [backend.<name>] table of the
workspace manifest, as one set, and checks FlatBuffers when that set is empty,
which is the case for no table and for tables whose encodings lists are all
empty. It reports on the
interaction that carries the payload, so a type that no interaction carries is
not reported. A fixed is provisioned and never in flight, so it is not
checked. A query whose reply is a fallible T | E is checked through both
types, and the larger problem is reported. Both messages say that the payload
ceiling is 16,256 bytes and that the codecs do not yet enforce it
(driftsys/ridl#996). Both default to warn, and a package can set either to
deny. The limits are stated in
ADR-0030.
[limits] in a unit's ridl.toml sets a payload limit below the ceiling for an
interaction. RIDL-151 (a payload above its declared limit or with no finite
bound) and RIDL-152 (a [limits] entry that names no interaction of its unit)
are errors, so they have no lint name and no level in [lints]. RIDL-151 runs
only where an entry gives the limit; where none does, payload-above-ceiling
alone applies. The table and the codes are described in the
CLI reference and in
ADR-0030.
payload-above-ceiling is raised by ridl check and ridl build. The language
server does not run it, so it does not appear in the editor. It skips a package
that has an error that stops ridl build writing artifacts, or that references
a package that has one, and still reports on the other packages of the
workspace. An RSDL-7xx error does not count, because a build still writes code
after it. An error in no package source file, such as a manifest error, skips
every package.
podbuf-padding-order (RIDL-156) reports a struct whose declared field order
lays out in a larger podbuf head than the same fields sorted by descending
alignment, with declaration order kept among fields of equal alignment. The
message names both head sizes and the saving order. It runs only when a
[backend.<name>] table of the workspace manifest lists podbuf in its
encodings, and only in ridl check, because it reads the published baseline
and ridlc does not. A struct is released when a snapshot under
.ridl/baseline/ (or under the --baseline path) declares it, and a released
struct is never reported, because its order is part of its wire format. With no
baseline, every struct is unreleased. The order is reported only when it is
strictly smaller than the declared order, so a struct that no order improves
draws nothing. The level is info by default.
Setting ordinal-changed to allow makes ridl check --baseline report
nothing for ordinal changes, so the baseline desk check no longer warns about
them.
link-encoding-not-emitted has a per-backend way to silence it, which the
[lints] level does not: a [backend.<name>] table that names no encoding —
an empty encodings list, or no encodings key at all — declares that the
backend emits no codec, and that backend's links are not compared. A backend
with no table at all is still compared. See
ridl build for the rule in full.
How the design lints' levels were set
inconsistent-unit, duplicate-shape and low-cohesion-interface are
workspace design lints: each reports a property of the whole checked workspace
rather than of one declaration. Their default levels and thresholds were not
chosen by hand. Each check was run over the evaluation corpus under evals/,
three workspaces translated from public interface definitions, and every
finding was labelled, blind, by two labellers, one of them a rule-based script,
with the maintainer deciding every disagreement. A check's precision is the
share of its findings that the labels accept:
- at least 80 %: the lint defaults to
warn; - from 50 % up to 80 %: the lint defaults to
info; - below 50 %: the check does not ship as a lint.
A check with fewer than ten findings on the corpus defaults to info at most,
because the sample is too small for warn. A threshold, such as the number of
groups low-cohesion-interface reports from, is the least strict value whose
precision still meets the level; it is a constant beside the check and is not
set in [lints]. Two candidate checks, one on inconsistent abbreviations and
one on a package's number of dependencies, fell below the floor and are not
lints; the package coupling numbers stay available through the MCP tool
ridl_metrics.
The derivation tables, with the precision at every candidate threshold and the
recall against the corpus review rubrics, are in
evals/calibration/summary.md;
the method, the outcome and the recall mapping are recorded in
evals/calibration/notes.md.
A test pins the number of findings each design lint reports on each corpus
workspace, so a change that moves a count is visible in review.
Stability
From release 0.10.0, you can move from one 0.10.x release to the next without
changing a source file, a manifest, a script that calls the CLI, a codegen
plugin, a program that uses the generated Rust crate, or a deployed peer. This
chapter lists what that promise covers and what it leaves out. Release 0.10.0 is
published. ridl vendor and the vendor/ layout were added after it, so the
promise covers them from the first release that contains them.
What will not change in 0.10.x
- The language. The syntax and the keywords of typl, ridl and rsdl, and the set of sources that compile without an error.
- The manifest. The keys of
ridl.toml, their value types, the layout ofvendor/(<manifest root>/vendor/<key>/), and the formats ofinterfaces.lockand.ridl/baseline. - Diagnostics and lints. The diagnostic codes, the lint names and the default level of each lint.
- The command line. The subcommands, the flags, the exit codes, and the
shape of the
--format jsonand--format sarifoutput. - The generated Rust crate. The domain types and the interaction face
(
Client,Provider,Publisher,Event,serveandBind). - The runtime crates. The public API of
ridl-rt,ridl-rt-conformance,ridl-loopback,ridl-descriptorandridl-transport-ws, and the names of their cargo features,podbufamong them. - The codegen plugin protocol. The messages
ridl.ir.v2andridl.codegen.v1, as a plugin reads and writes them. An enum of the protocol gains a value only in a minor release, such as 0.11, because a plugin rejects an enum value that it does not know. - The MCP tools. The tool names and the input and output schemas.
- The wire format. The frame envelope, the
attachhandshake, the inputs of the catalog hash, the emitted.protoand.fbsmapping, the encoding names, and the offset and size of every field of the podbuf head of every struct and union of the frozen corpus. - The minimum Rust version. Rust 1.88 for
ridl-rt,ridl-rt-conformance,ridl-loopback,ridl-transport-ws(with every cargo feature) andridl-descriptor, with edition 2021 for the first four and edition 2024 forridl-descriptor. The generated crate compiles as edition 2021 with Rust 1.88 with every cargo feature on and with default features off, andridl buildwritesedition = "2021"andrust-version = "1.88"in its manifest. A 0.10.x release does not raise a minimum. Releases before the one that raised it to 1.88 had a minimum of Rust 1.83.
The minimum covers the crates RIDL publishes and emits, not later releases of
their dependencies. A dependency, such as regex, can publish a release that
requires a later Rust. Edition 2021 selects resolver 2, which by default
selects the newest release without reading its rust-version. To build with
Rust 1.88, resolve dependencies with a resolver that reads rust-version: set
rust-version = "1.88" in your own package (the emitted manifest of the
generated crate sets it), and resolve with
CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback set or with resolver 3, or
build from a lockfile resolved that way. just compat-check resolves the
lockfile of the crate it emits for its test corpus this way before it builds
that crate with Rust 1.88.
What may change in 0.10.x
- Additions. A new subcommand, flag, manifest key, field, tool, cargo feature or transport default. A program that does not use an addition is not affected by it. A new flag, command-line argument or MCP tool input on an existing subcommand or tool is optional: a 0.10.x release does not add a required one to an existing subcommand or tool, and does not make an optional one required. A new subcommand or tool may have required ones.
- New lints. A new lint starts at the
alloworinfolevel, so it does not fail a build that passed before. TYPL-116 (bound-above-transfer) and RIDL-150 (payload-above-ceiling) are the two exceptions: they start atwarn, which does not failridl checkeither, because a payload above the payload ceiling is a defect, even though the codecs do not yet enforce the ceiling. - Message text. The text of a diagnostic message, of a doc comment and of generated docs.
- Size and budget values. For example the size of a header or the sum that a budget limits.
- Unpublished crates.
xtask,ridlc-gen-modelandridlc-gen-rust.
A security fix is the one reason to break a frozen surface in a 0.10.x release. Its reason is recorded in the stability policy.
When a break can happen
A break can only land in 0.11 or a later release, and that release states it. The releases of 0.9.x and earlier are not covered by this promise.
How the promise is checked
The repository checks each frozen surface that has a recorded baseline against
that baseline, and a change that removes or alters something a user depends on
fails the build. Some parts are promised by policy and not yet checked by a
tool: the formats of interfaces.lock and .ridl/baseline, the exit codes of
every subcommand other than ridl check, the output of ridl diff, the
categories that ridl diff reports, the layouts of the unsubscribe, read
and occurrence frames, which no test vector of the frame specification §11.1
shows, the payload-presence rule of §11.1 for a publish under Init or
Invalid(Declared) and for a response under reply, which no vector shows,
and the codes of §4 and §9.6 that no vector carries, among them the kind
values 2 and 5 and the attach encodings 2 and 3, and the ridl-ir types
that appear in ridl-descriptor signatures
(#1010). A contributor who
must change a frozen surface follows the steps in CONTRIBUTING.md, "Changing a
frozen surface". The full policy, with the gate that checks each surface and the
rules for approving a break, is
ADR-0029.
Built. typl has a working toolchain in this repository: the compiler accepts every construct below, and
ridl fmt, the LSP server and the code generators are built on it. See what is built.
typl Language Reference
Type Language — the vocabulary layer of the RIDL family: types, ranges,
units, constants, and composites, with package namespacing. typl is the root of
the family lattice — every other profile (ridl, rmdl, rsdl) builds on the
vocabulary typl declares.
Version: 0.1.0
Provenance. This reference re-frames Language Reference §1–§11 of the RIDL Language Reference as the named foundation layer (per the RIDL family concept note, §7.1 Stage 1), incorporates the module system of ADR-0002, and mines the preliminary markspec typl DSL as prior work (see Appendix F). Where this document and the RIDL Language Reference v0.1 overlap, this document is authoritative for the vocabulary layer; the RIDL Language Reference retains authority for interactions.
Table of Contents
- Scope and Position in the Family
- Lexical Conventions
- Packages, Imports, and Visibility
- Primitives
- Type Definitions
- Constants
- Structs
- Enums
- Enum Sets
- Unions
- Tuples
- Collections
- Comments
- Doc Comments
- Conventions
- Diagnostics
- Open Questions
- Appendix A — Standard Library
- Appendix B — Full Example
- Appendix C — Standards References
- Appendix D — Codegen Targets
- Appendix E — Formal Grammar (EBNF)
- Appendix F — Prior Work: markspec typl
- Appendix G — Coverage Analysis: JSON Schema and Other Schema Languages
- Appendix H — Glossary
1. Scope and Position in the Family
1.1 What typl is
typl is a units-aware, range-constrained schema language. It answers one question: what is the shared vocabulary? — the types, physical units, ranges, constants, and composite shapes that every other concern in a component-based reactive system describes things with.
typl is the only truly standalone member of the RIDL family. A pure typl package is useful by itself: it generates data types, validators, and documentation across every backend without any interface, behaviour, or deployment content. Every other profile requires typl; typl requires nothing above it.
The name is deliberately -L, not -DL: typl does not describe a system
aspect — it is the vocabulary the describing languages are written in.
1.2 The .typl profile
There is one grammar for the whole RIDL family; each language is a profile —
a restriction of that grammar selected by file extension. A .typl file accepts
type declarations only:
Accepted in .typl | Rejected in .typl (belongs to) |
|---|---|
package, import, as, internal | interface, signal, event, command, query, fixed (ridl) |
type, const | interaction declarations (ridl, and the rxdl spellings) |
struct, enum, enumset, union | model, node, behaviour operators (rmdl) |
tuples, collections, ? optionality | instances, wiring, deployment (rsdl) |
doc comments, @labels | timing annotations @Xms, @[min..max] (time core) |
streams <T> (ridl — see §1.3) |
typl declarations are also legal inside every other profile's files and inside
.rxdl (the total profile) — a .ridl file may declare the types its interface
uses. Profile purity is a policy decision declared per package in
ridl.toml, not a grammar limitation (concept note §4).
1.3 Profile boundary decisions
Three boundary calls are recorded here because they were genuinely undecided:
Streams belong to ridl, not typl. A stream <T> is not a data shape — it
has no bound, its framing is transport-decided, and it is only valid in
interaction position (command/query parameters and returns). That makes it
an interaction container, not a vocabulary item. typl owns the element
type (type FwBlock : bytes [1..4096]); ridl owns the <T> container. A
<T> appearing in a .typl file is a profile error (TYPL-301).
Timing is not typl. Duration literals (10ms, @[20ms..1s]) belong to the
family's time core, consumed by ridl/rmdl. The family lexer recognises
duration tokens everywhere (one lexer, one registry), but no typl construct
accepts them. The stdlib Duration type (a UCUM ms unit type) is unaffected
— it is an ordinary unit type.
Interaction kinds are not typl kinds. The markspec-typl precursor attached
kinds (signal, event, command, state, stream, config, document) to
type bindings. In the family these are split by layer:
signal/event/command/query/fixed are ridl's interaction keywords over
the interact core; state and stream processing are rmdl territory; config
maps to ridl fixed (provisioned constants); document is just a struct.
typl keeps only the pure vocabulary kinds: type, const, and the composites.
See Appendix F.
1.4 Keyword discipline
The family maintains one reserved-word registry across all profiles (concept
note §4.1). Every family keyword is reserved in every profile, including
keywords a profile does not accept — signal is not a valid identifier in a
.typl file even though .typl rejects signal declarations. This keeps .rxdl
unambiguous and keeps identifiers portable across layers.
Keywords used by the typl profile:
package import as internal
type const struct enum enumset union
boolean integer float string bytes
true false step match reserved error
Init values use bare = value (§5.8), not a keyword —
type Speed : km/h [0.0..250.0 step 0.5] = 0.0, the universal default-value
idiom. There is no default keyword (retired) and no init keyword in
typl (init is rmdl's, where two equations per flow must be disambiguated). The
wire clause and explicit width names (uint8…float64) are deferred to
v0.2 (§17.11): range-first inference plus ridl-diff breaking-change
detection cover the common case, and forward-compat width-pinning is a niche
refinement not worth ten keywords in v0.1.
Keywords reserved family-wide but rejected by .typl (the registry as
built, crates/ridl-syntax/src/keywords.rs): ridl's interface, service,
signal, event, command, query and fixed (family-neutral, ADR-0012
decision 4); the expr-core words require, ensure; the ridl clause words
states and during (general form §4.8); rmdl's model, function, let,
init, last, case, if, then, else, when, emit (it also surfaces
signal/event in signatures — same concepts, one registry entry each; init
is rmdl's alone — the memory-seed keyword, needed because a flow has two
equations; typl/ridl express init as bare = value; its ambient time values
now/dt are contextual identifiers, not keywords; pre,
->-as-followed-by, node, returns, realizes, and a surface step
considered and rejected — models are contract-blind, so no realizes; step
remains typl's quantization keyword alone), plus its reserved set merge,
current, state, transition, automaton; and rsdl's system, component,
distribution, deployment, machine, offers, requires, for (rsdl
reference §2 — the rsdl v0.1 words provides, instance, assurance,
target, place, on, transport, bundle, time, base, redundant,
supervise and degraded left the registry with rsdl v0.2, because no other
profile uses them, and each is an ordinary identifier in every profile). (node
and returns were considered and rejected by rmdl — never reserved.) The
per-profile keyword sections of each language reference enumerate their own
additions; the union of those sections is the registry until the platform
spec extracts it as a standalone normative list.
The domain spellings are not reserved words — the member-start rule
(ADR-0012 decision 4, as amended on 2026-10-11). present, notify, measure,
detect, actuate, trigger, the intent operation shapes activate,
toggle, select, adjust, dismiss, and the open candidates supply and
read are keywords only as the first token of an interface member, in a profile
that implements the spelling; no profile does in this release. Everywhere else
each is an identifier: the ROS 2 corpus names a field present
(evals/corpus/ros2/sensor_msgs/messages.typl) and a query trigger
(evals/corpus/ros2/std_srvs/interactions.ridl). An interface member line never
starts with an identifier, so a profile that gains a spelling invalidates no
source that compiled before. The uxdl words ADR-0012 retired — view,
display, input, action, fetch, navigate, scroll, drag, observe,
surface, agent — are ordinary identifiers in every profile.
Registry admission test — language, never runtime (family doctrine, audit
passed). Every registry entry names a describable property — a shape, an
interaction, a requirement, an availability, an occurrence relation — never an
execution mechanism. Steps, scheduling, acks, retries, async/await,
quarantine, and clock domains are runtime vocabulary: they appear in semantics
prose and runtime specs, never as surface syntax. Borderline entries carry their
justification where declared (now/dt: logical time is contract-constrainable
— rmdl §6.3). A future wire clause (deferred, §17.11) would clear the same bar
— wire ABI is contract, not execution. Rejected for leaking: throws, a surface
step, library time().
2. Lexical Conventions
The family has one lexer; these conventions bind family-wide. They are restated here because typl is the foundation document.
2.1 Encoding
Source files are encoded in UTF-8 (Unicode 15.0, RFC 3629). Non-ASCII characters are permitted only inside string literals and doc comments.
2.2 Whitespace, Line Endings, Separators
Whitespace (space, tab, CR, LF) is insignificant except as a token separator. A
line break is an LF, a CRLF pair, or a lone CR (a CR that is not
followed by an LF); a CRLF pair is one line break. A line comment and a doc
comment line end at a line break of any of these three forms, a string literal
and a regex literal cannot contain one, and diagnostic line numbers count each
of them. Newline and comma are interchangeable separators in all block
constructs; trailing comma is permitted. There are no semicolons — the
ADR-0002 example package veh.common.types; is errata; the grammar has none
(concept note §4.1 errata).
2.3 Identifiers
Three ASCII identifier conventions:
| Form | Pattern | Used for |
|---|---|---|
CamelCase | starts uppercase, no separators | types, structs, enums, enumsets, unions |
camelCase | starts lowercase, no separators | fields, tuple fields |
SCREAMING_SNAKE | uppercase, underscore separators | enum values, enumset bits, constants |
Identifiers start with a letter; digits permitted after the first character;
underscore only in SCREAMING_SNAKE. Reserved words (§1.4) may not be used as
identifiers.
2.4 Integer Literals
Follow ISO/IEC 9899:2018 (C17)
§6.4.4.1: decimal, no leading zeros, unary minus for negatives (0, 42,
-40).
2.5 Floating-Point Literals
IEEE 754-2019 double precision.
Must include a decimal point (0.0, 3.14, -40.0). Scientific notation is
not supported in v0.1. NaN and Inf are not permitted in range constraints.
2.6 String Literals
UTF-8 in double quotes. Escapes follow
RFC 8259 §7: \", \\, \n, \r,
\t, \uXXXX.
2.7 Regex Literals
Enclosed in forward slashes; syntax follows
ECMA-262,
restricted to the patterns that the Rust regex
crate also compiles. Forward slashes inside the pattern are escaped \/.
/^[A-HJ-NPR-Z0-9]{17}$/
A pattern that is not ECMA-262 syntax is TYPL-106. A pattern that is ECMA-262
syntax but that the regex crate cannot compile is TYPL-220. The Rust backend
emits a match constraint on a string-backed type as a regex::Regex::new
call in the generated code, under the generated crate's validate-pattern
feature (on by default), and a pattern that call refuses would fail at run time
instead of at build time. A regex constant is emitted as text and is not
compiled, but a match that names the constant is, so the constant is checked
at its declaration (§6.2).
The ECMA-262 constructs that the regex crate refuses, and that a typl pattern
therefore cannot use, include:
- lookaround:
(?=…),(?!…),(?<=…),(?<!…) - a backreference, numbered (
\1) or named (\k<name>) - the control escape
\cX, the NUL escape\0, and an identity escape of a letter theregexcrate gives no meaning, such as\e,\Qor\k. An identity escape of a letter the crate does give a meaning, such as\a,\A,\zor\pL, compiles in both engines, with the different meanings the table below records - the empty class
[], the negated empty class[^], and an unescaped[inside a class, as in[[] - the backspace escape inside a class,
[\b] - a class escape as a range bound, as in
[\d-z]or[\w-.] - a quantifier with no lower bound, such as
{,3}, and a{that opens no complete quantifier, as in{alone ora{ - a pattern whose compiled form exceeds the
regexcrate's size limit.\wand\p{..}are large Unicode classes in that crate, so an ordinary pattern such as^\w{1,256}$exceeds the limit, while^[A-Za-z0-9_]{1,256}$does not. An artificial pattern such as(a{1000}){1000}exceeds it too.
This list is not complete. The rule is that the compiler compiles the pattern
with both engines: a construct not listed here that the regex crate refuses is
TYPL-220 too.
TYPL-220 guarantees only that the Rust output can compile the pattern. It does not guarantee that the checker, the Rust output and the output of another backend match the same strings. The checker tests a declared init value against a pattern (TYPL-109) with ECMA-262 semantics, and some patterns that both engines compile have a different meaning in each (driftsys/ridl#597):
| In a pattern | ECMA-262, no u flag (the checker) | The Rust regex crate |
|---|---|---|
\d, \w | ASCII digits, ASCII word characters | Unicode digits, Unicode word characters |
\p{L} | the text p{L} | a Unicode letter |
\A, \z | the letters A and z | the start and the end of the text |
\a | the letter a | the bell character, U+0007 |
\< | the character < | a start-of-word boundary |
[a&&b] | one of a, &, b | the intersection of a and b, empty |
[[a]] | one of [, a, followed by ] | the nested class [a] |
2.8 Tokens recognised but not used by typl
Duration literals (500us, 10ms, 1s, 5min, 1h — the five suffixes ridl
§2.1 tabulates) and the @ timing sigil are lexed family-wide but rejected by
the .typl profile (TYPL-302).
3. Packages, Imports, and Visibility
The module system is ADR-0002's ns core, restated normatively for the typl
profile. Four keywords: package, import, as, internal. Distribution
(manifest, lockfile, cache) is tooling, not language — see ADR-0002.
3.1 Package Declaration
Every file begins with exactly one package declaration — dot-separated
lowercase identifiers:
package veh.common
A package is a directory. All files in the directory declare the same package name, and the name mirrors the directory path relative to the manifest root. Mismatch is a hard error (TYPL-002). The package — not the file — is the unit of visibility, cycle checking, and codegen output.
A package cannot take a name the compiler provides (TYPL-010). Today that is
ridl.std alone. Such a package can never be used: every package already
imports all of ridl.std implicitly (§3.2) rather than through an import, so
the declarations are unreachable under their own name. Where the generated
artifact is named for the package, it is overwritten by the standard package's
own as well. The reservation is the set of packages the compiler provides, not
the ridl. prefix — ridl.stdlib is an ordinary package name.
3.2 Imports
Imports are qualified and named:
import veh.common.Speed
import marine.nav.Speed as MarineSpeed // alias — collision release valve
Per ADR-0002 there are no wildcards, no relative imports, no re-exports. All three are hard errors.
Errata note. RIDL Language Reference v0.1 §2.2 showed
import veh.common.*as a linter warning. ADR-0002, accepted later, bans wildcards outright. This document follows ADR-0002: wildcard import is a compile error (TYPL-003). The RIDL Language Reference should be amended to match.
Implicit imports — always available: all definitions in the same package,
and all definitions in ridl.std (Appendix A).
Qualified references — any public definition may be referenced by fully qualified name without an import:
limit : veh.regulatory.SpeedLimit
A type reference that resolves through none of these — not the same package, not
ridl.std, not an import, not a qualified name — or that names another
package's internal declaration (§3.3) is a compile error (TYPL-011), reported
on the written path in every position that takes a type reference.
3.3 Visibility
Two levels. Public is the default; internal is package-private:
struct WheelTick { ... } // public — visible to importers
internal struct RawWheelFrame { ... } // package-private
internal may prefix any typl definition (type, const, struct, enum,
enumset, union). A public declaration must not expose an internal type in
its fields, arms, bounds constants, or backing (TYPL-005) — the contract surface
must be fully importable.
3.4 Cycles
Cycles within a package are permitted (mutually referencing declarations are normal). Cycles across packages are a hard error (TYPL-004). Note that recursive composite shapes are separately restricted — see §7.3.
4. Primitives
Five primitive types. Primitives are lowercase keywords — visually distinct from
CamelCase named types. They serve as backing types in type definitions;
direct use as field types is restricted (§15.3).
| Primitive | Meaning | Constraint |
|---|---|---|
boolean | logical true/false | none |
integer | whole number — width inferred from range | recommended — profile may require |
float | real number — width inferred from range and step | recommended — profile may require |
string | sequence of Unicode scalar values — default [0..256] if unspecified | recommended — profile may require |
bytes | opaque binary buffer — default [0..256] if unspecified | recommended — profile may require |
4.1 Boolean
No constraint syntax. Serialized as uint8 on transport — 0 = false, 1 =
true.
4.2 Integer
Range constraint [min..max] strongly recommended; active profiles may require
it. Width is inferred once by the compiler from the declared range and
passed to all backends:
| Range | Wire type |
|---|---|
[0..255] | uint8 |
[-128..127] | int8 |
[0..65535] | uint16 |
[-32768..32767] | int16 |
[0..4294967295] | uint32 |
[-2147483648..2147483647] | int32 |
larger unsigned — up to [0..9223372036854775807] | uint64 |
| larger signed or no range | int64 |
Integer domain. Every integer range must fit within [-2⁶³ .. 2⁶³−1] — the
int64 domain. Bounds outside it are a compile error (TYPL-111). This closes
the u64 hole: the language layer is always int64 (Kotlin Long, C++
int64_t), which cannot hold the top half of full-range uint64. A value that
genuinely needs all 64 unsigned bits is modelled as bytes [8].
Language layer — always int64. Transport layer — narrowest wire type
that safely contains the range. The resolved width is part of the contract:
widening a range across a width boundary changes the wire type, which is a
breaking change classified by ridl-diff. (An explicit width floor to
pre-empt such flips — a wire clause — is deferred to v0.2, §17.11.)
4.3 Float
Range and step strongly recommended; active profiles may require them. step
declares the value's quantization: valid values are origin + n·step, where n
is an integer and origin is the declared lower bound, or zero when no lower
bound is declared. This is a validity constraint, not a request to round an
input. Integer values must lie exactly on the grid. Source literals and resolved
constants use exact decimal grid membership. Floating-point constructors and
payload validators use the representation rule below and preserve the supplied
value rather than normalize it.
Floating-point grid validation uses the type's inferred wire precision. A constructor checks the value projected to that precision and preserves the supplied backing value. A payload validator checks the received representation at the same precision. This makes a constructed value's wire representation valid and keeps a decoded value valid under the same constructor check.
The runtime rule uses binary64 arithmetic over the projected value v. Prepare
the phase p = origin modulo step exactly in decimal arithmetic, in
[0, step), so shifting a grid by a large origin does not lose its phase.
Represent phase and step as a binary64 high part and a residual low part;
compensated arithmetic retains those residuals when reconstructing candidate
grid points. Subnormal coefficients are scaled by an exact power of two before
splitting and computation, then reconstructed values are scaled back.
The adjacent values at the wire precision define v's rounding cell. If the
step is smaller than that cell's width, it contains a grid point and v is
valid. Otherwise estimate q = (v - p) / step, with q = v / step - p / step
as the subtraction-overflow fallback. Check the nearest integer index and its
two neighbors, using compensated reconstruction and projecting each candidate to
the wire precision. A candidate r admits v when
abs(v - r) <= min(4 * epsilon * max(abs(v), abs(p), abs(step)), step / 4),
where epsilon is machine epsilon at the wire precision. round selects the
nearest integer, with ties away from zero. The quarter-step cap bounds the
rounding allowance where the grid is resolvable.
A positive decimal step that underflows binary64 is dense in every finite rounding cell. For a step that overflows binary64, prepare its finitely representable grid points exactly before rounding them and use the same relative rounding allowance. NaN and infinity are never grid values. This is a bounded representation rule, rather than exact decimal arithmetic in the runtime or input normalization; a contract requiring exact decimal grid identity should use the scaled-integer form below.
Width inference is count-based, not step-based. Let
N = (max − min) / step + 1 — the number of distinct representable values:
| Condition | Wire type |
|---|---|
range + step declared, N ≤ 2²⁴, both bounds exactly representable in binary32 | float32 |
otherwise — no step, no range, N > 2²⁴, or bounds not representable | float64 |
Errata note. RIDL Language Reference v0.1 §3.3 inferred float width from step alone (
step >= 0.001→float32). That rule is unsound:float [0.0..1000000.0 step 0.001]satisfies it but requires 10⁹ distinct values — far beyond binary32's 24-bit significand. The count-based rule replaces it; the RIDL Language Reference should be amended.
Quantized wire form — scaled integers. Because step anchors values at
min + n·step, a quantized float is losslessly a scaled integer:
raw = (value − min) / step, an unsigned integer whose width follows from N
via the §4.2 table. Transports with a scaled-integer convention — CAN/DBC
always, SOME/IP optionally per deployment — encode quantized floats this way
(factor = step, offset = min). This is exactly AUTOSAR's LINEAR CompuMethod, so
the ARXML mapping is direct, and it sidesteps binary-float step fuzziness (0.001
has no exact binary32 representation) on the transports that care most. proto3
and FlatBuffers keep native float/double. See Appendix D.
Language layer — always float64. Transport layer — as inferred above.
4.4 String
A string is a sequence of Unicode scalar values. string [N] bounds the
count of scalar values — not graphemes, which are unbounded, and not bytes,
which bytes counts. Default [0..256] when unspecified (warning).
The largest advised bound is 4,032 characters. A string of 4,032 characters is at most 16,128 bytes of UTF-8, which with the transport header and the encoding overhead fits in one transfer of 16,384 bytes (ADR-0030 decision 6). A bound above it draws TYPL-116 (warning), reported on the bound. The payload ceiling is 16,256 bytes, and the codecs do not yet enforce it (driftsys/ridl#996).
Every encoding carries a string as UTF-8. The in-memory representation is
the target language's own — UTF-8 in Rust, UTF-16 in TypeScript, Kotlin and C# —
and the codec converts at the buffer, as every proto and FlatBuffers runtime on
those platforms already does. The byte capacity of string [N] is therefore
4·N, which is UTF-16's worst case as well, so UTF-8 costs nothing at the
maximum.
Why not UTF-16 on the wire. proto3
stringmust be UTF-8 and FlatBuffersstringis UTF-8, so a UTF-16 wire form means emittingbytesand[ushort]in the schemas. A consumer generated from those schemas would then see an opaque byte field where the contract says text, which breaks ADR-0018 decision 4's rule that interoperability is at the bytes, mediated by the emitted schema.
One consequence is not a projection rule and belongs to a runtime rather than to
an encoding: a JavaScript string may hold a lone surrogate, which is not a
scalar value and which TextEncoder silently replaces with U+FFFD, so a codec
compiled to wasm cannot see the violation. The TypeScript runtime package checks
well-formedness at its boundary, before handing the string to the codec, and
rejects.
Two rules are an encoding's own rather than this reference's: how a string is
stored in an in-place layout, and whether a terminator is guaranteed for a C
reader. Both are settled by the podbuf projection record
(ADR-0031 decision 5): the
tail holds the bytes present followed by one NUL, and the fixed-capacity form is
a rejected alternative of that record.
4.5 Bytes
Bound is in bytes; constraint syntax mirrors string but has no match.
Default [0..256] when unspecified (warning).
The largest advised bound is 16,128 bytes, the byte size of a string at its advised bound (§4.4). A bound above it draws TYPL-116 (warning), reported on the bound.
4.6 Byte Order
Byte order is not a type property. A typl declaration never states one, and none of the ten inferred widths (§4.2, §4.3) carries an endianness.
Each encoding fixes it: proto3 and FlatBuffers each define their own, and the
podbuf projection declares one. A transport that carries a per-signal choice —
CAN and DBC's Intel/Motorola selection is the case that raises the question —
takes that choice from the deployment, which is rsdl's, and never from the type.
The rsdl surface for stating it lands with the rsdl rewrite at v0.2 (§17.9).
5. Type Definitions
The type keyword defines a named scalar — a constrained primitive or a
physical unit type. Named types carry domain meaning and are the primary
building blocks for all fields.
5.1 Physical Unit Types
A unit type associates a scalar with a unit from the Unified Code for Units of Measure (UCUM) — the unit standard used by OpenTelemetry, HL7 FHIR, and ISO 11240:
type Speed : km/h [0.0..250.0 step 0.5]
type Temperature : Cel [-40.0..125.0 step 0.1]
type Torque : N.m [0.0..500.0 step 0.1]
type RPM : /min [0.0..8000.0 step 10.0]
type Pressure : bar [0.0..10.0 step 0.01]
type Voltage : V [0.0..48.0 step 0.1]
type Ratio : % [0.0..100.0 step 0.1]
In v0.1 the underlying primitive is float, whatever the literal type of the
range (§17.12 records the v0.2 rule, under which integer literals give an
integer backing). UCUM expressions are case-sensitive. Because a unit type
carries unit, range, and step, downstream layers inherit dimensional checking
and range-driven saturation — rmdl computes with Speed, not bare real
(concept note §6).
Common automotive UCUM units: km/h, Cel, N.m, /min, m/s2, bar, V,
A, W, %.
5.2 Constrained Primitive Types
type Counter : integer [0..65535]
type Gain : float [0.0..1.0 step 0.01]
type Vin : string [17 match VIN_PATTERN]
type Frame : bytes [8]
5.3 String Constraint Syntax
| Syntax | Meaning |
|---|---|
string [N] | exactly N characters |
string [min..max] | min to max characters |
string [N match PATTERN] | exactly N, validated |
string [min..max match PATTERN] | range with validation |
match references a named regex constant or an inline regex literal.
5.4 Bytes Constraint Syntax
| Syntax | Meaning |
|---|---|
bytes [N] | exactly N bytes |
bytes [min..max] | min to max bytes |
No match — bytes are opaque.
5.5 Range Bounds
Ranges are closed (inclusive) on both ends. Either bound may be omitted
([0..], [..255]), in which case the missing bound defaults to the widest
value the inferred width allows.
NaN is outside every declared numeric range, including a range with an
omitted bound. NaN is also outside every step grid (§4.3); a grid admits only
finite values. A numeric range's omitted bound is the widest finite backing
value, so a range also excludes infinities. An unconstrained float retains its
backing representation's NaN and infinity values. Declaring a range or step
therefore changes validity, without globally restricting the float primitive
to finite values. These rules apply to named types and inline constraints. The
Rust backend enforces them in checked constructors for named scalars and in
payload verification for named and inline constraints. Public primitive fields
do not validate assignment. The reason is domain membership: NaN has no ordered
position in a closed interval and no integer grid index.
Exclusive bounds are not supported, and are not planned. An integer's exclusive bound is written as the closed bound one value inside it. A float's exclusive bound has no definable neighbour: §4.3 derives the width from the range, so "the next representable value" would depend on a width that depends on the bound itself. The forcing case that would reopen this: a published contract whose semantics genuinely require an open interval on a float.
5.6 Width Is Inferred, Never Written
typl v0.1 has no surface syntax for wire width. A range is the only width
control an author needs: integer [0..255] is a uint8, integer [0..65535]
is a uint16, and the compiler derives the narrowest safe width once (§4.2,
§4.3) and passes it to every backend. The ten concrete width names
(uint8 … float64) are not keywords, not primitives, and not writable anywhere
in a .typl source — writing one is a parse error (there is no production for
it).
Range-driven inference has one known hazard: widening [0..250] to [0..300]
silently flips the wire type uint8 → uint16 — a wire ABI break from a
semantically innocent edit (hardest on FlatBuffers and CAN, invisible on proto3
varint). v0.1 handles this with the ridl-diff gate alone: it classifies
every resolved-width change as breaking, so the flip is caught in CI. An
explicit width floor that would pre-empt the flip at the source — a wire
clause pinning headroom above the boundary — is deferred to v0.2; see §17.11.
A resolved width is part of the contract on every encoding, and part of the
memory layout on some. On an encoding with a fixed inline layout there is no
vtable to absorb a change of size, so a width change moves every later field.
The ridl-diff classification above covers both cases, because it is the
resolved width that it compares. A backend that emits a fixed inline layout must
therefore treat a width change as a layout break and must not rely on a
reader tolerating it.
5.7 Type Identity — Nominal
Every named type is nominally distinct — from its backing primitive and from
every other named type, even one with identical constraints. Speed and
Torque are both float-backed; they are not assignable to each other, and
neither accepts a bare float. A literal is accepted where a named type is
expected when it satisfies the type's constraints (this is how
const MAX_SPEED : Speed = 250.0 works); a value of another named type never
is. There is no implicit conversion anywhere in the family — explicit conversion
is expr/rmdl territory, where unit dimensions decide convertibility (§17.5).
Precedent: Ada derived types, Rust newtypes, Haskell newtype. Rationale:
nominal identity is what makes unit safety real — structural typing would let a
Torque flow into a Speed port because both are "float in a range", which is
precisely the class of error this language exists to prevent. Codegen realises
nominal types as distinct wrapper types where the target allows (Rust newtype,
Kotlin value class) and as documented aliases where it does not (proto3).
A language backend's wrapper type also carries the type's constraints: its value is built through a constructor that checks the range, step and length bounds of §5.1 to §5.5 and reports a violation instead of building the value, beside a constructor that does not check, for a value the payload validator has already checked. A backend may make the pattern check optional. A wire backend emits neither, because a schema cannot refuse a value when it is built. ADR-0013 decision 8 is the rule, and records which backends emit the checking constructor today.
5.8 Init Values
Every type has an init value — the value a consumer holds before anything
real arrives (the interaction layer's signal channels start from it; AUTOSAR
initValue is the precedent). It is either declared or derived:
Declared — a bare = value suffix, valid on type definitions as well as
struct fields. There is no keyword: the value simply follows the constraint.
type Speed : km/h [0.0..250.0 step 0.5] = 0.0
The bare = value form is the family's single init syntax at the vocabulary and
interaction layers (typl types, typl struct fields, ridl signal overrides). It
carries no keyword — not default (retired) and not init (which belongs
to rmdl alone, where init x = value seeds a memory recurrence and the keyword
is needed to disambiguate it from the equation).
Derived — when no = value is declared:
| Type | Derived init |
|---|---|
boolean | false |
| numeric / unit type | 0 (or 0.0) if admitted by both range and step, else the lower bound when declared |
string / bytes | empty if the bounds admit length 0; not derivable otherwise |
type with match pattern | not derivable (a pattern-valid value cannot be synthesised) — declare a = value |
enum | the value 0 if declared, else the lowest declared value |
enumset | empty set |
struct | each field's init, recursively; optional fields absent |
union | first arm's init |
| tuple | each field's init |
| array | min-bound count of element inits (empty when min = 0) |
| map | empty when min = 0; one key/value init when min = 1 and both are derivable; otherwise not derivable |
For a numeric range with no lower bound whose upper bound is negative, derive the upper bound when no step is declared. With a step, derive the greatest grid point at or below that upper bound, using the zero origin and exact decimal arithmetic. A derived floating-point init must fit the finite backing domain; when no finite grid point is available, the init is not derivable.
A map with a minimum count above one has no derived init: repeating a key's init would violate key uniqueness, and the toolchain does not synthesize distinct keys. Optional map fields without an override remain absent.
A declared init value must have the backing's kind and satisfy the type's
constraints, including step (TYPL-109). Floating-point init values and typed
floating-point constants must lie within the finite binary64 backing domain.
Integer and enum init values may use an exactly integral numeric spelling such
as 1.0; an enum value must name a declared discriminant. Constant references
are checked after resolution; an unresolved reference is an invalid init. A
string literal initializes string or its UTF-8 bytes initialize bytes, with
the corresponding character or byte length check. A boolean literal initializes
only boolean. The init syntax has no composite or enum-set literal, so a
literal or constant override on a struct, union, tuple, array, map or enum set
is rejected rather than stored as an untyped scalar. Derived composite inits
remain recursive as listed above. A type whose init is not derivable is simply
marked so in the IR (TYPL-115, info) — it becomes an error only where a consumer
requires an init (e.g. a ridl signal payload, ridl §4.4) and none is declared.
6. Constants
const defines a named compile-time constant — package-scoped, importable, and
reusable in range constraints, match patterns, and init (= value)
declarations. Higher profiles also use constants in require/ensure
expressions.
6.1 Value Constants
package veh.common
const MAX_SPEED : Speed = 250.0
const SPEED_LIMIT_EU : Speed = 130.0
const MAX_GEAR : integer = 6
const IDLE_RPM : RPM = 800.0
SCREAMING_SNAKE naming. The declared value must satisfy the constant's type
constraints (TYPL-108). Reuse:
type Speed : km/h [0.0..MAX_SPEED step 0.5]
struct GearState {
gear : integer [0..MAX_GEAR]
}
6.2 Regex Constants
A const may hold a regex literal, reusable in match constraints. The pattern
follows §2.7, and TYPL-106 or TYPL-220 is reported at the constant's
declaration, not at each match that names it. The Rust backend emits the
constant itself as text, not as a compiled pattern:
const VIN_PATTERN = /^[A-HJ-NPR-Z0-9]{17}$/
const ASCII_PATTERN = /^[\x20-\x7E]+$/
type Vin : string [17 match VIN_PATTERN]
type Email : string [1..254 match /^[^@]+@[^@]+\.[^@]+$/]
7. Structs
A struct is a named composite with a fixed, closed set of named fields.
There are no additional/unknown properties — a struct is closed by definition
(contrast JSON Schema's open-world default, Appendix G). A field name is unique
within its struct (TYPL-215): a name identifies one field, so a struct cannot
declare two.
struct DriverProfile {
name : Name
speed : Speed
override : Speed? // optional — may be absent
}
7.1 Optionality
The ? suffix marks a field as optional — may be absent, which is distinct
from "present with a null value"; typl has no null. Maps to optional in
proto3, Option<T> in Rust, T? in Kotlin.
7.2 Field Init Values
A field may carry a bare = value suffix — its init value, the same syntax a
type uses (§5.8). There is no attribute block and no keyword; typl carries no
other field attribute at the vocabulary layer.
struct RetryPolicy {
attempts : integer [0..10] = 3
backoff : Duration = 100.0
}
The init value must satisfy the field's type (TYPL-109). require/ensure are
expr-core attributes owned by higher profiles and are rejected in .typl
(TYPL-303).
7.3 No Recursive Shapes
A struct (or union) must not reference itself, directly or through any chain of composite references. Recursion makes the wire size unbounded, which contradicts the bounded-size guarantee every typl composite otherwise carries. Recursive composite reference is a compile error (TYPL-206). A genuine tree- or graph-shaped payload is modelled with explicit indices into a bounded array.
This is not provisional. The bounded-size guarantee is what the rest of the language rests on: §12 requires an explicit bound on every variable-size collection, §4.3 infers float width from a count of representable values, and an encoding with a fixed inline layout cannot exist at all for a shape whose size is not known at compile time. The forcing case that would reopen it: a published contract whose payload is genuinely tree-shaped and whose depth cannot be bounded at the source.
7.4 Field Identity and Evolution
Tag-based transports (proto3 field numbers, FlatBuffers field ids) need a stable
numeric identity per field. typl assigns it implicitly, by declaration order
(1-based), for struct fields and union arms alike. There is no explicit = N
tag syntax.
That makes evolution discipline part of the language:
- Append-only. New fields are added at the end of the struct or union.
Inserting or reordering fields shifts the ordinals of everything after them —
a wire break, rejected by
ridl-diff. - Delete by tombstone. A removed field keeps its ordinal slot occupied with
reserved:
struct DriverProfile {
name : Name
reserved legacyChecksum // was ordinal 2 — slot retired, never reused
speed : Speed
}
Re-declaring a field under a reserved name is a compile error (TYPL-210).
reserved in an enum body. Both forms are legal there, and they guarantee
different things. reserved 3 retires the wire value 3: it is never reused
with a new meaning. reserved Retired retires the name Retired: it is
never re-declared, and it says nothing about any wire value — a value is retired
only by the integer form, because the value a retired name once carried is not
recoverable from the source that no longer declares it. Re-declaring either
draws TYPL-210. A backend projects a name tombstone where its target has a name
reservation of its own — proto3's reserved "NAME"; — and drops it where its
target has none, as FlatBuffers does.
-
ridl-diffis the enforcement point. Ordinals are fully derivable from source — no sidecar state, no lockfile-assigned numbers. The CI plane (ridl-diffagainst the previous IR snapshot) rejects reorder, insertion, un-tombstoned deletion, and an append that the backward-compatibility decoder rule below cannot read, in published packages. -
Decoder rule for forward compatibility. The flip side of append-only: a decoder built against version n of a struct, receiving a payload from a version n+k encoder, must ignore unknown trailing fields (and should preserve them when acting as a proxy — the proto3 unknown-field lesson). This is native on proto3/FlatBuffers; on positional transports it requires length-prefixed framing (SOME/IP TLV) or is simply unavailable (CAN) — where unavailable, mixed-version communication is a deployment error rsdl must surface (§17.9).
-
Decoder rule for backward compatibility. The other direction: a decoder built against version n+k of a struct, receiving a payload from a version n encoder, finds every field appended since n absent. An absent optional field reads as absent. An absent non-optional field reads as the transport's default, 0, only when the field is a scalar, an enum or an enum set whose type allows the value 0: a range and
stepthat hold 0 (§4.3), an enum that declares a member with the value 0, or any enum set (0 is the empty set). Any other absent non-optional field — a string, bytes, struct, union, tuple, array or map field, or a scalar or enum whose type excludes 0 — has no value to read, and the decoder refuses the payload rather than invent one. Soridl-diffreports appending a non-optional field as breaking unless its type allows 0; the author declares the new field optional (field : T?) instead (driftsys/ridl#598). A type the diff cannot resolve is reported as breaking;ridl diffresolves types against the new snapshot and the built-inridl.std.
Rationale. Explicit proto-style tags were considered and rejected: they
impose a per-field numbering ritual on every struct — including the majority
bound to positional transports (CAN, SOME/IP) where the numbers mean nothing —
and gaps/vanity numbering must then be linted. Implicit ordinals keep the
surface small and the audit trail honest: the wire layout is readable from the
.typl file alone. The discipline this demands — append-only with tombstones —
is the discipline a contract language wants anyway. Trade-off on record:
reordering is legal syntax, and only ridl-diff makes it an error; publishing
without the CI gate is unprotected. Judged acceptable because ridlc --frozen +
ridl-diff are already mandatory in the ecosystem's CI plane.
8. Enums
An enum is a named set of discrete integer-backed values, for single-value
selection:
enum GearPosition {
PARK = 0
DRIVE = 1
REVERSE = 2
NEUTRAL = 3
}
- Backing type is
integer, implicit; all values explicitly assigned; values unique (TYPL-203) - A value name is unique within its enum (TYPL-216): a name identifies one value, so an enum cannot declare two
- First value conventionally
= 0for proto3 compatibility - String-backed enums are not supported in v0.1; planned for v0.2 (§17.1)
Identity is the value, not the position. An enum value's wire identity is
the explicit number it declares, and nothing about it depends on where the value
sits in the body. Reordering the members of an enum body therefore changes
nothing on any wire; ridl diff reports it as enum_reordered, compatible.
This is the opposite of a struct field or a union arm, whose identity is its
declaration order (§7.4), and it is why an enum value may be inserted in the
middle of a body while a struct field may not.
As built.
ridl diffcomparesenumvalues andenumsetbits by the number each declares. A member whose number changed is reported asmember_reorderedwith its old and new number, breaking. A body whose difference is only order — its values or bits reordered with no number changed, or anenum'sreservedlist reordered with no entry changed — is reported asenum_reordered, compatible: nothing moves on the wire. The catalog hash covers only the declarations an interface reaches; when an interface reaches theenumorenumset, the order of its values or bits, and of anenum'sreservedlist, is part of the hash, so the hash changes then.
9. Enum Sets
An enumset is a named bitfield — multiple flags active simultaneously. Backing
width inferred from the highest bit position.
An enumset bit's identity is the bit position it declares, not its position in
the body, so reordering the members of an enumset body changes nothing on any
wire, and ridl diff reports it as enum_reordered, compatible — the same rule
as §8's, for the same reason.
9.1 Standalone Form
enumset WarningFlags {
LOW_FUEL = 0 // bit 0
CHECK_ENGINE = 1 // bit 1
DOOR_OPEN = 2 // bit 2
SEATBELT = 3 // bit 3
}
- Bit positions unique (TYPL-207)
- A bit name is unique within its enumset (TYPL-218): a name identifies one bit, so an enumset cannot declare two
- A bit position is a number with an integer value (TYPL-219): a string, a
boolean, a fractional number, a regex, or a constant reference is refused,
1.0is accepted as bit 1, and a position outside bits 0..63 is TYPL-111
9.2 Derived Form
Derives bit positions from an existing enum — preferred when both single-value
and set use are needed:
enum Warning { LOW_FUEL = 0, CHECK_ENGINE = 1, DOOR_OPEN = 2, SEATBELT = 3 }
enumset WarningFlags : Warning
9.3 Width Inference
| Highest bit | Wire type |
|---|---|
| 0..7 | uint8 |
| 8..15 | uint16 |
| 16..31 | uint32 |
| 32..63 | uint64 |
Language layer — always int64. Transport layer — narrowest inferred.
10. Unions
A union is a discriminated (tagged) union — exactly one arm active at any
time:
union SensorResult {
ok : SensorReading
err : SensorFault
}
- Arms reference named types only — primitives not permitted directly (TYPL-204)
- An arm name is unique within its union (TYPL-217): a name identifies one arm, so a union cannot declare two
- Untagged unions and intersections (JSON Schema
anyOf/allOf) are deliberately not supported — see Appendix G - Maps to
oneofin proto3,unionin FlatBuffers,sealed classin Kotlin, Rustenum
10.1 Error Types
The error modifier marks a named composite as failure vocabulary:
error enum DiagError {
FILTER_INVALID = 0
STORAGE_BUSY = 1
ACCESS_DENIED = 2
}
error struct CalFault { // data-carrying failure
code : integer [0..255]
detail : Message
}
error union ServiceFault { // composes error types — all arms error-typed
diag : DiagError
cal : CalFault
}
erroris valid onenum,struct, anduniononly (TYPL-212); anerror union's arms must all be error-typed (TYPL-214)- An error type is otherwise an ordinary type — package-scoped, importable, nominally distinct, usable wherever data is legal (a fault-log struct may contain one; a signal may publish one). The marker adds exactly one bit of meaning: this shape describes a failure
- Codegen realises error types with the target's error material where it exists
(Rust
std::error::Errorimpl, Kotlin sealed failure hierarchies) and as plain data elsewhere
10.2 Result Unions
A union mixing error and non-error arms is a result union and must have
exactly two arms — one success arm (any non-error type) and one error arm (an
error type). Anything else that mixes the two kinds is malformed (TYPL-213).
Several failure kinds compose into one error union before entering the
result union.
union CalOutcome {
ok : CalReport
err : CalFault
}
A result union is the family's Result<T, E> — achieved without generics,
consistent with the no-parametric-types decision. In pure typl a result union is
simply a union; the interaction layer (ridl §10.1) recognises it at
interaction positions and maps the error arm onto transport-native error
channels. This is the family's entire functional-error mechanism: failure is
data, declared once in the vocabulary — there is no throws, no exception, no
second channel anywhere in the family.
11. Tuples
A tuple is an anonymous composite of named fields used inline — as struct fields at the typl layer; higher profiles additionally use tuples as query returns (ridl §7.1). A tuple is not an interaction parameter type: a parameter is a named type or a stream (ridl §6.1, §7.1).
struct SensorBounds {
range : (min: Speed, max: Speed)
}
- Fields are always named — positional access is not permitted
- A field name is unique within its tuple (TYPL-215), as in a struct (§7)
- The empty tuple
()is the unit type (its interaction role is defined by ridl) - The same tuple shape used in multiple places draws a linter warning: extract a
named
struct(TYPL-205)
12. Collections
Collections are finite, bounded aggregates. All variable-size collections require explicit bounds — no defaults, no exceptions (TYPL-201/202).
12.1 Array
readings : [Speed; 8] // fixed — exactly 8
faults : [FaultCode; 0..32] // bounded — 0 to 32
12.2 Map
Keys must be a named string type or primitive. Each key occurs at most once in a
map; a payload containing duplicate keys is invalid. Equality is value equality
in the key's declared type, including floating-point equality (+0.0 and -0.0
compare equal; NaN does not compare equal to itself). Validation must reject
duplicates rather than choose which entry to retain:
metadata : [Label : Name; 0..32]
sensors : [Label : Speed; 1..8]
A bare string or bytes key takes §4.4–§4.5's [0..256] length default, with
TYPL-103, the same as every other position — §15.3 keeps a bare string/bytes
out of a field position, but a map key is not one, so the default applies there
instead. A key therefore always carries a length bound, which is what lets a
projection state a finite size for the map.
A map has no declared entry order, and typl defines no canonical payload
encoding. A map is the only typl construct whose ordering is free: an array is
ordered, a struct field and a union arm are identified by ordinal (§7.4), and an
enum value and an enumset bit carry explicit numbers (§8, §9). So a
bit-reproducible byte sequence — what a signed payload, a content hash or a
replay plane needs — is a property of an encoding and of the frame that carries
it, not of the type. The rule that fixes it, including the entry order a map
serialises in, belongs to the frame specification and to each projection record
(§17.10).
12.3 Bound Rules
| Construct | Syntax | Bound |
|---|---|---|
| Exact-length array | [T; N] | exactly N — mandatory |
| Bounded array | [T; min..max] | min to max — mandatory |
| Bounded map | [K:V; min..max] | min to max entries — mandatory |
The stream container <T> is the deliberate exception to boundedness and lives
in the ridl profile (§1.3).
13. Comments
Discarded by the compiler.
// line comment
/* block comment — nesting not supported */
14. Doc Comments
A doc comment documents the next named declaration or member, called its
carrier. It is processed by documentation generators and editors and has no
semantic effect: the catalog hash clears every doc field, and ridl diff
classifies a change to one as DocOnly. No blank line may separate a doc
comment from its carrier (TYPL-404). The decisions behind this section are
ADR-0026.
14.1 Syntax
/// Current calibrated wheel radius
type WheelRadius : m [0.20..0.45 step 0.001]
/// lines are the house style. A block /** ... */ has the same meaning; a
leading * on each of its lines is decoration and is stripped. A project can
require /// with the doc-comment-style lint (TYPL-410, allow by default).
A doc is CommonMark, and a bracketed name in it is a link to a declaration or a member (§14.5). The carriers are listed in §14.4.
Examples are written under a # Examples heading with a fenced code block. This
is a convention: the compiler does not read it.
14.2 Tags
A tag is @word at the start of a line of the doc, after the comment markers
and leading whitespace. An @ anywhere else is prose. A tag line is not part of
the doc text. Each tag may appear more than once, and every value is kept.
| Tag | Value | Valid on |
|---|---|---|
@see | one qualified name, with an optional member; a link | all |
@since | a version, MAJOR.MINOR or MAJOR.MINOR.PATCH | all |
@labels | comma-separated classification labels (§14.3) | all |
@deprecated | "reason string" | all |
Any other tag draws TYPL-408. A @see or @since with a missing or malformed
value draws TYPL-409, and a @see target that does not resolve draws TYPL-401.
A @deprecated with no reason draws TYPL-405. Every tag is accepted on every
carrier, and @deprecated and @labels are stored in the IR on the carriers
that have the field — a declaration, a struct field, an interface, an
interaction and a service. On an enum value, an enumset bit, a union arm, a
parameter and an rsdl carrier the two tags are read and dropped, with no
diagnostic. Both stay doc tags: ADR-0028 decision 4 declines the general form's
plan to make deprecated and labels attribute keys.
14.3 Labels
@labels carries free-form SCREAMING_SNAKE classification identifiers
(optionally with a parenthesised suffix, e.g. MY_LABEL(D)). The core language
defines no vocabulary — an external profile validates labels and enforces
combinations. The compiler passes labels through to generated metadata
unchanged.
14.4 Carriers
| Carrier | A doc is required (TYPL-406) |
|---|---|
type, const, struct, enum, enumset, union, interface, service | when not internal |
| struct field, enum value, enumset bit, union arm, interaction | when the declaration is not internal |
parameter of a command or query | never |
reserved entry | never |
rsdl system, component, distribution, deployment, machine | always |
rsdl body line (offers, requires, a bare member reference) | never |
A package needs no doc. A doc made only of tags (§14.2) counts as missing. A
parameter's type is always a named type, which carries its own doc. A doc
comment in any other position — before package, an import, a return type or
an attribute block, an arm of an inline T | E return, or at the end of a file
or a body — is misplaced (TYPL-407).
14.5 Links
Three bracket forms are doc links:
[Name]and[pkg.Name];[`Name`], the code-span form;[text][Name], with an explicit label.
The bracket content is a link only when it is a qualified identifier with at
most one member suffix: [0..250] and [see below] are prose. A name inside a
code span or a fenced code block is not a link, and a [Name] that has a
CommonMark link reference definition in the same doc is an ordinary Markdown
link.
A link resolves in the scope of the file that holds it, with the rules of a type
reference (§3): a bare name is looked up locally, then among the imported names,
then in ridl.std. A qualified pkg.Name names any package the current package
can depend on (ADR-0002 §5) without an import; a remote package of [imports]
is reachable where an import of it resolves — the package's own [imports]
or, for a workspace member, the workspace [imports] pin its unit. One more
segment names a member of the declaration: a field, enum value, enumset bit,
union arm or interaction ([Gear.PARK], [CruiseControl.setLever]). When a
name could be a package path or a declaration followed by a member, the link
resolves as a type reference resolves the same name. A declaration that is
internal in another package cannot be linked to. A link that does not resolve
draws TYPL-401; each link that resolves is stored in the IR with its canonical
target.
15. Conventions
15.1 Naming
| Construct | Convention | Example |
|---|---|---|
type, struct, enum, enumset, union | CamelCase | VehicleSpeed, WarningFlags |
| Fields, tuple fields | camelCase | currentSpeed |
| Enum values, enumset bits, constants | SCREAMING_SNAKE | PARK, MAX_SPEED |
| Packages | lowercase.dot | veh.common |
15.2 Separators
Newline and comma interchangeable in all block constructs; trailing comma permitted; no semicolons.
15.3 Type Usage
stringandbytesmust not be used directly as field types — always define a namedtype(the ridl profile grants streams an element-type exception; there is no exception inside typl)- All variable-size types declare explicit bounds
unionarms reference named types only- Prefer named types over inline primitives for all domain concepts
15.4 Imports
Explicit single-type imports only (wildcards are an error, §3.2). Aliases only on genuine collision — linters flag gratuitous aliases.
16. Diagnostics
typl adopts stable diagnostic codes — a practice inherited from the markspec
typl catalogue (Appendix F). Codes are namespaced TYPL- and grouped by
hundreds: 0xx module, 1xx scalars & constants, 2xx composites, 3xx profile
boundary, 4xx documentation. Codes are never renumbered; retired codes are
marked deprecated and never reused.
The tables below are the TYPL- codes only. A .typl file also draws the two
namespaces no profile owns — FORM- (surface syntax: lexical, parse, and the
attribute-block rules) and MANI- (the manifest). Both are tabulated once in
the family overview §7 and are not restated here.
A .typl file also draws one RIDL- code, RIDL-149: two names in one scope
that collide after a pinned name transform (ADR-0016). Three of its scopes are
typl declarations — the fields of one struct, the arms of one union, and the
values of one enum — so LEVEL_10 beside LEVEL10 in one enum is refused, as
both become Level10. Its row, with the transform each scope is checked under,
is in ridl §16.4.
16.1 Module (TYPL-0xx)
| Code | Rule | Severity |
|---|---|---|
| TYPL-001 | more than one package declaration in a file | error |
| TYPL-002 | package name does not mirror the directory path | error |
| TYPL-003 | wildcard, relative, or re-exporting import | error |
| TYPL-004 | circular package imports | error |
| TYPL-005 | a public declaration exposes an internal type | error |
| TYPL-006 | conflicting imports without an alias | error |
| TYPL-007 | unused import | warning |
| TYPL-008 | import alias without an actual collision | warning |
| TYPL-009 | duplicate definition of the same name in a package | error |
| TYPL-010 | package name is reserved for a package the compiler provides | error |
| TYPL-011 | type reference names no visible declaration (§3.2, §3.3) | error |
16.2 Scalars and Constants (TYPL-1xx)
| Code | Rule | Severity |
|---|---|---|
| TYPL-101 | integer without a range constraint | warning; error if active profile requires |
| TYPL-102 | float without both a range and a step | warning; error if active profile requires |
| TYPL-103 | string/bytes without explicit bounds — default [0..256] applied | warning; error if active profile requires |
| TYPL-104 | range min > max | error |
| TYPL-105 | step type mismatch, non-positive, or larger than the range | error |
| TYPL-106 | invalid regex syntax in match or a regex const — a valid pattern the Rust regex crate refuses is TYPL-220 | error |
| TYPL-107 | regex contradicts declared character bound | warning |
| TYPL-108 | const value violates its declared type constraints | error |
| TYPL-109 | init = value incompatible with the type or field constraints | error |
| TYPL-110 | unknown or malformed UCUM unit expression | error |
| TYPL-111 | integer range bound outside the int64 domain ([-2⁶³..2⁶³−1]) | error |
| TYPL-112 | a concrete wire-width name (uint8 … float64) written in source — width is inferred, not written (§5.6) | error |
| TYPL-115 | type has no derivable init value and no declared = value (§5.8) | info — escalated by consumers that require an init |
| TYPL-116 | string/bytes bound above the advised transfer limit — a string bound above 4,032 characters or a bytes bound above 16,128 bytes (§4.4, §4.5), reported on the bound; the payload ceiling is 16,256 bytes, which the codecs do not yet enforce (driftsys/ridl#996) | warning |
16.3 Composites (TYPL-2xx)
| Code | Rule | Severity |
|---|---|---|
| TYPL-201 | array without explicit bounds | error |
| TYPL-202 | map without explicit bounds | error |
| TYPL-203 | enum values not unique or not explicitly assigned | error |
| TYPL-204 | union arm with a primitive type | error |
| TYPL-205 | same tuple shape used in multiple places | warning |
| TYPL-206 | recursive composite reference, direct or transitive | error |
| TYPL-207 | enumset bit positions not unique | error |
| TYPL-208 | string/bytes used directly as a field type | error |
| TYPL-209 | map key is not a named string type or a primitive | error |
| TYPL-210 | field, arm, or enum value re-declared under a reserved entry (a name or a value) | error |
| TYPL-211 | duplicate reserved entry — a dangling entry (a name or value never previously used) falls under this rule too, but its check needs the previous IR snapshot and is not built | warning |
| TYPL-212 | error modifier on a declaration other than enum, struct, union | error |
| TYPL-213 | union mixes error and non-error arms without the result-union shape (exactly one success + one error arm) | error |
| TYPL-214 | error union contains a non-error-typed arm | error |
| TYPL-215 | field name declared twice in one struct or one tuple | error |
| TYPL-216 | enum value name declared twice in one enum | error |
| TYPL-217 | union arm name declared twice in one union | error |
| TYPL-218 | enumset bit name declared twice in one enumset | error |
| TYPL-219 | enumset bit position is not a number with an integer value | error |
| TYPL-220 | regex pattern the Rust regex crate cannot compile (§2.7) — numbered in this range, but not a composite | error |
| TYPL-222 | one field name used with different units | info |
| TYPL-224 | duplicate declaration shape | info |
16.4 Profile Boundary (TYPL-3xx)
Emitted when a .typl file (or a package declared profile = "typl" in
ridl.toml) contains constructs of a higher layer. TYPL-301 is also emitted in
a .ridl file, as its row says:
| Code | Rule | Severity |
|---|---|---|
| TYPL-301 | stream type <T> outside interaction position — in a typl context, and in a ridl file anywhere but a parameter or a query return, such as a struct field, a collection, a type definition or a union arm, except a position diagnosed separately: a signal or event payload is RIDL-201, a fixed payload is FORM-102 and a command return is RIDL-104 (ridl §12.3) | error |
| TYPL-302 | timing annotation or duration literal in a typl context | error |
| TYPL-303 | require/ensure attribute in a typl context | error |
| TYPL-304 | interaction declaration in a typl context — one of the ridl interaction words at declaration start; an rmdl or rsdl word there is FORM-102 | error |
16.5 Documentation (TYPL-4xx)
| Code | Rule | Severity |
|---|---|---|
| TYPL-401 | doc link or @see target that does not resolve — the name is unknown, is internal in another package, or names no member of the declaration | warning |
| TYPL-402 | @labels identifier not recognised by active profile | info |
| TYPL-403 | @labels combination invalid per active profile | error |
| TYPL-404 | blank line between a doc comment and its carrier — a declaration or a member | warning |
| TYPL-405 | @deprecated doc tag without a reason string | warning |
| TYPL-406 | item without a doc comment — a declaration that is not internal, a member of one, or an rsdl declaration; a doc made only of tags is missing | warning |
| TYPL-407 | doc comment in a position that is not a carrier — before package, an import, a return type or an attribute block, or at the end of a file or a body | warning |
| TYPL-408 | doc tag other than @see, @since, @deprecated and @labels | warning |
| TYPL-409 | @see or @since with a missing or malformed value | warning |
| TYPL-410 | doc comment written as /** */ — allow by default | warning |
17. Open Questions
Every question this section has carried is disposed of. A question is either resolved — its rule has moved into the section of this reference that governs it, and it no longer appears below — or deferred to a named version, in which case it appears below with that version.
The numbers are stable. Other documents cite these questions by number, so a resolved question's number is retired rather than reused, and the ledger records where its rule now lives. A number with no entry after the ledger is resolved, except §17.10, which is resolved and keeps its entry because other documents cite that section.
17.0 Disposition Ledger
| # | Question | Disposition |
|---|---|---|
| 17.1 | String-backed enums | deferred to v0.2 |
| 17.2 | Exclusive range bounds | resolved — not supported and not planned; the rule and its reason are §5.5 |
| 17.3 | uniqueItems for arrays | deferred to v0.2, as an array constraint; the set container is rejected |
| 17.4 | Recursion policy | resolved — the hard error stands; the rule and its reason are §7.3 |
| 17.5 | Unit conversion semantics | deferred to the rmdl era |
| 17.6 | Scientific notation in float literals | deferred to v0.2 |
| 17.7 | Value-rule expressions | deferred to the family expr core |
| 17.8 | Standards-fixed invalid sentinel values | deferred to v0.2; the rest of the original question is resolved |
| 17.9 | Byte order | resolved for typl — byte order is not a type property, §4.6; the deployment surface is rsdl's, at rsdl v0.2 |
| 17.10 | Canonical (deterministic) encoding | resolved — not a type-layer property, §12.2; the frame specification §4 and each projection record own the rest |
| 17.11 | Explicit wire-width floor | split: the layout consequence is resolved in §5.6; the wire clause is deferred to v0.2 |
| 17.12 | Integer-backed unit types | deferred to v0.2 |
| 17.13 | What a string is made of, and how it is encoded | resolved — the rule is §4 and §4.4 |
| 17.14 | Value-aware enum and enum-set reorder comparison | resolved — the rule is §8 and §9 |
| 17.15 | A reorder in the same edit as an addition or a removal | deferred to v0.2, sequenced behind driftsys/ridl#302 |
One question outside this section was disposed of in the same pass: whether a
name-based reserved is legal in an enum body, and what it guarantees
(driftsys/ridl#245). It is legal and it retires the name; the rule is §7.4.
17.1 String-Backed Enums — deferred to v0.2
JSON Schema, TypeScript, and OpenAPI make string enums the common case; typl
v0.1 supports only integer-backed enums (wire efficiency, DBC/CAN mapping).
Candidate design: enum Region : string { EU = "eu", US = "us" } — integer wire
form with a declared string rendering.
Deferred to v0.2, with the candidate design above as the starting point. The
wire form stays integer, so nothing in §4.2's width table, in ridl-diff, or in
any encoding changes; what is added is a rendering per value, carried to the
targets that have somewhere to put it. It is the one gap in Appendix G's matrix
with named external consumers.
17.3 uniqueItems for Arrays — deferred to v0.2
Not expressible today. The set collection once suggested here
({T; min..max}) is rejected: no target this family emits has a set type —
proto3, FlatBuffers and podbuf each carry a vector plus a rule — so a set
container would add a surface with no projection, and every backend would
flatten it back into an array.
Uniqueness is instead a constraint on an array, alongside the range, step,
length bound and match: enforced by the generated validator and carried as
documentation on the wire targets. Deferred to v0.2, where the generated
validator is the enforcement point.
17.5 Unit Conversion Semantics — deferred to the rmdl era
UCUM units are currently labels with dimensional identity. Whether typl-core
defines convertibility (km/h ↔ m/s), or leaves conversion to codegen, is
open.
Deferred to the rmdl era — the question is claimed by rmdl §12 open question
8, where cross-unit arithmetic (Speed * Duration : Length) makes it concrete,
and RMDL-106's explicit-conversion rule is the stopgap until then. typl's own
commitment is unchanged and is not waiting on that answer: a unit is a label
with dimensional identity, and nominal identity (§5.7) forbids implicit
conversion in every profile.
17.6 Scientific Notation in Float Literals — deferred to v0.2
A v0.1 float literal must contain a decimal point and admits no exponent (§2.5). The restriction is at the family lexer, so it binds every profile, and rmdl is where it is felt: gains, tolerances and small time constants are where exponent notation earns its place.
Deferred to v0.2, as a family lexer change. Precondition: the exponent
form must not collide with the number-plus-unit-atom merge the same lexer
performs. That merge is restricted to the UCUM time atoms (us, ms, s,
min, h), none of which begins with e, but UCUM has atoms that do (eV).
The check before the pattern changes is that no literal position in the v0.2
grammar admits a bare unit atom after a number.
17.7 Value-Rule Expressions — deferred to the family expr core
typl v0.1 constraints are deliberately closed-form: literal/const ranges,
step, length bounds, and match patterns. Everything requiring a general
expression language is deferred to the family expr core and will arrive with
require/ensure: (a) arithmetic in constraint bounds
([0.0..MAX_SPEED * 0.5] — v0.1 bounds accept only literals and plain constant
references); (b) predicate constraints on a single value (value % 2 == 0);
(c) regex matching as a predicate operator — declarative match on string
types exists today; when expr lands, the same match keyword serves infix
in predicates (require vin match VIN_PATTERN): one keyword, one concept
(pattern conformance), two positions. (A ~ sigil was considered and rejected —
the family is deliberately sigil-poor, and match reads as English to
non-programmer audiences); (d) cross-field invariants on structs
(min <= max) — the likely surface is a struct-level invariant block
completing the Eiffel triad beside ridl's require/ensure.
Rationale for the deferral: closed-form constraints keep every typl type
decidable for width inference and mechanically derivable into property-test
generators; general expressions in constraint position need fencing rules
(const-evaluable subset only) that belong to the expr-core specification, not
here. TYPL-303 already rejects require/ensure in a .typl context, which is
the boundary this deferral protects.
The target is anchored. The expr core is the
expr-core Specification (family overview §2;
concept note open question 6), whose guaranteed subset ships with the interface
layer and whose function layer is not built. Sub-items (a) to (c) are
constraint-position expressions and are the expr core's entirely. Sub-item (d)'s
expression language is likewise the expr core's, but its surface — a
struct-level invariant block — is a typl declaration shape and lands in §7
when the expr core does.
17.8 Standards-Fixed Invalid Sentinel Values — deferred to v0.2
Most of the original question is resolved and lives elsewhere. The init half is
§5.8 (a declared bare = value, or derived). The invalid half is handled at the
interaction layer: ridl §4.5 propagates invalidity as channel state, with the
SNA sentinel as its CAN/AUTOSAR wire realisation. Where the codegen is free to
choose the value, ADR-0013
decision 7 settles it and no typl syntax is needed — ? declares the absence,
the backend picks a value the declared range does not use and does not surface
it in the generated API, and where the range has no room the backend fails with
a diagnostic.
What remains open is the case where the backend may not choose, because a published standard has fixed the value. Two sub-cases are known and are the inputs to the decision:
- a single fixed value, as in a brownfield DBC signal whose SNA encoding is already deployed;
- several fixed values with distinct meanings, as in the ETSI ITS vehicle-length data type, which separates "longer than this field can express" from "not measured". One reserved value cannot carry both, and a consumer can act on the first while it can only abstain on the second. This is a contract fact rather than a backend fact, which is why it is not pushed to a backend annotation.
Deferred to v0.2. It is answerable when a conformance target exists: modelling one published message end to end — a Cooperative Awareness Message is the candidate — is the way to test a proposal before it becomes syntax.
17.9 Byte Order — resolved for typl; the deployment surface is rsdl v0.2
CAN/DBC signals carry per-signal byte order (Intel/Motorola) and SOME/IP defaults big-endian, and typl was silent.
Resolved for typl: byte order is not a type property. The rule is §4.6.
Deferred, rsdl v0.2: the surface by which a deployment states the byte order of a transport that carries a choice belongs to rsdl, and lands with the rsdl rewrite at v0.2 (family overview decision ledger #36). A typl rule cannot be written against rsdl surface that does not exist yet, which is why this half is named rather than written here. The same deployment surface is what §7.4's mixed-version decoder rule needs on a transport that cannot carry unknown trailing fields.
17.10 Canonical (Deterministic) Encoding — resolved; the frame specification and each projection record own the rest of the rule
This question is resolved, and its entry stays below because the frame specification and ADR-0014 cite this section.
Signed payloads, content-hashing of data, and the replay/test plane all want a bit-reproducible encoding (protobuf's deterministic-marshal caveats are the cautionary tale; map ordering is the classic leak).
One correction to this question as it was first written. It guessed the answer was "likely an IR-spec concern". The IR's own canonical form is settled — ADR-0014 decision 9 makes canonical protobuf JSON the canonical encoding, with binary and prototext derived (that clause named binary until decision 9's 2026-09-22 amendment; the policy is the IR specification) — and that is a different artifact. This question is about the payload encoding a contract's values travel in, which ADR-0014 does not touch.
Resolved for typl: a canonical payload encoding is not a type-layer property, and the one ordering typl controls is a map's key order. The rule is §12.2.
Placed: a reproducible byte sequence for a transmitted unit is a property of an encoding and its frame, so the rule belongs to the frame specification and to each projection record, not to this reference. Written 2026-09-22: that document is the frame specification, whose §4 makes the frame header canonical and leaves the payload's part to each projection record.
17.11 Explicit Wire-Width Floor — a deferred wire clause, v0.2
v0.1 has no surface syntax for wire width (§5.6): a range is the width,
inferred once by the compiler, and the ten concrete width names
(uint8 … float64) are not writable anywhere. This leaves one hazard unhanded
at the source: widening a range across a width boundary silently flips the wire
type (uint8 → uint16), a wire ABI break that today only the ridl-diff CI
gate catches.
A future wire clause would let an author pin an explicit width floor
above the boundary — type Counter : integer [0..250] wire uint16 — reserving
evolution headroom so a later widening does not flip the transport encoding.
Design constraints already settled, for whenever it lands: the named width
must be at least the inferred width and of the same signedness class
(narrower or sign-incompatible is an error); it never changes the language-layer
type (int64/float64) or the value constraints — the range still validates,
only the transport encoding widens; and it is a floor, never a parallel type
system (ranges remain the semantic truth). Whether the floor should also be
declarable per-field, and how it interacts with the scaled-integer wire form of
quantized floats, are the two rules a real contract should shape.
Deferred to v0.2. The ridl-diff gate already prevents the silent break in
CI, and the clause is pure evolution ergonomics. The forcing case that reopens
it is ADR-0013 decision 6's: a
deployment that needs to widen a range on a FlatBuffers-bound contract without a
coordinated flag day.
One catalogue entry moves with the clause. TYPL-112 makes a concrete
wire-width name written in source an error (§16.2). A wire clause makes that
rule conditional — the name becomes writable in exactly one position — so the
two are reconciled together when the clause lands.
The half that is not deferred is the consequence a fixed inline layout
carries, which is now stated in §5.6: a resolved width change is a layout break
as well as a wire break, and ridl-diff classifies it breaking on both counts.
No encoding has to wait for the clause to know that.
17.12 Integer-Backed Unit Types — deferred to v0.2
A unit type's backing would be the literal type of its range: integer literals
give an integer backing, float literals give a float backing, and a unit type
with no range keeps today's float default, so a range-less declaration is
unchanged. Latency : ms [0..5000] is then an integer type carrying a unit, and
Timestamp : us and Duration : ms become declarable in typl as the integers a
runtime library already makes them.
The grammar already distinguishes int_lit from float_lit, and TYPL-105
already rejects a step whose literal type differs from the range's, so a
backing cannot flip unnoticed by editing one number wherever a step is
declared; where none is, ridl-diff is what sees the change, because the
backing reaches the IR. The domain type, the width and every encoding follow the
rules plain integer already has, and the unit algebra of §17.5 is untouched.
An explicit backing keyword was rejected: TYPL-105 already makes the flip
explicit, and this is the rule every language uses for 1 against 1.0.
Deferred to v0.2, and it is not a v0.1 change. §5.1 fixes the backing of a
unit type to float, so the new rule changes the backing, the inferred width
and the IR of every unit type already declared with integer literals, and
ridl-diff classifies each as a breaking width change. A point release that
turns every such published contract into a breaking diff is not a point release.
The corpus check runs before the change lands, not after: every unit type
written with integer literals today is either a wanted integer or a missing
.0. Recorded in
docs/wip/2026-09-12-release-scope-and-plugin-system-design.md
§3.10.
17.15 A Reorder in the Same Edit as an Addition or a Removal — deferred to v0.2
ridl-diff's walk reports member_reordered only when both bodies hold the
same member names; with a member added or removed, the report carries the
addition or removal alone and does not name the survivors that moved.
Until it is taken, the report is incomplete rather than wrong. A member
inserted in the middle of a struct or union body, with every later member left
in its written order, shifts the ordinal of every member after it and is
reported as one decl_added, which the classifier judges breaking under §7.4's
append-only rule — the classifier reads the body to judge the addition's
direction, rather than relying on the reorder walk. An enum value or enumset
bit inserted mid-body shifts no number and is judged by its value (§8, §9). A
member removed is one decl_removed, breaking, whether or not a tombstone
replaced it.
Deferred to v0.2, sequenced behind driftsys/ridl#302. Widening the case
reaches the name-keyed matching logic that crates/ridl-diff/src/walk.rs's
carried-debt comment guards, and #302 records why that logic cannot move alone:
on FlatBuffers a union arm's discriminant is implicit in declaration order, so a
tombstoned retirement that the widened logic would call compatible shifts every
later arm. #302 also records why the backend cannot move alone — explicit union
member values pin the discriminants, but flatc 25.12.19 accepts the form while
planus 1.3.0 rejects it, so taking it would put the union path outside the
validity oracle. The blocking constraint is that oracle gap, and no edit to this
reference removes it.
Appendix A — Standard Library
ridl.std is a pure typl package — the proof that typl stands alone. It is
implicitly imported in every file of every profile.
Inclusion criterion. ridl.std holds only definitions whose form is fixed
by a cross-industry standard and whose meaning does not depend on the domain the
system models. A definition that is meaningful in one industry and inert in the
others belongs in a domain package. The criterion follows from the implicit
import: every file of every profile carries whatever this package holds, so a
domain type here is a cost paid by every user who does not work in that domain,
and admitting one leaves no principled place to stop.
Applying that criterion removed the ISO 3779 Vin type and its VIN_PATTERN
constant, which this appendix had inherited unexamined from the RIDL Language
Reference v0.1. A vehicle program declares both in two lines of its own package,
and no other member of ridl.std is bound to a single industry.
Contents (normative):
package ridl.std
// ---------- Regex Constants ----------
/// Pattern of a UUID: five groups of 8, 4, 4, 4 and 12 lowercase hexadecimal digits
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
/// Pattern of a ULID: 26 Crockford base32 characters, the first one 0 to 7
const ULID_PATTERN = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/
/// Pattern of a URI: a scheme, `://`, then at least one character
const URI_PATTERN = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\/.+$/
/// Pattern of a URL: `http://` or `https://`, then at least one character
const URL_PATTERN = /^https?:\/\/.+$/
/// Pattern of an email address: a local part, `@`, and a domain that contains a dot
const EMAIL_PATTERN = /^[^@]+@[^@]+\.[^@]+$/
/// Pattern of an IPv4 address: four groups of 1 to 3 digits, separated by dots
const IPV4_PATTERN = /^(\d{1,3}\.){3}\d{1,3}$/
/// Pattern of an IPv6 address: lowercase hexadecimal digits and colons
const IPV6_PATTERN = /^[0-9a-f:]+$/
/// Pattern of a string of printable ASCII characters
const ASCII_PATTERN = /^[\x20-\x7E]+$/
// ---------- Identity Types ----------
/// RFC 4122 UUID — 36 characters including hyphens
type Uuid : string [36 match UUID_PATTERN]
/// ULID — 26 characters, lexicographically sortable
type Ulid : string [26 match ULID_PATTERN]
// ---------- Network Types ----------
/// RFC 3986 URI
type Uri : string [1..2048 match URI_PATTERN]
/// HTTP/HTTPS URL
type Url : string [1..2048 match URL_PATTERN]
/// Email address — RFC 5321
type Email : string [1..254 match EMAIL_PATTERN]
/// IPv4 address — dotted decimal notation
type IpV4 : string [7..15 match IPV4_PATTERN]
/// IPv6 address
type IpV6 : string [2..39 match IPV6_PATTERN]
// ---------- General String Types ----------
/// Short human-readable label — ASCII printable, max 64 characters
type Label : string [1..64 match ASCII_PATTERN]
/// Human-readable name — max 128 characters
type Name : string [1..128 match ASCII_PATTERN]
/// Short message or description — max 256 characters
type Message : string [1..256 match ASCII_PATTERN]
// ---------- Time Types ----------
/// Absolute point in time — platform time domain (ridl §3.1):
/// int64 microseconds since the PTP epoch, 1970-01-01 00:00:00 TAI.
/// Continuous and leap-second-free; convert to civil datetime only at
/// presentation edges.
type Timestamp : integer [0..9223372036854775807]
/// Elapsed time interval in milliseconds
type Duration : ms [0.0..9223372036854775807]
/// Calendar date — ISO 8601 YYYY-MM-DD
type Date : string [10 match /^\d{4}-\d{2}-\d{2}$/]
/// Time of day — ISO 8601 HH:MM:SS
type TimeOfDay : string [8..15 match /^\d{2}:\d{2}:\d{2}/]
// ---------- Version ----------
/// Semantic version — SemVer 2.0.0
type Version : string [5..32 match /^\d+\.\d+\.\d+/]
// ---------- Locale ----------
/// ISO 3166-1 alpha-2 country code
type CountryCode : string [2 match /^[A-Z]{2}$/]
/// ISO 639-1 language code with optional region — e.g. "en", "en-US"
type LanguageCode : string [2..5 match /^[a-z]{2}(-[A-Z]{2})?$/]
// ---------- Crypto / Security ----------
/// SHA-256 hash — 32 bytes
type Sha256Hash : bytes [32]
/// SHA-512 hash — 64 bytes
type Sha512Hash : bytes [64]
/// Generic cryptographic signature — 64 bytes
type Signature : bytes [64]
/// X.509 DER certificate
type Certificate : bytes [1..4096]
Appendix B — Full Example
A complete pure-typl package — veh/common/, profile-pure, generating types
across every backend with no interface content:
package veh.common
// ---------- Units and scalars ----------
/// Vehicle speed over ground
type Speed : km/h [0.0..MAX_SPEED step 0.5]
/// Coolant / ambient temperature
type Temperature : Cel [-40.0..125.0 step 0.1]
/// Engine crankshaft speed
type RPM : /min [0.0..8000.0 step 10.0]
/// Normalised ratio
type Ratio : % [0.0..100.0 step 0.1]
type Counter : integer [0..65535]
type Gain : float [0.0..1.0 step 0.01]
// ---------- Constants ----------
const MAX_SPEED : Speed = 250.0
const SPEED_LIMIT_EU : Speed = 130.0
const MAX_GEAR : integer = 6
const IDLE_RPM : RPM = 800.0
// ---------- Enums ----------
enum GearPosition {
PARK = 0
DRIVE = 1
REVERSE = 2
NEUTRAL = 3
}
enum Warning {
LOW_FUEL = 0
CHECK_ENGINE = 1
DOOR_OPEN = 2
SEATBELT = 3
}
enumset WarningFlags : Warning
// ---------- Composites ----------
struct SpeedLimitPayload {
limit : Speed
actual : Speed
}
struct DriverProfile {
name : Name // ridl.std
speed : Speed
override : Speed? // optional
gears : integer [0..MAX_GEAR] = 6
}
struct SensorBounds {
range : (min: Speed, max: Speed)
readings : [Speed; 8]
labels : [Label; 1..16]
meta : [Label : Name; 0..32]
}
union SensorResult { // result union — §10.2: one success arm, one error arm
ok : SensorReading
err : SensorFault
}
struct SensorReading {
value : Speed
timestamp : Timestamp // ridl.std
}
error struct SensorFault { // failure vocabulary — §10.1
code : Counter
message : Message // ridl.std
}
// package-private helper — not visible to importers, omitted from
// cross-package codegen artifacts
internal struct RawWheelFrame {
ticks : Counter
frame : bytes [8]
}
Appendix C — Standards References
| Standard | Used for |
|---|---|
| Unicode 15.0 | source encoding |
| RFC 3629 | UTF-8 |
| RFC 8259 | string escapes |
| ECMA-262 | regex literal syntax, restricted to the patterns the Rust regex crate also compiles (§2.7) |
| CommonMark | doc comment markdown |
| IEEE 754-2019 | floating point |
| ISO/IEC 9899:2018 (C17) | integer/float literal conventions |
| UCUM | physical unit expressions |
| ISO 8601 / RFC 3339 | time types |
| RFC 3986, RFC 4122 | Uri, Uuid |
| SemVer 2.0.0 | Version |
| ISO 3779, ISO 3166-1, ISO 639-1 | Vin, CountryCode, LanguageCode |
| JSON Schema 2020-12 | coverage benchmark (Appendix G) |
Appendix D — Codegen Targets
typl owns the type layer of every backend; interaction/behaviour codegen is specified by the higher profiles. Width mapping is resolved once by the compiler and passed to all backends (§4.2, §4.3, §9.3).
Language layer — always widest. The columns are the width rule per language, not a list of planned backends: Rust and TypeScript are built, Kotlin is an out-of-tree plugin, and C++ has no plan (ADR-0013 decision 1, as amended 2026-09-12).
| Canonical | Rust | Kotlin | C++ | TypeScript |
|---|---|---|---|---|
| any integer | i64 | Long | int64_t | bigint/number* |
| any float | f64 | Double | double | number |
| enumset | i64 | Long | int64_t | bigint |
*TypeScript uses number when the range fits in 2^53, bigint otherwise.
Transport layer — narrowest that safely fits:
| Canonical | proto3 | FlatBuffers | SOME/IP | CAN/DBC | AIDL |
|---|---|---|---|---|---|
uint8 | uint32* | uint8 | UINT8 | 8 bits | int |
uint16 | uint32* | uint16 | UINT16 | 16 bits | int |
uint32 | uint32 | uint32 | UINT32 | 32 bits | int |
int32 | sint32 | int32 | SINT32 | 32 bits signed | int |
int64 | sint64 | int64 | SINT64 | 64 bits signed | long |
float32 | float | float32 | FLOAT | 32 bits | float |
float64 | double | float64 | FLOAT64 | 64 bits | double |
*proto3 has no uint8/uint16 — varint encoding keeps small values small on
the wire.
Per-Target Compatibility Notes
proto3. Unsigned 8/16-bit map to uint32 varint — no wire penalty for small
values. Ranges containing negatives map to sint32/sint64 (ZigZag encoding);
plain int32 varint would cost 10 bytes for every negative value. Field numbers
are the typl ordinals (§7.4); reserved tombstones emit proto reserved
statements. Range widening never breaks proto wire (varint absorbs it) but
remains a contract-breaking change per ridl-diff.
FlatBuffers. Full uint8..uint64 palette — the cleanest width mapping.
Field ids are the typl ordinals emitted as (id: N) attributes. FlatBuffers is
the most width-brittle target: any resolved-width change is a hard wire break —
the primary motivation for the wire floor deferred to v0.2 (§17.11); until it
lands the ridl-diff gate is the sole guard, and it is also a layout break
rather than only a wire break (§5.6). A struct whose fields are all fixed-width
and non-optional may be emitted as a FlatBuffers struct (inline, zero
indirection) instead of a table; the IR carries a fixed_layout flag for
this. The built FlatBuffers projection does not take that allowance
(ADR-0019 decision 3):
a FlatBuffers struct has a fixed inline layout and no vtable, so after a
compatible field append a reader with the newer schema reads past what an older
writer wrote and returns the appended field fabricated from padding, where a
table reports it absent. Every struct is therefore emitted as a table. The
fixed_layout flag stays in the IR for a target where a fixed layout is safe.
AUTOSAR Classic (ARXML). The best structural fit: range → DataConstr,
quantized float → LINEAR CompuMethod (factor = step, offset = min) — the
scaled-integer wire form (§4.3) is native here. Two gaps become profile
rules: Classic sender-receiver records have no optional fields — a struct
containing ? bound to a Classic/CAN transport is a codegen error, never
silently default-filled; and Classic has no native tagged union — a union on a
Classic-bound payload is a codegen error in v0.1 (a generated tag + padded-body
convention is a possible later opt-in). A normative UCUM → AUTOSAR unit /
physical-dimension mapping table is required and does not yet exist — open
item.
CAN / DBC. Signals are scaled integers by construction — factor, offset, min, max, and bit length all derive from range + step. Strings, bytes, maps, and anything unbounded cannot bind to CAN; bounded arrays flatten to indexed signals. Optionality does not exist on CAN — same profile rule as Classic.
SOME/IP. Positional serialization — ordinals matter only as declaration
order, which §7.4 freezes anyway. Width flips shift the byte layout: breaking,
caught by ridl-diff. Quantized-float-as-scaled-integer is available as a
per-deployment choice (an rsdl concern, not a type property).
Schema targets. Because typl is a schema language, schema languages are
themselves backends: JSON Schema 2020-12 (each named type → $defs entry;
ranges → minimum/maximum, step → multipleOf, match → pattern, structs →
closed objects with additionalProperties: false), OpenAPI/AsyncAPI component
schemas, ARXML (<AR-PACKAGE> chain, CompuMethods from unit types),
DBC (scaling/offset derived from range and step). The JSON Schema emitter is
the round-trip check on Appendix G.
Appendix E — Formal Grammar (EBNF)
The typl profile grammar — the restriction of the family grammar accepted in
.typl files. Family constructs not derivable here (interactions, timing,
streams, behaviour, architecture) are rejected with TYPL-3xx.
(* Top-level *)
file = package { import } { definition } ;
package = "package" qualified_id ;
import = "import" qualified_id [ "as" id ] ;
(* no wildcards, no relative paths, no re-exports — ADR-0002 *)
definition = [ "internal" ] [ "error" ] ( type_def | const_def | struct_def
| enum_def | enumset_def | union_def ) ;
(* "error" valid on struct_def, enum_def, union_def only — §10.1 *)
(* ---------- Type ---------- *)
type_def = doc_comment? "type" CamelCase_id ":" type_backing constraint? init_value? ;
init_value = "=" literal ; (* declared init value, bare — §5.8 *)
type_backing = "integer" | "float" | "string" | "bytes" | "boolean" | ucum_unit ;
ucum_unit = (* UCUM expression: km/h, Cel, N.m, /min, %, bar, V, A ... *) ;
constraint = "[" constraint_spec "]" ;
constraint_spec
= scalar ".." scalar ( "step" scalar )?
| scalar ".."
| ".." scalar
| int_lit
| int_lit ".." int_lit
| int_lit "match" pattern
| int_lit ".." int_lit "match" pattern
| "match" pattern
;
pattern = regex_lit | SCREAMING_SNAKE_ID ;
scalar = int_lit | float_lit | SCREAMING_SNAKE_ID ; (* constants usable as bounds *)
(* ---------- Constants ---------- *)
const_def = doc_comment? "const" SCREAMING_SNAKE_ID ( ":" type_ref )? "="
( literal | regex_lit ) ;
(* ---------- Struct ---------- *)
struct_def = doc_comment? "struct" CamelCase_id "{" { ( field | reserved ) sep? } "}" ;
field = doc_comment? camelCase_id ":" field_type init_value? ;
reserved = "reserved" ( camelCase_id | int_lit ) ; (* tombstone — §7.4 *)
field_type = "boolean"
| ( "integer" | "float" ) constraint? (* inline constrained primitive;
string/bytes need a named type — §15.3 *)
| type_ref
| tuple_type
| field_type "?"
| "[" field_type ";" bound "]" (* array *)
| "[" key_type ":" field_type ";" bound "]" (* map *)
;
bound = int_lit | int_lit ".." int_lit ;
key_type = type_ref | "string" | "integer" ;
tuple_type = "(" named_field { "," named_field } ")" ;
named_field = camelCase_id ":" field_type ;
type_ref = qualified_id ; (* Type or pkg.Type *)
(* ---------- Enum ---------- *)
enum_def = doc_comment? "enum" CamelCase_id "{" { ( enum_value | reserved ) sep? } "}" ;
enum_value = doc_comment? SCREAMING_SNAKE_ID "=" int_lit ;
(* ---------- EnumSet ---------- *)
enumset_def = doc_comment? "enumset" CamelCase_id "{" { enumset_bit sep? } "}"
| doc_comment? "enumset" CamelCase_id ":" type_ref
;
enumset_bit = doc_comment? SCREAMING_SNAKE_ID "=" int_lit ;
(* ---------- Union ---------- *)
union_def = doc_comment? "union" CamelCase_id "{" { ( union_arm | reserved ) sep? } "}" ;
union_arm = doc_comment? camelCase_id ":" type_ref ;
(* ---------- Doc Comments ---------- *)
doc_comment = "/**" { doc_tag | markdown_text } "*/"
| { "///" markdown_text newline } ;
doc_tag = "@see" qualified_id
| "@since" version
| "@labels" label { "," label }
| "@deprecated" string_lit ;
version = int_lit "." int_lit [ "." int_lit ] ;
label = SCREAMING_SNAKE_ID [ "(" SCREAMING_SNAKE_ID ")" ] ;
(* ---------- Separators, Identifiers, Literals ---------- *)
sep = "," | newline ;
qualified_id = id { "." id } ;
id = CamelCase_id | camelCase_id ;
CamelCase_id = [A-Z][a-zA-Z0-9]* ;
camelCase_id = [a-z][a-zA-Z0-9]* ;
SCREAMING_SNAKE_ID = [A-Z][A-Z0-9_]* ;
int_lit = "-"? [0-9]+ ;
float_lit = "-"? [0-9]+ "." [0-9]+ ;
string_lit = '"' { utf8_char } '"' ;
regex_lit = "/" { regex_char } "/" ;
bool_lit = "true" | "false" ;
literal = int_lit | float_lit | string_lit | bool_lit ;
Appendix F — Prior Work: markspec typl
The first typl was drafted as a MarkSpec extension — a Type Specification DSL
binding $Name identifiers inside requirements entries to a kind and a shape
(markspec typl spec).
That work validated the core idea (a small, closed, range-first shape grammar
with a coded diagnostic catalogue) in a live toolchain (parser, LSP
hover/completion, JSON compile output with a corpus-level typeRegistry).
MarkSpec also carries uxil, the precursor of uxdl and rxdl — relevant to the
rxdl vocabulary workshop, not to this document.
This specification is the successor, not a superset. The mapping:
| markspec typl | family typl (this spec) | Disposition |
|---|---|---|
$Name : kind shape bindings with sigil | named declarations in packages (type, const, struct, …) | sigil dropped — names live in the module system, not in prose |
kind vocabulary: signal, event, command, stream | not typl — ridl's interaction keywords over the interact core | moved up a layer |
kind state | not typl — rmdl territory | moved up a layer |
kind config | ridl fixed (provisioned constant) | moved up a layer — fixed confirmed over config: it names the consumer contract (immutable, cacheable), not the provider workflow; see concept-note naming ledger |
kind const | const | kept |
kind value (default) | type / struct field | kept, made explicit |
kind document | struct | folded in |
kind namespace + $. relative refs + published tier | package / import (ADR-0002 ns core) | superseded — packages give declared-once, absolute citation, and ownership corpus-wide |
int[0..300], float[0..1.0] postfix ranges | integer [0..300], float [0.0..1.0 step s] | kept in spirit; family name : Type [constraint] syntax; step added |
string[3..6], bytes[32] length shapes | string [3..6], bytes [32] | kept |
pattern /re/ shape | match /re/ inside the string constraint | merged into the constraint; named regex constants added |
'low' | 'mid' | 'high' literal-union enums | enum (integer-backed) | not carried over — string enums are planned for v0.2 (§17.1) |
{ field: shape } records | struct | kept; structs are named and closed |
element[](min..max) arrays | [T; min..max] | kept; bounds made mandatory |
shape? optional | field : T? | kept |
typedef type Name = shape | type Name : backing [constraint] (scalars) / named composites | split by composite kind |
| entry-local scope tier | not applicable — no prose entries; packages are the only scope | superseded |
| TYPL-001…012 diagnostic catalogue | TYPL-0xx…4xx coded catalogue (§16) | practice adopted, codes renumbered by area |
| four Markdown surfaces (fence, bullet, inline, table) | not adopted — typl lives in .typl files | MarkSpec remains a consumer: a requirements corpus can cite typl symbols, and a future markspec bridge can validate ```ridl fences against a workspace |
What survives unchanged in spirit: closed vocabularies over extensible ones, ranges as first-class shape (not an afterthought annotation), a small shape grammar a reader can hold in their head, and machine-checked diagnostics with stable codes.
Appendix G — Coverage Analysis: JSON Schema and Other Schema Languages
G.1 Method
typl claims to be a schema language, so the benchmark is: can typl express what a contract author reaches for in JSON Schema 2020-12 — the latest published dialect (the in-progress "stable" rewrite does not change the validation vocabulary materially)? The matrix covers the entire 2020-12 validation and annotation vocabulary. ✓ covered, ≈ covered differently (usually stricter), ✗ not expressible (deliberate or open).
G.2 JSON Schema 2020-12 coverage matrix
| JSON Schema keyword | typl equivalent | Status |
|---|---|---|
type: boolean/integer/number/string | boolean, integer, float, string | ✓ |
type: null / nullable | ? optionality (absence; typl has no null value) | ≈ stricter |
type: array | [T; N], [T; min..max] | ✓ bounds mandatory |
type: object (free-form) | struct (closed) or [K:V; min..max] map | ≈ stricter |
minimum / maximum | [min..max] | ✓ |
exclusiveMinimum / exclusiveMaximum | — | ✗ deliberate — not supported and not planned (§5.5) |
multipleOf | step (quantization anchored at range min) | ✓ |
minLength / maxLength | string [min..max] | ✓ |
pattern | match /re/ or named regex const | ✓ + reusable named patterns |
format (uuid, email, uri, ipv4, date, …) | ridl.std named types (Uuid, Email, Uri, IpV4, Date, …) | ✓ and normative (JSON Schema format is annotation-only by default) |
contentEncoding / contentMediaType | bytes + doc comment | ≈ partial |
minItems / maxItems | [T; min..max] | ✓ mandatory |
uniqueItems | — | ✗ v0.2 (§17.3) — an array constraint, not a set container |
prefixItems (positional tuple) | named tuples (a: T, b: U) | ≈ named, not positional — deliberate |
contains / minContains / maxContains | — | ✗ deliberate (validation logic, not shape) |
properties + required | struct fields; optional via ? (required is the default) | ✓ inverted default, stricter |
additionalProperties: false | implicit — structs are closed | ✓ by construction |
additionalProperties: <schema> / patternProperties | bounded map [K:V; min..max] with match-constrained key type | ≈ |
propertyNames | map key = named string type with match | ✓ |
minProperties / maxProperties | map bounds | ✓ |
dependentRequired / dependentSchemas / if/then/else | — | ✗ deliberate: conditional shape is undecidable for width inference and hostile to certification; cross-field invariants belong to expr contracts in higher profiles (§17.7) |
enum (numeric) | enum | ✓ |
enum (string literals) | — | ✗ v0.2 (§17.1) — integer wire form with a declared rendering |
const (fixed value in schema position) | const declarations; fixed-value field via [N] exact forms | ≈ |
oneOf (discriminated) | union (tagged) | ✓ stricter — tag is structural |
anyOf / allOf / not | — | ✗ deliberate: open-world combinators break the faithful-codegen guarantee (no clean proto/FlatBuffers/DBC mapping) |
unevaluatedItems / unevaluatedProperties | irrelevant — structs closed, arrays homogeneous by construction | ✓ by construction |
$ref / $defs | named types + qualified imports | ✓ stronger: versioned, package-scoped, cycle-checked |
$id / $anchor / $dynamicRef | package + name is the identity | ≈ superseded by module system |
| recursive schemas | — | ✗ deliberate (§7.3): unbounded wire size |
title / description | doc comments | ✓ |
default | bare = v (init value, §5.8) | ✓ type-checked (JSON Schema does not validate default) |
deprecated | @deprecated "reason" | ✓ reason mandatory in practice |
examples | — (doc-comment code blocks) | ≈ |
readOnly / writeOnly | not a type property — direction lives on interactions (ridl fixed, signal direction) | ≈ relocated by design |
vocabularies / meta-schemas / $schema | one closed grammar + label profiles | ✗ deliberate — no user-extensible validation vocabulary |
Verdict. typl covers the JSON Schema constructs that describe data shape —
everything a codegen target can faithfully realise — and adds what JSON Schema
lacks: physical units, quantization (step as more than multipleOf — it
drives width inference), wire-width inference, bitfields (enumset), a real
module system with declared-once semantics, and type-checked init values. What
it refuses is JSON Schema's validation-logic stratum (conditionals,
combinators, contains, unevaluated*) — deliberately, because those constructs
have no faithful mapping onto proto/FlatBuffers/CAN and defeat static width/size
guarantees. Of the four gaps this appendix once tracked as open, two are now
closed by decision — exclusive bounds (§5.5) and recursion (§7.3) — and two are
planned for v0.2: string enums (§17.1) and uniqueItems as an array constraint
(§17.3).
G.3 Other prior art consulted
| Language | What typl takes / how it differs |
|---|---|
| ASN.1 (X.680) with constraints | the closest ancestor in spirit: subtype constraints (INTEGER (0..255)) driving encoding width (PER) is exactly typl's range→width inference. typl trades ASN.1's enormous surface for a closed 6-declaration grammar |
| CDDL (RFC 8610) | compact shape DSL over CBOR; ranges and .size/.regexp control operators parallel typl constraints; CDDL allows unbounded recursion — typl does not |
| XSD | facets (minInclusive, pattern, length) map to typl constraints; typl rejects XSD's inheritance/extension model in favour of composition |
| Protocol Buffers | the codegen contract baseline; typl is deliberately richer (ranges, units, bounds) precisely where proto is silent, and its emitter targets proto3 (Appendix D) |
| FlatBuffers / Avro / Thrift | fixed-width scalars and required bounds validate typl's transport-layer mapping; Avro's named-types + namespace model parallels the package system |
| Franca IDL / AUTOSAR ARXML | automotive precedent: ARXML CompuMethods and data constraints are the target of typl's unit types and ranges; Franca's typeCollection ≈ a pure typl package |
| DBC | signal scaling/offset/min/max are derivable from unit type + range + step — the rest-bus generation path |
| TypeSpec (Microsoft) | a modern "one schema source, many emitters" language — same architecture as typl→IR→backends; TypeSpec chooses an open decorator/extensibility model where typl chooses a closed grammar with label profiles |
| CUE | values-as-types and lattice unification are powerful but undecidable for width inference; typl keeps constraints first-class but static |
| Zod / valibot | runtime-validator ergonomics (.min(), .regex(), branded types) confirm the demand for constraint-first typing; typl moves the same checks to compile time and codegen |
Appendix H — Glossary
| Term | Definition |
|---|---|
| RIDL (capitals) | the platform and family name; typl is this language, the family's vocabulary layer and root of the lattice |
| family | the four languages — typl, ridl, rmdl, rsdl — sharing one grammar, one toolchain, one IR; rxdl is a profile and a spelling layer over ridl, not a fifth language |
| profile | the restriction of the family grammar accepted by a file extension; .typl accepts type declarations only; .rxdl is the total profile accepting every layer |
| profile purity | the ridl.toml policy restricting a package to one profile's declarations — enforced per package, not by the grammar |
| core | a reusable semantic unit beneath the surface languages: ns (namespacing), typl-core (types), expr (predicates), time (timing), interact (interactions) |
| vocabulary layer | typl's role: the types, units, ranges, constants, and composites every other layer describes things with |
| package | the unit of namespace, visibility, versioning, and codegen — a directory whose files share one package declaration (ADR-0002) |
| workspace | a coordinated set of packages with one lockfile and one [workspace] manifest |
| manifest / lockfile | ridl.toml (declares imports, profile purity, defaults) / ridl.lock (pins remote imports by content hash) |
| primitive | one of the five built-in scalars: boolean, integer, float, string, bytes — backing material, restricted as direct field types |
| named type | a type declaration: a constrained primitive or unit type carrying domain meaning; the normal building block for fields |
| backing | the primitive (or UCUM unit, itself float-backed) underneath a named type |
| unit type | a named type whose backing is a UCUM unit expression (km/h, Cel) — carries dimensional identity into every layer |
| UCUM | Unified Code for Units of Measure — the unit vocabulary standard (also used by OpenTelemetry, HL7 FHIR) |
| constraint | the [ ] clause on a type: range, step, length, match — closed-form by design (no general expressions, §17.7) |
| range | closed (inclusive) [min..max] bounds; the semantic truth from which wire width is inferred |
| step | quantization: valid values are min + n·step; drives float width inference and the scaled-integer wire form |
| quantized float | a float with range + step — losslessly representable as a scaled integer (raw = (value − min)/step), the CAN/AUTOSAR wire form |
match | the constraint keyword binding a string type to a regex pattern (named const or inline literal) |
| width inference | the compiler deriving the narrowest safe wire type from a declared range/step — computed once, passed to all backends |
| language layer | the generated in-code representation: always widest (int64/float64) so arithmetic cannot overflow storage |
| transport layer | the on-wire representation: narrowest width the range provably fits |
| wire type | a concrete transport width (uint8 … float64); inferred from the range and never written in source — the names are not typl keywords (§5.6) |
| width flip | the evolution hazard where widening a range crosses a width boundary and silently changes the wire type — caught by ridl-diff; a source-level pre-emption (a deferred wire floor) is §17.11 |
| nominal typing | §5.7: every named type is distinct from its backing and from all other types; no implicit conversion — the foundation of unit safety |
| struct | a named composite with a fixed, closed set of named fields — no unknown properties, ever |
optional (?) | a field that may be absent (typl has no null); inverted from JSON Schema — presence is the default |
| init value | the value a consumer holds before anything real arrives — declared via a bare = value suffix or derived per §5.8; the seed of every ridl signal channel. Carries no keyword at the typl/ridl layer (default retired; init is rmdl's memory-seed keyword only) |
| enum / enumset | closed set of integer-backed values (single selection) / named bitfield (multiple simultaneous flags, width from highest bit) |
| union | discriminated (tagged) composite — exactly one named-type arm active; never untagged |
| error type | an enum, struct, or union carrying the error modifier — failure vocabulary; ordinary data with one extra bit of meaning |
| result union | a two-arm union of one success arm + one error-typed arm — the family's Result<T, E> without generics; the entire functional-error mechanism |
| tuple | anonymous composite of named fields used inline; () is the unit type |
| bounded | the invariant that every typl composite has a statically known maximum wire size — why collections require bounds and recursion is an error |
| ordinal | a field's/arm's implicit 1-based declaration-order identity — the source of proto field numbers and FlatBuffers ids (§7.4) |
reserved | tombstone keeping a retired field's ordinal (or enum wire value) occupied so it is never reused |
| append-only | the evolution discipline: new fields at the end, deletions by tombstone, reorder = wire break caught by ridl-diff |
| one-definition rule | a public name is declared exactly once, in exactly one package, importable everywhere — no re-exports, no wildcards |
internal | package-private visibility; the opt-out from public-by-default |
| diagnostic code | a stable TYPL-nnn identifier for a compiler rule — never renumbered, never reused (practice inherited from markspec typl) |
| IR | the stable serialized intermediate representation — resolved names, checked types with ranges/units/widths intact — consumed by every backend and tool |
ridlc / ridl / ridl-diff | the compiler (plumbing, pure source→IR) / the toolchain facade (porcelain) / the IR-comparison breaking-change gate (plumbing-grade despite living in the facade) |
| profile (assurance) | an external plug-in validating @labels vocabulary and escalating optional rules (explicit ranges/bounds) to errors — distinct from grammar profile |
| SSOT | single source of truth — one vocabulary from which types, validators, schemas, and documentation derive across every backend |
End of typl Language Reference v0.1.0.
Built. ridl has a working toolchain in this repository: the five interaction kinds, timing annotations, contracts, interfaces and services, with Rust and TypeScript code generation and
ridl diff. There is no runtime you can run a contract over from a command in this book — of the transport bindings below, the WebSocket one is implemented byridl-transport-ws, which a program embeds; the others and their delivery semantics are specified and not implemented, and the provider-side contract enforcement that is implemented is not reachable from any command. See what is built.
ridl Language Reference
Reactive Interface Description Language — the system-interaction layer of
the RIDL family: transport-neutral reactive contracts (signal · event ·
command · query · fixed) over the typl vocabulary.
Version: 0.2.0
Provenance and supersession. This document supersedes the interaction half of the RIDL Language Reference v0.1 (§12 Streams, §13 Interfaces, and the interaction rows of §16–§17). The vocabulary half (§1–§11) is now the typl Language Reference, which this document builds on and never restates. v0.1 errata applied here: wildcard imports are an error (ADR-0002), no semicolons, float width inference is count-based (typl §4.3). New in v0.2, from the prior-art review (Appendix F): the error model (§10), the last-value subscription guarantee (§4.4), and interaction identity & evolution (§11).
Reconciled with the shipped toolchain at the close-out of the interface layer (ADR-0008 decisions 1 and 16 to 21). Four supersessions from general form §6 are now absorbed rather than pending: inline
T | Ereturns (§7, §10.1, Appendix A, Appendix C), genericmin/maxtiming with the per-kind table as a derivation (§4.3, §5.2, §9), ordinal drift reported at the desk (RIDL-407), and the Stratum-3 wording "infrastructure failure — detected, undeclared" (§10.3). §2.1's duration table, §9.2's@[X..X]rule, §16.1's RIDL-108 and RIDL-110 rows, and Appendix C's grammar are corrected to the compiler.
Table of Contents
- Scope and Position in the Family
- Lexical Additions
- The Interaction Model — families (§3.2), correspondence obligations (§3.3), availability (§3.4)
- Signal
- Event
- Command
- Query
- Fixed
- Timing Annotations
- Errors
- Interaction Identity and Evolution
- Streams
- Contracts — require / ensure
- Interfaces and Services
- Conventions
- Diagnostics
- Open Questions
- Appendix A — Full Example
- Appendix B — Codegen Targets
- Appendix C — Formal Grammar (EBNF)
- Appendix D — Standards References
- Appendix E — Prior Art Survey
- Appendix F — Coverage Analysis: Other Interface Languages
- Appendix G — Glossary
1. Scope and Position in the Family
1.1 What ridl is
ridl answers one question: what does a service produce, consume, and
guarantee? It is the single source of truth for the contract boundary of a
reactive system, and it is transport-neutral by design — the same .ridl
contract binds to SOME/IP, proto/gRPC, DBC, AIDL, MQTT/AsyncAPI, or an
in-process broker without modification.
ridl is a profile of the one family grammar. A .ridl file accepts
interaction declarations plus everything typl accepts — types and their
interfaces naturally travel together; profile purity per package remains a
ridl.toml policy (typl §1.2). What .ridl rejects: behaviour declarations
(rmdl), architecture declarations (rsdl).
1.2 What ridl adds to typl
Exactly four things, each owned by a family core:
| Addition | Core | Surface |
|---|---|---|
| interaction kinds | interact | interface, signal, event, command, query, fixed |
| timing | time | @Xms, @[min..max], duration literals |
| contracts | expr | require, ensure attribute clauses |
| stream container | — (ridl-owned) | <T> in interaction position |
Everything else — types, units, ranges, constants, composites, packages, visibility, doc comments, diagnostic-code practice — is typl, unchanged.
Doc comments follow typl §14 in every ridl file. ridl adds carriers: an
interface, a service, each interaction, and each parameter of a command or
query. An interface's doc states the interface's responsibility and does not
list its members; each interaction is documented on its own line, and
missing-docs (TYPL-406) covers the interface and each interaction. A parameter
doc is allowed and never required, because a parameter's type is a named type
with its own doc (typl §14.4). A doc link can name an interaction as a member,
[CruiseControl.setLever]. A doc comment before a return type, before an arm of
an inline T | E return, or before an attribute block is misplaced (TYPL-407).
1.3 ridl describes every boundary
The five interaction kinds are named, typed, directed interactions on a contract
boundary — any boundary. ADR-0012 retired uxdl as a separate language
and gave ridl an interaction family on every declaration: dispatch (system
to system), presentation and intent (the person boundary), acquisition and
control (the physical boundary). Only dispatch carries no correspondence
obligation, because there the datum and its referent are the same thing.
The kinds below are family-neutral: a signal is a continuous state value
whoever reads it. What a non-dispatch family adds is the four correspondence
obligations, not a different kind. Readable per-family spellings — present,
measure, actuate and the rest — are the rxdl reference's, and add no
semantics to what is defined here.
2. Lexical Additions
ridl inherits the family lexical conventions (typl §2) and activates two token classes typl rejects:
2.1 Duration Literals
A positive integer followed by a time-unit suffix. Used exclusively in timing annotations and time-typed contract expressions. The five suffixes below are the complete set:
| Suffix | Unit | Example | Microseconds |
|---|---|---|---|
us | microseconds | 500us | 1 |
ms | milliseconds | 10ms | 1 000 |
s | seconds | 1s | 1 000 000 |
min | minutes | 5min | 60 000 000 |
h | hours | 1h | 3 600 000 000 |
The suffixes are UCUM time atoms, but they are a proper subset of the UCUM
atom table typl uses for unit types (typl §5.1): d is a unit atom there and is
not a duration suffix here, so @[1min..1d] is a parse error.
Zero duration is not permitted (RIDL-102). Fractions are not supported — use a
smaller unit (500us, not 0.5ms); a fractional literal is FORM-102.
2.2 The @ Sigil
@ introduces a timing annotation and is used for nothing else in the family.
2.3 Keywords
Keywords used by the ridl profile, beyond typl's set:
interface service signal event command query fixed
interface names the abstract contract shape; service names a global
published declaration of one or more of them (§14). Both are ridl's — the
contract SSOT — while offering services and requiring interfaces is rsdl's.
A signal's init override carries no keyword: it is a bare = value suffix
(§4.4), the same idiom typl uses for a type or field init (typl §5.8). There is
no init keyword in ridl — init belongs to rmdl alone, where init x = value
seeds a memory recurrence and the keyword is needed to disambiguate it from the
equation.
There is deliberately no error keyword: functional errors are typl
vocabulary (error types, result unions — typl §10.1–10.2), not interaction
syntax (§10.1).
(reserved and the evolution machinery are inherited from typl §7.4.
require/ensure are expr-core words activated here. Every registry word
remains reserved in every profile, and the registry as built is the list in typl
§1.4. The ADR-0012 domain spellings — present, trigger and the rest — are
not in it: each is a keyword only as the first token of an interface member, in
a profile that implements it, and an identifier everywhere else — the
member-start rule, typl §1.4.)
3. The Interaction Model
Five interaction kinds, each a distinct answer to "who initiates, and what does delivery mean":
| Keyword | Pattern | Semantic | Initiator |
|---|---|---|---|
signal | pub/sub | continuous state value — the latest sample matters | provider publishes |
event | pub/sub | discrete occurrence — every occurrence matters | provider publishes |
command | RPC | fire-and-forget action — no reply channel | consumer calls |
query | RPC | request/response — reply mandatory | consumer calls |
fixed | provisioned | immutable for the software-instance lifetime | neither (provisioned) |
The signal/event split is the load-bearing distinction, borrowed from the
automotive tradition (AUTOSAR isQueued): state may be sampled, coalesced,
and cached — missing an intermediate value is acceptable; occurrences are
queued and each one is meaningful. Everything else in this document (timing
semantics, late-joiner behaviour, error handling) follows from which side of
that line an interaction sits on.
Payloads are always named typl types — the interaction layer never defines shapes, it references vocabulary. This is the family's anti-coupling rule (concept note §7.1).
3.1 The Implicit Envelope
Every interaction instance — a signal publication, an event occurrence, a command call, a query request and reply — carries an envelope supplied by the runtime and never declared in the contract:
| Envelope field | Meaning |
|---|---|
| timestamp | when the instance was published/raised/called — stamped by the sender, at the producing binding, before transmission |
| sequence number | monotonic counter (frame number) assigned by the sender: per channel per provider instance on a signal or an event; on a command or a query, per caller over every request it sends, and per provider over its responses (frame specification §7) |
Timestamping is a sender-side act, always: no broker, relay, or receiver ever (re)stamps the envelope. Under the synchronized time base (below) this is what makes the semantics right — an event's TTL measures age since it was raised, not since it was forwarded; freshness measures the provider's publication, not the broker's hop; and one-way latency is simply receive-side clock minus envelope timestamp. Receive time is a local runtime observable, never envelope data.
The envelope is what the machinery runs on: TTL, debounce, and freshness (§9)
are evaluated on envelope timestamps; duplicate suppression of a retried call
(§6.1) on the caller's identity plus the request's sequence number, so two
callers are never merged under one sequence number, and a retry resends the
request with its envelope unchanged
(frame specification §7); a sequence gap makes loss
detectable on an event channel (a Stratum 3 detection, feeding §10.4
management) — a gap on a signal channel is not a loss, because intermediate
samples may be missed (§4); seq 0 is the envelope of a signal channel that has
had no publication; AUTOSAR E2E protection consumes the counter; deterministic
replay (concept note §9.3) is ordered by it; observability spans derive from
both. Generated subscriber/caller APIs expose the envelope alongside the value
(value + provenance + envelope); request/reply correlation is likewise
runtime-internal.
System time. The platform assumes one synchronized time base across the system — gPTP/PTP (IEEE 802.1AS in vehicle networks) or an equivalent shared realtime clock. Envelope timestamps are stamped in that domain and are therefore comparable system-wide: cross-node latency, end-to-end freshness, and global event ordering are meaningful by assumption, not by luck. The concrete mechanism and sync topology are deployment configuration, which rsdl does not declare (rsdl §3.4); that a synchronized base exists is a platform assumption every contract may rely on. Loss of synchronization is a detectable system failure like any other — surfaced by the runtime, handled by failure management (§10.4), never silently absorbed.
Epoch. Platform time is int64 microseconds since the PTP epoch —
1970-01-01 00:00:00 TAI — the PTP timescale: continuous, leap-second-free,
monotonic. This is the domain of every envelope timestamp, and ridl.std's
Timestamp type shares the same epoch and resolution, so domain time in
payloads and transport time in envelopes subtract cleanly. Civil datetime
(UTC/local, leap seconds, time zones) is a presentation conversion at the
edges — never the computation domain.
Because the envelope always exists, contract payloads should not re-declare
it: a payload field carrying publication time or a frame counter draws an info
lint (RIDL-406; §16.4 names the eight field spellings it matches). The
legitimate exception is domain time distinct from transport time — a
FaultEvent.timestamp recording when the fault occurred belongs in the
payload, because the envelope of a streamed history reply timestamps delivery,
not occurrence.
3.2 Interaction Families
Every interaction declares a family — who is on the other side of the boundary. The counterparty set is closed, because there are only three kinds of thing a system can talk to: another piece of software, a person, the physical world (ADR-0012 decision 2).
| Family | Direction | The provider's promise |
|---|---|---|
| dispatch | system → system | transfer, nothing interpreted |
| presentation | system → person | I offer this to be perceived |
| intent | person → system | I capture what you meant |
| acquisition | world → system | I report what is |
| control | system → world | I cause this to happen |
dispatch is the default and the baseline: an interaction that declares no
family is dispatch, and everything in §4 through §14 reads unchanged for it.
The other four are where the datum and the thing it stands for come apart, and
they carry the obligations of §3.3.
The family is a property of the declaration, not of the file, the package,
or the deployment, and it is selected by the declaring keyword — there is no
family clause. signal, event, command, and query are dispatch, so a
.ridl file declares nothing else. The non-dispatch spellings belong to the
rxdl reference and lower to these same kinds carrying a family; this document
owns what a family means and every rule one can violate, and rxdl owns only
the words. The family reaches the IR as a closed enum alongside the kind.
The agent asymmetry
Two families are epistemic (presentation, acquisition) and two volitional (intent, control), and the agent swaps sides: at the person boundary the person is the agent and the system informs; at the world boundary the system is the agent and the world informs.
Every structural rule below follows from that one fact, so it is worth stating plainly rather than deriving each rule separately.
Which kinds each family admits
| Family | continuous (signal) | occurrence (event) | operation (command) | request/response (query) |
|---|---|---|---|---|
| dispatch | ✓ | ✓ | ✓ | ✓ |
| presentation | ✓ | ✓ | — | — |
| intent | — | ✓ | ✓ — a shape (§3.2.1) | — |
| acquisition | ✓ | ✓ | — | reserved |
| control | ✓ | — | ✓ | — |
A combination outside this table is RIDL-501. Each absence is a consequence of the agent asymmetry, not an unfilled gap:
- presentation admits no operation. Nothing can be invoked on an agent. A provider may present, and the person may then act; it may not call them.
- intent admits no continuous value. A person's continuous state is knowable
only by measuring a physical proxy, and measuring a physical proxy is
acquisition. An accelerator pedal is an acquisition signal, not an intent one: there is no interpretation gap between a foot's position and its meaning, and all four obligations instantiate exactly as they do for a wheel-speed sensor. A person therefore appears at two boundaries — as an agent that means things, and as a physical object that is measured. - intent admits no query. A provider cannot block on a human. A confirmation is a presentation followed by an operation — two interactions, not a request/response.
- acquisition admits no operation. Commanding a device to recalibrate acts
on the world, so it is
control. - control admits no occurrence. Nothing leaves a system uninvited.
- control admits no query. Effect is never returned; it is observed by measuring back, which is a separate acquisition interaction paired with the control one (§3.3).
The reserved acquisition query is the explicit one-time read — see §17.10.
3.2.1 Operation shapes
An intent operation declares a shape, which constrains its parameters:
| Shape | Meaning | Parameters |
|---|---|---|
activate | invoke | none |
toggle | flip a binary state | none |
select | choose one from a set | exactly one — the key |
adjust | set a value within a range | exactly one — a ranged/unit type |
dismiss | cancel or close a transient | none |
Violating a shape's parameter rule is RIDL-502.
The set is closed and has no generic form. A person performs one act at a
time; a multi-parameter operation is a function call, not a gesture. Sending a
message is an occurrence carrying the body, a select of the recipient, and an
activate — three interactions, each separately observable and testable.
Repositioning an item, stripped of modality, is select then adjust.
An intent operation without a shape is RIDL-503. The world boundary has no
such restriction: a control operation takes whatever parameters it needs,
because nothing there is an agent constrained to single gestures.
Shapes are machine-readable gesture semantics. They drive interface scaffolding, accessibility roles, and the test plane's parameter generation — which is what earns them keywords rather than an attribute, and what a generic escape hatch would quietly destroy.
3.3 Correspondence Obligations
Between two software peers the datum is the truth: both sides agree on a number and there is nothing behind it. At every boundary with the non-software world, the datum and its referent come apart.
The canonical case is a speedometer. A vehicle's true speed and the number on
its cluster are deliberately different quantities: the indication is legally
constrained never to read below the true value, is quantised with hysteresis, is
damped, and is unit-switched per market. Declaring both as a plain signal
erases the distinction that matters, and the wrong one eventually reaches the
cluster.
Direction rule. In every non-dispatch family one side causes the other. The causally upstream side is the reference; the downstream side is the realisation; the obligation is that the realisation corresponds to the reference within declared bounds.
| Family | Reference | Realisation |
|---|---|---|
| presentation | the provider's value | what the person perceives |
| intent | what the person meant | the captured value |
| acquisition | the physical quantity | the reported value |
| control | the commanded value | the resulting world state |
The four obligations
| Obligation | Presentation | Acquisition | Control |
|---|---|---|---|
| relationship | never under-reads; quantised | transfer function; calibration | authority limits; slew rate |
| uncertainty | display resolution | tolerance per reading | positioning tolerance |
| latency of correspondence | perception delay | sample instant ≠ publication instant | actuation lag |
| failure to correspond | shown but not perceivable | plausible but false | commanded but not achieved |
Two are load-bearing enough to state separately.
Latency of correspondence is not delivery latency. The envelope is sender-stamped at publication (§3.1). For a transducer with a response time, the value describes the world as it was before that stamp, so any model computing with the time of the cause is silently wrong by the transducer's lag unless the contract declares it. The obligation has two forms: an instant offset, and a span for a value acquired over an interval rather than at a point — a swept sensor frame corresponds to the world across its sweep, which is why motion distortion exists in such frames.
Failure to correspond is not invalidity. §4.5's invalid state detects a malformed or unavailable value. A sensor can report a well-formed, in-range, perfectly fresh value that is false — a wheel spinning on ice. Nothing in the datum reveals it, which is why plausibility must be declared rather than inferred.
Obligations relate two declarations
An obligation frequently qualifies a pair rather than a single declaration: raw against indicated speed, commanded against achieved angle, a toggle against the state it flips, a set value against the value shown back. The pair is where the obligation lives, and one mechanism serves every family.
This is also a real failure mode with no other expression: a person acts, the indication does not follow, the person acts again — and both declarations were individually satisfied.
Obligations compose along a path
Correspondences chain, and the chain is the point:
true wheel speed
│ acquisition — measurement error, sensor lag
▼
the provider's speed value
│ relationship — legal bias, quantisation, damping
▼
the indicated value
│ presentation — refresh rate, legibility, perception delay
▼
what the driver believes
The legal requirement is stated at the end of that chain, so it is verifiable only if every hop declares its own contribution. An end-to-end budget is computable exactly when the path is fully declared, and not otherwise.
Metrology is the reference
These are metrology's concepts, and the correspondence with VIM (JCGM 200 / ISO-IEC Guide 99) is term for term:
| VIM term | Here |
|---|---|
| measurand | what an acquisition interaction names |
| calibration — the relation between quantity values and indications | the relationship obligation |
| measurement uncertainty | the uncertainty obligation |
| indication — the value a measuring instrument provides | a presentation-family value |
| detector — indicates presence when a threshold is exceeded | an acquisition occurrence |
| metrological traceability — an unbroken chain of calibrations, each contributing to the uncertainty | the composition property above |
Where this layer needs to name something further, VIM is the reference to reach for before inventing a word. One VIM concept has no expression here yet — the influence quantity, which does not affect the measurand but does affect the relation between indication and result, such as ambient temperature shifting a pressure sensor's characteristic. It is neither uncertainty (which is dispersion) nor the relationship (it is what perturbs the relationship); see §17.11.
Declaration
Obligations are attributes in the tail block (general-form §4.2), because they are authored, heterogeneous, and mix the assignment and predicate forms. The working spellings are below; they are fixed with the obligation implementation and an author should expect them to settle rather than change in substance.
The example below is written in the rxdl spellings, because a .ridl file
spells only dispatch and these are non-dispatch interactions. measure is a
signal in the acquisition family and present a signal in presentation;
both lower to the kinds and attributes this section defines.
interface Cluster {
measure wheelSpeed: Speed @10ms [
tolerance = 0.5 km/h,
lag = 40ms,
plausible abs(delta) < 10.0
]
present clusterSpeed: IndicatedSpeed @[50ms..200ms] [
derives = wheelSpeed,
resolution = 1 km/h,
ensure clusterSpeed >= wheelSpeed
]
}
A non-dispatch interaction that declares no obligation at all draws RIDL-504: the family exists precisely to make these facts explicit, and a family tag with nothing behind it is a declaration wearing a label.
3.4 Availability
An interaction may be unavailable, and unavailability has five distinct
sources. during, a reserved keyword whose grammar is not decided yet, covers
the first; the rest are predicates.
| Source | Example | Carried by |
|---|---|---|
| mode | only while the system is in ERROR | during — a state gate |
| data | disabled until a value validates | a predicate over declared state |
| in progress | already running, cannot be re-invoked | a predicate over a declared progress value |
| policy | not permitted in this condition | a predicate over declared policy state |
| provisioning | this variant does not have the interaction | fixed — and the interaction is absent |
The first four render as present but refused; provisioning renders as not there at all. Those lead a consumer — and a person — to different conclusions, and they are different tests.
The consumer-evaluability rule
At a presentation-obligated boundary, an availability condition must be evaluable by the consumer.
A person must perceive that something is unavailable before attempting it, not
discover it by rejection. A renderer cannot disable a control whose condition it
cannot evaluate, so an availability predicate on an intent interaction may
reference only declared, consumer-visible state — never provider-internal
state. Violating this is RIDL-505.
At the dispatch and control boundaries the rule does not apply: a software
caller or an actuator may simply be rejected, and §13's ordinary require
semantics hold unchanged.
Availability is general; pre-visibility is not. Every family has all five sources — an actuator is mode-gated, derated, mid-stroke, policy-gated, and fitted or not. Only the person boundary requires that unavailability be perceivable in advance.
Field-level absence is a sixth thing, and is not on this list
All five sources above are properties of an interaction. A field inside a
payload may separately carry no value, written with typl's ? (typl §7.1) — a
CAM-style status message whose acceleration was not measured this cycle is
well-formed, its channel is live, and one field is empty.
On an interaction payload ? has exactly one meaning: the provider has no
value for this field right now. It never means "this variant does not have
it", because that is the provisioning source above, and provisioning renders as
the interaction being absent — a variant with no accelerometer omits the
interaction rather than publishing a permanently empty field.
Absence is realised per target, not per contract: a target that can represent it structurally does so, and one that cannot takes a value the range does not use, without surfacing that value to consumers (ADR-0013 decision 7).
4. Signal
A continuous volatile state value. The latest sample is the truth; intermediate samples may be missed.
signal currentSpeed : Speed @10ms
signal engineTemp : Temperature @[20ms..100ms]
signal warnings : WarningFlags @[50ms..1s]
4.1 Rules
- Single named-type payload
- Timing annotation optional:
@Xms(strict periodic) or@[min..max](change-driven with bounds). An untimed signal receives the configurable default@[100ms..1000ms]— a warning, and concrete bounds in the IR either way (§9.1) - Optional bare
= <value>suffix overriding the payload type's init value (§4.4) - Stream
<T>not valid (RIDL-201) - No
require/ensure— a signal has no call site; validity is the typl constraint (RIDL-301). Error-typed payloads are legal — errors are data (§10.1)
4.2 Direction
The interface provider publishes; consumers subscribe. (Consumer-published flows belong to the consuming component's own provided interface — every flow has exactly one owning provider.)
4.3 Publication Semantics
With @Xms, the provider publishes every X ms regardless of change. With
@[min..max], the provider publishes on change, no faster than the rate floor
min and at least every staleness bound max even unchanged. On a signal those
two generic bounds derive as debounce and refresh ceiling, because state may be
coalesced and survives being unchanged — see §9.
4.4 The Channel Is Never Empty — Init Value and Last-Value
Normative: a signal channel holds a value from the moment it exists. Subscribing delivers a value immediately (within the transport's delivery latency): the init value before the provider's first publication, the latest published value thereafter. A signal is state — state exists even while unchanged, and it exists before anything happens.
- Init value. Defaults to the payload type's init value (typl §5.8 — a
declared bare
= valueor derived). A signal may override it with the same bare= valueidiom, written directly after the payload type — one syntax for init everywhere at the vocabulary and interaction layers, no keyword:
signal targetSpeed : Speed = SPEED_LIMIT_EU @[20ms..500ms]
The init value is the channel-level counterpart of the type's own init: a
property of this signal, layered over the type's default. A payload type with
no derivable init and no = value override is a compile error (RIDL-109); an
override violating the payload constraints likewise (RIDL-110).
- Last-value cache. The binding/broker maintains it per signal, seeded with
the init value at channel creation — this is the contract's demand on every
transport mapping (SOME/IP field with initial value, MQTT retained message
seeded at provisioning, DDS
TRANSIENT_LOCAL; Appendix B). - Application code can always distinguish init from published — the channel state carries provenance (init / live / invalid, §4.5), so consumers that must not act on a mere init value can tell.
- Events carry none of this (§5) — occurrences are not state.
Precedent: AUTOSAR initValue, DDS durability, MQTT retained, SOME/IP field
semantics.
4.5 Invalid Values Propagate — As Invalid State
A payload violating the typl constraints is never hidden. The malformed value itself is not delivered, but its invalidity is: the channel transitions to the invalid state, propagated to every subscriber like any other state change. Silent quarantine — subscribers unknowingly holding stale "last good" data — is exactly the failure mode a safe system must not have.
The generated subscriber API therefore exposes the channel as: value +
provenance, where provenance is init | live | invalid (with the last good
value still accessible in the invalid state). What the application does on
invalid — hold last-good, apply failsafe, degrade — is failure management
(§10.4), not contract semantics; the contract's job is that invalidity is
visible, typed, and timely.
Transport realisation: on CAN/AUTOSAR, channel invalidity is carried in-band
as the SNA/invalid sentinel (0xFF-style, AUTOSAR invalidValue) — which is
what typl's sentinel open question (typl §17.8) becomes at this layer; on
SOME/IP/DDS/MQTT it is a marked update or metadata flag per binding.
Observability hooks record every invalid transition (§10.3). There is no error
channel back to a publisher on pub/sub — a publisher emitting invalid values is
a provider bug surfacing through telemetry, not through consumers.
5. Event
A discrete occurrence. Every occurrence matters; occurrences are queued, not coalesced.
event doorOpened : DoorPayload @[50ms..500ms]
event speedLimitExceeded : SpeedLimitPayload @[100ms..2000ms]
5.1 Rules
- Single named-type payload
- Timing annotation optional:
@[min..max]only — strict periodic@Xmsis meaningless for occurrences (RIDL-103). An untimed event receives the configurable default@[100ms..1000ms](§9.1) - Stream
<T>not valid (RIDL-201) - No
require/ensure(RIDL-301) - No late-joiner delivery: subscribing to an event delivers only occurrences raised after the subscription. No cache, no replay — an occurrence that happened before you were listening did not happen to you. (Replaying history is the test/observability plane's job, not the contract's.)
- Subscription granularity is the interaction. A consumer subscribes to an event, not to a group of them and not to a set chosen per consumer.
- An invalid occurrence is delivered, marked invalid. An occurrence whose payload violates its typl constraints (Stratum 2, §10.2) or is not a well-formed encoding (Stratum 3, §10.3) reaches the subscriber with an invalid marker in place of the value, together with its envelope. The occurrence is never withheld, because nothing fails silently (§10.3, §10.4), and never delivered as a value, because the contract does not admit the value. Nothing is sent back to the provider, as on a signal (§4.5) (frame specification §9.2)
Who receives an occurrence is not a contract term. An occurrence is raised to every consumer subscribed to that event; the contract says what is raised and when, never to whom. Two neighbouring mechanisms sit outside it for the same reason:
- Per-client delivery — Franca's
broadcast selective, where the provider directs an occurrence at one named client — puts the consumer's identity in the provider's contract, and a contract that names its consumers is no longer one contract. Where the recipient is part of what is being said, it is part of the payload: raise the occurrence with the subject as a field, and let each consumer read the subject it cares about. - Subscription granularity below the interface — SOME/IP eventgroups, where a consumer takes a subset of an interface's events — is a transport-level grouping for subscription efficiency. Appendix B maps an eventgroup to an interface, and a finer grouping is a deployment matter the binding derives, not a fact the contract carries: the same contract is realized with one eventgroup per interface or with several, and no consumer can tell from the contract which it got. Grouping for efficiency is rsdl's and the binding's.
A signal subscription has the same granularity, for the same reason: §4.2 gives each flow one owning provider, and who observes it is not the provider's to declare.
5.2 Timing Semantics
min is the rate floor and max the staleness bound, as everywhere (§9). On an
event they derive as a throttle (the provider must not raise occurrences
faster than min) and a TTL (an occurrence processed later than max after
being raised is stale and is discarded by the binding), because an occurrence is
individually meaningful and cannot be coalesced into its successor.
6. Command
A fire-and-forget action request. The consumer requests; the provider acts; there is no reply.
command setGear(position: GearPosition) [
require position != GearPosition.PARK || currentSpeed == 0.0
]
command resetFaults()
command uploadFirmware(data: <FwBlock>)
6.1 Rules
- Parameters are named typl types; stream
<T>permitted on parameters. A tuple is not a parameter type — pass a namedstruct(a tuple is a query return type, §7.1) - A parameter name is unique within the parameter list (RIDL-413): a name identifies one parameter, so a command cannot declare two
- Always returns
()— writing a return type is an error (RIDL-104); if the caller needs a result or a completion signal, usequery - No functional-error channel, by construction: a command has no return, so
there is nowhere for a fallible return. A command whose outcome the caller
must know about is a
queryreturningT | E— the language forces that honesty requirepermitted (§13);ensurenot (nothing to observe)- A range timing annotation is permitted, and an undeclared response bound
draws a warning (§9.3): the
maxbound is a response bound on acceptance — the delivery acknowledgment below, never execution — and theminbound is a call throttle on the caller. RIDL-112 reports a command that declares no response bound and takes the default (§9.1) - Fire-and-forget describes the contract, not the wire. The runtime protocol carries a delivery acknowledgment beneath every command: the receiving binding confirms received and accepted for execution (payload valid, precondition passed) or negatively acknowledges with the Stratum 2 category (§10.2). The ack carries no functional payload, never reaches the contract surface, and is not application-visible as a return value — it exists so the runtime can implement retries, delivery supervision, and duplicate suppression (keyed on the caller's identity plus the envelope sequence number, §3.1), and so a rejected command is a detected event in a safety context rather than a silent one
- A command is therefore fallible like everything else — invalid payload or
failed precondition (Stratum 2, negative ack), or undelivered (Stratum 3, ack
timeout) — all visible to the calling runtime, none to the contract. What
the contract does not promise is execution outcome: observable results of a
command travel back as state (e.g.
setGear→ observecurrentGear). This is the CQRS discipline: commands mutate, state reports.
6.2 Provider-Side Rejection
The provider binding validates parameters (typl constraints) and require
clauses before invoking application code. A violating command is rejected —
never partially executed — and the rejection surfaces twice: as a negative
acknowledgment carrying the Stratum 2 category back to the calling runtime
(§6.1), and through the provider's observability hooks. Application code on both
sides stays uninvolved.
7. Query
A request/response interaction. The reply is mandatory; failure is expressible.
query getAverageSpeed(window: Duration): Speed [
require window > 0ms
ensure result >= 0.0
]
query getMinMax(window: Duration): (min: Speed, max: Speed)
query streamFaults(filter: DiagFilter): <FaultEvent>
query calibrate(axle: Axle): CalReport | CalError // inline T | E — fallible query, §10.1
7.1 Rules
- Must return non-void —
()return is an error, usecommand(RIDL-105) - Return type: named type, named-field tuple, inline
T | E, or stream<T>; streams also permitted on parameters (bidirectional streaming supported) - A parameter name is unique within the parameter list (RIDL-413): a name identifies one parameter, so a query cannot declare two
requireandensurepermitted (§13);ensureconstrainsresult- Functional failure is expressed in the return type: the inline
T | Eform makes the query fallible — §10.1. There is nothrowsclause in ridl - A range timing annotation is permitted, and an undeclared response bound
draws a warning (§9.3): the
maxbound is a response bound on the reply, and theminbound is a call throttle on the caller. RIDL-112 reports a query that declares no response bound and takes the default (§9.1)
7.2 Concurrency and Idempotence
The contract does not serialize queries — providers may answer concurrently and
out of order. Queries should be read-only or idempotent; a state-mutating
request belongs to command (with state observation) unless the mutation
inherently needs a result (e.g. allocate, calibrate). Linters flag
verb-named queries (set…, reset…) as probable commands; §16.4 names the six
verbs RIDL-404 matches.
8. Fixed
A value provisioned externally — build, factory, FOTA — and immutable for the lifetime of the running software instance.
fixed vin : Vin
fixed softwareVersion : Version
fixed capabilities : [Label; 0..32]
- Single named-type payload (collections permitted); no timing, no attribute block (RIDL-106)
- Read-only: no interaction can mutate it; safe to cache unconditionally
- Reading a
fixedis free of the query machinery — bindings expose it as a plain accessor, populated at binding initialization - Naming decision on record:
fixed, notfinaland notconfig— one word for one concept at every boundary (ADR-0011);fixedis family-neutral, because a provisioned constant carries no correspondence anywhere.finalwas the earlier spelling and misled: a Java or Kotlin reader takes it for a compile-time constant, which this is not.configconnotes hot-reload and is reserved vocabulary space for rsdl. See the concept-note naming ledger
Maps to: Android ro.* properties, AUTOSAR CalibrationParameter, SOME/IP
field with getter only.
9. Timing Annotations
Timing is the time core surfacing in ridl. The @ annotation is part of the
contract, not documentation — it generates freshness monitoring, drives rmdl
base clocks, and defines "late" for the observability plane.
Strict periodic — signal only:
signal currentSpeed : Speed @10ms
Range — signal, event, command, and query:
@[20ms..100ms] // both bounds
@[20ms..] // lower bound only
@[..100ms] // upper bound only
The bounds have one generic meaning, whatever the declaring keyword (general form §6.2):
min= rate floor — the minimum interval between publications.max= staleness bound — the maximum age, measured on envelope sender timestamps (§3.1), before the value or occurrence is stale.
So the naive reading of @[20ms..500ms] — "not faster than 20ms, not staler
than 500ms" — is the correct one, and it is the same reading on a signal and on
an event.
What differs per kind is what the runtime does at each bound, and that is derived from the semantics of the declaring keyword (§3), not from the annotation. The familiar names are the derivation, not the definition:
| Bound | On a signal (state) | On an event (occurrence) | On a command / query (call) |
|---|---|---|---|
min — rate floor | debounce — a faster update is coalesced into the next sample | throttle — the provider must not raise occurrences faster | call throttle — the caller must not call faster |
max — staleness bound | refresh ceiling — re-publish even if unchanged | TTL — an occurrence older than max is discarded | response bound — the provider must respond within |
A state value that is stale is refreshed and a fast one coalesced, because state survives being unchanged and only the latest sample matters; an occurrence that is stale is discarded and a fast one throttled, because every occurrence is individually meaningful and cannot be merged into its successor. A call has no sample to refresh or discard: its two bounds oblige the two ends of the call directly — §9.3 (ADR-0015). Editor hover expands the per-kind consequence from the declaring keyword.
9.1 Default Timing
A signal or event with no @ annotation receives the default range
@[100ms..1000ms] — a 100ms rate floor and a 1s staleness bound, derived
per kind as above. The default is configurable at compile time in ridl.toml:
[defaults]
timing = "[100ms..1000ms]" # applies to untimed signals and events
command_timing = "[..1s]" # applies to a command with no response bound
query_timing = "[..3s]" # applies to a query with no response bound
Resolution follows the ADR-0002 precedence: package-level [defaults] shadows
workspace-level, which shadows the built-in — [100ms..1000ms] for timing,
[..1s] for command_timing, [..3s] for query_timing. Each key resolves on
its own. Three properties keep this safe:
- The IR always carries concrete bounds. The default is applied at compile time, so every downstream consumer — freshness SLOs, observability conventions, rmdl, test generators — sees a fully timed contract; "untimed" does not exist beyond the parser.
- An untimed interaction draws a warning (RIDL-100: default applied); an active profile may escalate to an error and require explicit timing — the same idiom as typl's default string bounds (typl §4.4). Safety-graded packages should require explicit timing.
- Changing the configured default is a contract change. Because
ridl-diffcompares resolved IR, editing a[defaults]key flips the bounds of every untimed interaction of the kinds it covers in the package and is flagged accordingly — the default is a convenience, not a loophole.
Strict periodic @Xms is never defaulted — an isochronous rate is always an
explicit engineering decision (it drives rmdl base clocks).
A command or query takes a default response bound (ADR-0015 decision 4).
An RPC with no declared response bound draws RIDL-112 (warning; an active
profile may escalate it to an error, the same two-step as RIDL-100 above) and
receives the command_timing or query_timing default: [..1s] for a command
and [..3s] for a query unless a manifest says otherwise. The default fills
max only. A member written @[20ms..] keeps its min and takes max from
the default; a default's min applies only to a member with no annotation. The
IR records default_applied as true. A default is a value the package declares
and both sides read from the catalog, so it is not an invented promise. To
require every call to carry its own bound, set missing-response-bound = "deny"
in [lints]: an untimed call is then an error.
9.2 Validity Rules
Rules: @0ms is an error (RIDL-102); @[X..Y] with X > Y is an error
(RIDL-101); @[X..X] draws a warning (RIDL-108) on every kind that admits the
range — it is a degenerate range, a rate floor equal to its staleness bound,
which is almost always a mistake. Neither RIDL-101 nor RIDL-108 runs on a range
that has a bound drawing FORM-102 (a duration that is not a whole number of time
units): the rejected literal is the only ordering report until the author
rewrites it. It is not a spelling of the strict period @Xms: a strict
period is a separate mode, admitted on signals only, never defaulted (§9.1),
recorded in the IR beside the bounds, and a change between the two modes is
breaking whatever the bounds do.
Timing belongs to every kind but fixed. A signal takes either form; an
event, a command, and a query take the range form only, so a strict period
@Xms on any of the three is RIDL-103 — an isochronous rate is meaningless for
occurrences, and a caller is not isochronous by contract (ADR-0015 decision 5).
An @ annotation on a fixed is RIDL-106. The grammar admits the annotation on
every interaction kind so that each narrowing is a semantic rule with a semantic
message; none is a parse error.
A signal's @Xms or @[..max] is an alertable freshness SLO: a subscriber
that has not seen a publication within the bound may treat the value as stale —
generated bindings expose staleness, and the observability conventions map the
bound to an OTel attribute. Every timing bound — the rate floor, the staleness
bound, and the derived debounce, refresh, throttle and TTL behaviour — is
evaluated on envelope timestamps (§3.1), never on payload content.
DDS students will recognise max as the DEADLINE QoS and staleness as
LIVELINESS — ridl puts them in the contract instead of a QoS profile; see
Appendix F.
9.3 RPC Bounds — the Call Throttle and the Response Bound
command and query admit the range form, and only the range form (ADR-0015
decisions 2, 3, and 5). The two bounds keep their generic meaning; the per-kind
consequence is derived from the declaring keyword, exactly as it is for state
and occurrences:
minis the call throttle — the minimum interval between calls. It constrains the caller, not the provider. That is not an inconsistency:minalways constrains whoever initiates, and on an RPC the initiator is the consumer. It is enforceable at the provider's admission point, and it is what a rate-limiting binding already implements.maxis the response bound — the provider must respond within it. What responding means is derived per kind. For aqueryit is the reply. For acommandit is acceptance — the delivery acknowledgment of §6.1 — since §6.1 promises no execution outcome: a command's bound covers admission and queueing at the provider, and not execution.
The response bound is a bound on the response, not on delivery. Delivery latency is a property of the link, so a per-interaction delivery bound would declare the same number on every declaration in the package and carry no information. Response time is what varies per interaction — which is why gRPC, DDS-RPC, and AIDL all bound the whole call.
The half-open spellings carry the two partial cases: @[..100ms] is a response
bound with no throttle, and @[20ms..] a throttle with no response bound. An
RPC whose response bound is undeclared — a bare declaration, or the @[20ms..]
spelling — draws RIDL-112 and takes the default response bound (§9.1). The
warning is about max specifically, not about the annotation; a missing min
draws nothing, because an unbounded call rate is the sensible default and is
what every RPC has today.
In the IR the declared bounds reuse the Timing message unchanged (ADR-0015
decision 7): the mode is always the range mode, min_us is the call throttle,
max_us the response bound (always present), and default_applied true when a
bound came from a default. The response bound is for RPC what §10.3 calls the
freshness machinery for pub/sub: the contract-level view of transport health.
10. Errors
New in v0.2. The error model has three strata. None of them adds interaction syntax: the first is expressed as data from the typl vocabulary; the other two never appear in source at all.
10.1 Stratum 1 — Functional Errors Are Data
ridl has no error syntax — no throws, no exceptions, no status codes. A
query that can fail as part of its domain semantics says so in its return
type, with vocabulary declared in typl:
error enum CalError { // typl §10.1 — failure vocabulary
SENSOR_UNAVAILABLE = 0
VEHICLE_MOVING = 1
OUT_OF_RANGE = 2
}
query calibrate(axle: Axle): CalReport | CalError // inline T | E — the canonical spelling
Rules:
- An inline
T | Ein query-return position makes a fallible query — the canonical spelling (general form §6.1). The left arm is any non-error named type (success); the right arm is exactly one error type. Both arms stay named typl types; only the union container is structural, and its transport identity is synthesized from the interface, the interaction ordinal, and the ordered arm types (ADR-0008 decision 4) - Bindings split it mechanically: the success arm is the reply payload; the
error arm maps to the transport's native error channel — SOME/IP return
code, gRPC rich status, AIDL exception — and to
Result<T, E>(Rust) / sealed result (Kotlin) in language targets, with exhaustive handling enforced wherever the target can express it - Several failure kinds compose into one
error unionbefore appearing as the error arm (typl §10.2) — one query still carries one closed failure set - A named result union (typl §10.2) remains legal typl data — for storing an outcome in a struct or a log — but in return position it draws RIDL-308, a lint steering to the inline spelling
- A query with no success path is an error (RIDL-303): a bare error type in
return position, or an
error-typed left arm of an inlineT | E commandremains failure-free by construction: it has no return, so there is nowhere for a fallible return — nothing to ban. A command's observable failure is state, like every other command outcome: publish a fault signal (§6.1)- Because errors are ordinary vocabulary, pub/sub can carry them:
signal lastFault : ServiceFault @[1s..]is legal and idiomatic for fault reporting — structurally impossible in throws-based designs, where failure lives in a control-flow channel that pub/sub does not have
Rationale (decided v0.2, replacing a draft throws clause). One concept
instead of two: failure shapes are typl types like every other shape — nominally
typed, importable, reusable across interfaces, evolvable under the same ordinal
rules. ridl adds zero error surface; the runtime keeps its own stratum below
without the contract pretending to model it. The cost — naming the failure type
on the signature — is exactly the honesty wanted: the failure set is part of the
signature, as data. The inline spelling (general form §6.1) puts it at the
signature rather than one hop away in a named union.
10.2 Stratum 2 — Contract Errors: implicit, standardized, derived
Invalid values and violated preconditions are contract violations, not
functional errors. They are never declared — the typl constraints and
require clauses already describe them completely — and never occupy an error
type. The spec defines the categories once; bindings implement them uniformly:
| Category | Trigger | RPC surface (caller sees) | Pub/sub surface |
|---|---|---|---|
INVALID_VALUE | payload violates typl constraints (range, step, bounds, pattern) at a boundary | standard error, distinct from any result-union error arm — gRPC INVALID_ARGUMENT, SOME/IP E_MALFORMED_MESSAGE | signal: channel transitions to invalid state, propagated to subscribers (§4.5); event: occurrence delivered marked invalid (§5.1) |
PRECONDITION_FAILED | a require clause evaluates false | standard error — gRPC FAILED_PRECONDITION, SOME/IP E_NOT_OK | command: negative ack to calling runtime + observability (§6.2) |
CONTRACT_BROKEN | an ensure clause evaluates false — a provider bug | standard error to caller; incident-grade telemetry on provider | n/a |
UNKNOWN_INTERACTION | ordinal/version mismatch between peers (§11) | standard error — SOME/IP E_UNKNOWN_METHOD | subscription fails at bind time |
Provider bindings evaluate constraints and require before application code
runs, and ensure after; application code never sees a contract-violating
input, and callers can always distinguish "the service said no" (Stratum 1) from
"the call was ill-formed" (Stratum 2).
10.3 Stratum 3 — Transport Errors: infrastructure failure — detected, undeclared
Timeouts, broker loss, serialization failures, connection resets, and a
command's missing delivery acknowledgment (§6.1): these exist for every
interaction identically and say nothing about the contract, so the language
never declares them. The normative phrasing is "infrastructure failure —
detected, undeclared" (general form §6.4), and both halves are load-bearing.
Detected: the runtime observes every one of them — acks, timeouts, staleness —
and nothing fails silently (§10.4). Undeclared: the contract language has no
vocabulary for them, because a contract author has no knowledge to express. This
is the opposite of undefined behaviour, where a system may do anything; the
generated code names the stratum in exactly these words. Generated caller-side
types carry a transport-error variant supplied by the runtime (ridl-rt), not
by the .ridl source — including command delivery status for callers that
choose to supervise it. The freshness machinery (§9) is the contract-level view
of transport health for pub/sub; the response bound (§9.3) is the same view for
RPC.
The invariant across all three strata: what the contract declares is exactly the failure knowledge a domain engineer possesses (Stratum 1); what the contract implies is derived mechanically and enforced uniformly (Stratum 2); what the contract cannot know, it does not pretend to know (Stratum 3).
10.4 Failure Management Is Out of Scope — Deliberately
Detection is not management. This section specifies how failures are detected and classified; what a system does about them — failsafe states, fallbacks, degraded modes, health monitoring, halt management — is a safety/quality-management concern, specified separately and never expressed in ridl syntax. The division of labour:
- ridl declares which failures a fallible query can answer with (the error
arm of its
T | Ereturn — the vocabulary itself is typl's, §10.1) and the bounds whose violation defines failure (typl constraints,require/ensure, freshness) - the runtime detects every failure across all three strata — quarantines, negative acks, ack timeouts, staleness — nothing fails silently; this total detection is precisely what makes failure management implementable
- the management layer decides — supervision policies, fallback wiring,
degradation ladders, health state machines. Its natural homes are the
deployment topology (rsdl: which component supervises which, what replaces a
failed provider) and the runtime specification (
ridl-rtsupervision hooks), informed by@labelsassurance levels
Planned direction — properties, not mechanisms. The family is expected to
grow declarative safety/high-availability properties across its layers: what
must hold, never how the runtime achieves it. Candidate surfaces, gated through
the @labels/profile system first and promoted to keywords only when earned:
typl — invalid/SNA sentinel values (typl §17.8); ridl — per-signal failsafe
values, availability requirements on interactions; rmdl — degraded-mode models
and observers acting as safety monitors; rsdl — arbitration over the redundant
provider sets it derives from instances (rsdl §7), supervision topology,
fallback wiring. Deferred as a body of work of its own (§17.8) — in v0.2 the
contract's job ends at making every failure visible and typed.
11. Interaction Identity and Evolution
typl §7.4's model, applied to interfaces. Tag-based transports need stable numeric identities per interaction (SOME/IP method/event IDs, proto RPC identity).
- Implicit ordinals by declaration order (1-based) across all interactions of an interface, one sequence regardless of kind
- Append-only: new interactions go at the end; insert/reorder shifts
ordinals — wire break, rejected by
ridl-diff reservedtombstones retire removed interactions by name:
interface VehicleStatus {
signal currentSpeed : Speed @10ms
reserved legacyTemp // was ordinal 2 — retired
event doorOpened : DoorPayload @[50ms..500ms]
}
- Publication enforces the tombstone.
ridl baselinerefuses to replace a published baseline when, in an interface body both declare, the replacement breaks the rule above in any of four shapes — RIDL-408, exit 1, the published baseline left as it was: an interaction is gone with noreservedline at all; it is retired with areservedline, but at an ordinal other than the one it held; the baseline already retired it and the source has dropped thereservedline; or the source declares a live interaction under a name the baseline retires. The published snapshot is the only record that the ordinal was taken, so publication is the last point at which the change can be refused. The gate covers the interaction level; the interface level is the lock's, one level up below: a whole interface removed with its lock entry live fails the build (RIDL-409), a provisional number is refused at publication (RIDL-411), and so is a published number the fresh snapshot neither carries nor retires (RIDL-412). RIDL-407 is unchanged: it remains the desk-time warning that an ordinal moved, emitted byridl check, and it neither classifies nor gates. - What the publication gate is for, and what it is not for. The gate
protects the record of a retirement: the four shapes above are the ones
that would publish a baseline in which an ordinal's history is no longer
written down anywhere. It is not a second compatibility gate —
ridl diffand its exit-code contract are that one, and they report every breaking change, including the shapes publication lets through, and the one below it refuses at the interface level only:- An ordinal that moves. An interaction inserted mid-body, a live
interaction reordered, or an existing tombstone moved from the ordinal it
held (§17.12) leaves the record complete: every name in the published
snapshot still carries an ordinal, and a later comparison reads it. The move
is breaking,
ridl diffsays so and exits 1, andridl checkwarns RIDL-407 at the desk. Publication does not refuse it. - A whole package removed or renamed (§17.13). The interaction level does
not refuse it, and the snapshot goes with every tombstone it held. The
interface level does: an interface number the removed package's snapshot
holds is refused at publication (RIDL-412) unless the unit's
interfaces.lockretires it or another package of the unit still declares it; a number 0, published before the lock existed, is never refused. This reverses the earlier position, which rested on the language having no package-level retirement. That premise no longer holds for a package removed from a unit that remains: the unit's lock retires its numbers (ridl lock --retire), which is the remedy the author writes. For a whole unit removed or renamed, the refusal catches the accidental case — a moved directory or a changed manifest — that would otherwise drop the unit's snapshots and lose the record without a sign. The deliberate case costs one deletion, of that unit's snapshots from.ridl/baseline/, which the message names. What would reopen the question is a unit-level retirement construct.ridl diffreports the removal as breaking. - A whole service removed, or a service whose form switches between inline
and named (§17.14). Neither moves an identity of its own: an interface is
identified by its number, a named-form service carries no number, and its
list is a set (§14.5) the gate does not read. Every case where an identity
would be lost is already refused one level down — a live lock entry with no
declaration is RIDL-409 at the build, and an inline shape's own number is
RIDL-411 or RIDL-412 at publication.
ridl diffreports the removal and the form switch as breaking.
- An ordinal that moves. An interaction inserted mid-body, a live
interaction reordered, or an existing tombstone moved from the ordinal it
held (§17.12) leaves the record complete: every name in the published
snapshot still carries an ordinal, and a later comparison reads it. The move
is breaking,
- Transport IDs derive deterministically from ordinals (e.g. SOME/IP: method ID = ordinal for RPC kinds, event ID = ordinal with the event flag bit; Appendix B) — readable from source, no sidecar state
- No in-language version block. Franca's
version { major minor }was considered and rejected: the package version (ridl.toml, ADR-0002) is the one version, andridl-diffdecides compatibility mechanically — a hand-maintained version pair inside the source is a second source of truth that drifts. Major-version coexistence is a deployment topology question (rsdl), not a contract question - Changing an interaction's kind (e.g. event → signal), payload type, timing
bound, or a fallible return's error arm in a breaking direction is detected by
ridl-diffper its category rules; the diff exit-code contract (concept note §9.1) applies
One level up, an interface's identity is its number, and the number lives
outside the source. Every interface of a unit — a declared interface and a
service's inline shape (§14.5) alike — carries an interface number from the
unit's interfaces.lock. A unit is one ridl.toml with a [package] table
plus every source package in its directory tree (ADR-0002 §1), and the lock is
one line table in the manifest directory, beside the root source package, that
only ridl lock writes: a next N line, then one entry per interface,
Name N, keyed by the interface's catalog name or, for an inline shape, by
service: and the service's dotted name. The catalog name of a declared
interface is its source package relative to the unit, then its name, joined by
.: cluster.SpeedDisplay for an interface of the source package
com.example.hmi.cluster in the unit com.example.hmi, and Session for an
interface of the root source package. The key is therefore one identifier or a
dotted path of identifiers ending in the interface name. The number is 1-based,
per unit, and is the interface's routing identity; a rename keeps it, and an
entry is never removed or renumbered — a retired interface keeps its line with
the word retired, and its number is never allocated again. A declaration with
no entry compiles with a provisional number, taken from next upward in
byte order of the name — the lock key without the service: prefix of an inline
shape, with an interface before an inline shape of the same name — over the
whole unit. A provisional number carries no identity until plain ridl lock
allocates and records it. The compiler reads the file as a unit input and
refuses the departures it cannot resolve: a live entry with no declaration is
RIDL-409, fixed on the branch that made the change with
ridl lock <path> --rename Old=New or ridl lock <path> --retire Old, where
<path> is any path inside the unit and Old and New are catalog names —
with a published baseline, ridl check names the one --rename when exactly
one declaration without an entry has the old interface's shape, member for
member — and a malformed file, a merge conflict left in it included, is
RIDL-410. Publication refuses what the build cannot see: ridl baseline
refuses a provisional number (RIDL-411) and a published number that is
absent from the fresh side and not retired (RIDL-412). An interaction's
ordinal space stays local to its interface — a binding keys the spaces on the
unit and the interface number, never on a name or on a position in a
service's list (§14.5) — so the two levels never renumber each other.
The lock is the unit's, and only the file beside the manifest is read. An
interfaces.lock in a subdirectory of a unit is not read and draws the warning
RIDL-416. An interface that moves to another source package of its unit
keeps its number when ridl lock --rename records the move from its old catalog
name to its new one, so a move between sibling packages is a rename and not a
removal. A retired entry belongs to the source package its catalog name names.
The IR spells each retired entry (Package.retired[].name) as its lock key, the
catalog name, and the unit's anchor package carries the entries that name no
source package of the unit, and every service: entry. The anchor package is
the source package named like the unit when it exists, and otherwise the first
source package of the unit in byte order of name; it also reports the unit's
RIDL-409 orphans. In single-file mode, outside any manifest, the unit is the
file's package name and the lock beside the file is the unit's lock.
12. Streams
The stream container <T> is ridl-owned (typl §1.3): an unbounded sequence
valid only in interaction position — command/query parameters and query
returns.
query streamFaults(filter: DiagFilter): <FaultEvent> // server produces
command uploadFirmware(data: <FwBlock>) // client produces
query pipe(samples: <SensorSample>): <ProcessedSample> // bidirectional
12.1 Direction by Position
| Position | Direction |
|---|---|
| return type | provider produces, consumer consumes |
| parameter | consumer produces, provider consumes |
| both | bidirectional, full-duplex |
12.2 Element Type
A named typl type, or raw string/bytes when the element is genuinely
unstructured (the one exception to typl §15.3, inherited from v0.1). The element
type's bound describes one logical element; the stream itself has no bound —
framing, buffering, and backpressure are transport concerns.
12.3 Restrictions
<T> on signal/event (RIDL-201), struct fields, or collections (typl
TYPL-301 territory) is an error. A signal is the better stream for state; an
unbounded push of occurrences is an event; <T> exists for RPC-scoped
transfers with a beginning and an end.
Maps to: gRPC streaming, Kotlin Flow, Rust Stream, WIT stream<T> (WASI
0.3), SOME/IP segmented transfer.
12.4 Errors on Streams
Functional failure around a stream is data, like everywhere else: a failure the producer knows about is expressed by making the element type a result union.
An element that violates its typl constraints ends the stream. It is a
Stratum 2 INVALID_VALUE (§10.2), and a stream lives inside an RPC (§12.3), so
it takes the RPC surface of that stratum: a standard error to the caller,
distinct from any result-union error arm, and the call — with the stream inside
it — is over. Dropping the element and continuing is the one behaviour ruled
out, for the reason §4.5 gives for state: a consumer that silently receives
fewer elements than the producer sent cannot tell a complete transfer from an
incomplete one, and silent quarantine is the failure mode a safe system must not
have.
What the element type records is which stratum a failure lands in, and that
is the whole of the contract-level choice. With a plain T, a failing element
is a contract violation and the call ends. With T | E, the failure is declared
data: it is Stratum 1, it does not end the call, and the consumer handles it
element by element. What the producer does after emitting an error element —
close the stream, or go on producing — is behaviour, not contract, and a
consumer writes for both, because a stream may end at any point in any case. A
producer that means the error element to be the last one closes after it, and a
consumer reads the close, not the arm, as the end.
No channel-level policy, keyword, or per-binding setting is needed for either half, and none exists. Transport interruption remains Stratum 3.
13. Contracts — require / ensure
The expr core surfacing in ridl. Attribute blocks [ ] on command and
query:
command setRange(min: Speed, max: Speed) [
require min < max
require max <= MAX_SPEED
]
query getAverageSpeed(window: Duration): Speed [
require window > 0ms
ensure result >= 0.0
]
| Attribute | Meaning | Valid on |
|---|---|---|
require <expr> | precondition over parameters and the interface's own signals | command, query |
ensure <expr> | postcondition over result (and parameters) | query only |
Both predicates are rows of the attribute registry (ridl_core::attributes,
ADR-0012 decision 8): owner core, form predicate, sites as the table above
says, consumer the clause check, and a change to either reported by ridl diff
as contract_changed. The registry is the one allow-list the checker reads for
a plain key: a plain key on a member draws FORM-107 when the registry has a row
for it on an rsdl site and FORM-106 when it has none. No plain key is accepted
on a member today.
- Expressions are type-checked, unquoted, side-effect-free; they reference
parameters,
result, constants, enum values, and the interface's own signals (arequiremay readcurrentSpeed— the provider's latest published state) - Violations are Stratum 2 errors (§10.2) — never the error arm of a
T | E - The full expression grammar is the expr-core specification. This document
fixes the positions and the checking discipline; the expr-core
specification §3.1 fixes the grammar of the guaranteed subset —
comparison, boolean connectives, arithmetic, enum access, tuple-field access,
and duration comparison, over parameters,
result, constants, enum values and the interface's own signals — and §8 fixes the RIDL-306 boundary that rejects everything outside it - One assertion, four executions (concept note §9.2): static where decidable,
property test in CI, online observer on live flows, synchronous observer in
the rmdl reference oracle.
require/ensurewritten here are the source for all four
14. Interfaces and Services
ridl names contracts at two levels — the shape and its global declaration — the split the whole SDV industry converged on (AUTOSAR Adaptive ServiceInterface vs Service Instance; VSS branch type vs instance; uProtocol interface vs UUri endpoint):
interface— the abstract shape: a reusable, identity-less group of interactions. A contract type. Defined once, realized by many services. The analogue of a class or a proto service definition.service— a global, named, published declaration of one or more interfaces: the SSOT catalog entry that gives contracts concrete identity in the system, addressable asservice.member. The analogue of a global named instance. What actually gets offered, deployed, and (optionally) discovered.
interface : service :: type : global-named-instance. This section covers both.
14.0 Interface — the abstract shape
An interface groups interactions under a named, reusable contract shape. It
has no identity and no location — it is not a runtime unit, not addressable,
not deployed. It exists to be realized by services (§14.5) and to type them.
Multiple services may share one interface (a second vehicle variant, a family of
sensors). Within one rsdl system each interface is listed by one service,
because the routing key names no service (rsdl §8, RSDL-408); whether ridl
rejects a shared interface across a whole workspace is open (rsdl §17, item 10).
/**
* Main vehicle status contract shape.
* @labels SIL_B, CAL_2, PRIVATE
*/
interface VehicleStatus {
signal currentSpeed : Speed @10ms
signal warnings : WarningFlags @[50ms..1s]
event doorOpened : DoorPayload @[50ms..500ms]
command setGear(position: GearPosition) [
require position != GearPosition.PARK || currentSpeed == 0.0
]
query getAverageSpeed(window: Duration): Speed [
require window > 0ms
ensure result >= 0.0
]
fixed softwareVersion : Version
}
14.1 Rules
- Contains interactions and
reservedtombstones only — type declarations live at package level (they are typl), never inside an interface (RIDL-107) - Flat: there is no interface inheritance.
extendswas considered and rejected (Franca/CORBA precedent notwithstanding): inheritance couples the derived interface's wire identity to base evolution (a base insertion renumbers every child), complicates audit ("what is this service's complete contract?" requires closure computation), and has no faithful mapping to proto services or DBC. Shared shapes are typl; a shared interaction set is composed at the service — a service lists several interfaces (§14.5), so a recurring block keeps its own ordinal space beside every contract it joins and is never flattened into one (§17.2, ADR-0015) - Interface names are
CamelCase; interaction namescamelCase @labelsclassification and profiles as in typl §14.3 — assurance labels (SIL_B, …) gate the test plane's injection rights (concept note §9.2)
14.5 Service — the global published declaration
A service is a global, named declaration that contracts of the given
interface shapes exist in the system. It is the SSOT catalog entry:
system-visible at design time, addressable by name, and the unit that an rsdl
component offers (§14.6) and that deployment realizes.
service veh.adas.cruise : CruiseControl
service veh.body.doors : DoorControl, DiagBlock
service veh.powertrain.motor : MotorControl
- A service has a dotted global name (reverse-domain, like packages) and a
comma-separated set of interfaces after
:(ADR-0015 decision 12, as amended by the lock design): the list's order carries nothing. Its members are addressedservice.member—veh.adas.cruise.engaged,veh.adas.cruise.setLever. - Commas are required between shapes — the one list in the family whose
separators are not optional (ADR-0015 decision 13). The reason is structural:
every other list is terminated by a closing token, and this one is not — it
ends where the next declaration begins. Without required commas the list would
parse greedily, and a mistyped declaration on a following line (
Struct Foo {forstruct Foo {) would be absorbed as another shape, with the error surfacing at the{and saying nothing about the mistake. A trailing comma stays optional. - The name is unique across the system (RIDL-140) — the service catalog is a
flat global namespace, the SSOT every component agrees on. This is what makes
veh.adas.cruise.engagedan unambiguous system-wide address (the VSS/uProtocol model). - A service is always public: it takes no
internalmodifier, and every shape it lists must be public too. Publishing aninternalinterface at a global address is RIDL-143, reported against the offending list element — the address would name a contract no importer can implement. Dropinternalfrom the interface, or give the service an inline shape. A shape that is not an interface at all is RIDL-141, likewise per element. - A service may instead declare one inline shape, for a one-off global contract not worth a reusable interface — never both forms in one declaration (ADR-0015 decision 14):
service veh.hvac.cabin {
signal temperature : Temperature @[1s..10s]
command setTarget(t: Temperature)
}
Composing interfaces — numbers, flat addressing, and the rules
A service composing several interfaces names each in its list, and the list is a set (rsdl decision D-7; the lock design §7 and §9):
-
An interface's number comes from its unit's
interfaces.lock(§11), not from its place in a service's list. An inline shape is an interface too and has its own entry there, keyedservice:followed by the service's dotted name. The list holds no slot and no tombstone: adding an interface to the set, removing one, or reordering the list moves no wire identity, andridl diffreports an addition asservice_interface_addedand a removal asservice_interface_removed, both compatible. A removed interface is retired in the lock —ridl lock <pkg> --retire Name— never in the list, andreservedin a service's list is a parse error. -
Ordinals stay per-interface, and a binding keys the ordinal spaces on (unit, interface number) (ADR-0015 decision 17, as amended by the lock design), not on the interface name and not on list position. Renumbering interactions across a service was rejected: an interface's wire identity would then depend on what else the service happens to carry — the coupling §14.1 rejected inheritance to avoid. Appendix B already maps a SOME/IP eventgroup to an interface, so a multi-interface service maps to several transport-level groupings under one logical name, each keyed on the number.
-
Addressing stays flat (ADR-0015 decision 16): members remain
service.member, so every address written before composition existed stays valid. A member name duplicated across a service's interfaces is a compile error (RIDL-144) —service.statuswith two referents cannot be expressed. The accepted cost is stated rather than discovered: two independently written interfaces that share a member name cannot be composed into one service without renaming one of them. -
The same interface named twice in one service is RIDL-145 — its own code rather than one RIDL-144 per member, which would bury the actual mistake; the duplicate listing is dropped.
-
Extracting an inline shape into a named interface is breaking, by design. The interface's number survives the refactor — the lock entry is renamed,
ridl lock <pkg> --rename service:veh.hvac.cabin=Cabin— but a fallible return's transport identity derives from the enclosing interface name (ADR-0008 decision 4), and an inline shape uses the service's dotted name instead — so extraction rewrites the identity of every fallible query in the shape, andridl-diffclassifies the form switch as breaking. Making that refactor compatible would be a wire-identity decision in its own right (ADR-0015 decision 15). -
Posture-neutral by design. A service declaration says nothing about how it is realized on the wire. The posture — static (its signals/events packed into bus frames, Classic/CAN) or discovered (SOME/IP/DDS/uProtocol, Adaptive) — is a deployment matter, so the same declaration can be realized either way. This is the whole point of ridl being the bus SSOT:
service veh.adas.cruisecan compile down to static CAN signals or a discovered service, no rewrite. One constraint follows from transport physics: acommand/querymember (RPC) cannot be realized on a pure static bus — buses carry dataflow, not calls — so a service deployed statically realizes only itssignal/eventmembers; its control API requires the discovered posture. Deriving the posture per deployment, and enforcing that constraint at deploy time, are deferred: rsdl reserves both (rsdl §12, RSDL-803). -
Offering and redundancy are rsdl concerns (§14.6): a component
offersa service, and exactly one component of a system offers each service (rsdl §8, RSDL-502). Redundancy is derived from that component's instances, never declared by a second offerer (rsdl §7).
Coherence — the published set is simultaneous
The signals of one provided interface are published coherently: the set a provider publishes in one step is a set of values it held simultaneously. A consumer reading two or more of them observes such a set wherever the binding preserves the grouping; where a binding cannot, that is a deploy-time constraint, not a weaker contract. The group is the provided interface, identified by its number in the unit's
interfaces.lock(§11) — an inline shape included, under itsservice:entry. The interface's name, or the service's dotted name for an inline shape, is how the group is written, not what identifies it: a rename keeps the number, and keeps the group.
The rule states production coherence (ADR-0015 decision 9). The three rules below establish that the provider's published set is simultaneous; they establish nothing about what survives an arbitrary transport, which is why the consumer sentence is conditional on the binding. What a consumer observes under a given binding is delivery coherence, treated separately below.
The rule is implicit, not declared — there is no coherent keyword. Three
rules the family already states produce it: §4.2 gives every flow exactly one
owning provider; a provider computes its outputs in one step (rmdl's topological
schedule, rmdl §6); and each provider realizes the service it publishes as a
whole — a component offers a service whole (rsdl §3.2), and one component offers
it (rsdl §8, RSDL-502). So the values a provider publishes in one step are a
simultaneous state by construction. Signals publishing at different rates do not
break this: in a given step some cells are written and others are not, but every
value present is one that provider held at that step, so the observed set
remains a state that existed.
Redundancy does not weaken the rule. A redundant provider set is several instances of the one component that offers the service (§14.6 above, rsdl §7), and the rule then holds of each instance's published set — which is what a consumer reads, since a consumer reads one provider at a time. How a consumer chooses between the instances is arbitration, which rsdl reserves (rsdl §12).
Declaring coherent would declare a consequence of how the platform executes,
which the general form §4.1 deletion test exists to reject. It would also be
false in the one place it appeared to help: marking one interface coherent would
imply the others are not, when all of them are.
Production coherence and delivery coherence
Production coherence and delivery coherence are different, and the difference is a demand on every transport mapping (ADR-0015 decision 10) — in the same sense the §4.4 last-value guarantee already is. The provider producing a coherent set is implicit; whether a consumer observes it coherently depends on the binding:
| Transport | How the grouping is preserved |
|---|---|
| shared memory | one versioned block per group, one generation counter |
| DDS | GROUP-scope PRESENTATION with coherent_access |
| SOME/IP | one notifier per field; preserved only within a field |
| static bus (CAN) | preserved only within one frame; not across frames |
Two consequences follow:
- Where a consumer needs the guarantee to survive an arbitrary binding, the answer is the struct idiom (§17.3): make the values one struct, so they are one payload on one channel — atomic on every transport.
- Where a binding cannot preserve the grouping, that is a deploy-time constraint, not a weaker contract. The precedent is the posture constraint above — a statically deployed service's control API requires the discovered posture — and the owner is the same: the deployment. rsdl lowers only the crossing kind of each link, and the transport is the implementer's configuration (rsdl §10); the check of feasibility against the contract at deploy time is reserved with posture derivation (rsdl §12, RSDL-801).
Appendix B carries the per-target realization.
The generation unit and the addressing unit
A provided interface is the generation unit (ADR-0015 decision 11). Coherence (above), ordinals (§11), and group identity all key on the same boundary, and that convergence is what makes a store and a dispatcher generatable from the IR alone. From one provided interface:
- one store — its signals, laid out as one coherent block behind a single
generation counter; each cell seeded with the payload's init value (§4.4),
carrying provenance and the envelope (§3.1), and evaluated against its own
maxstaleness bound (§9); - one dispatcher — its events, commands, and queries, routed by the interface's single ordinal sequence (§11), with the typl constraints checked before the handler and the response bound applied per its per-kind derivation (§9.3) — around the reply for a query, around acceptance for a command.
The service is the addressing unit: it contributes the global name and the
service.member addresses, never the shape of what is generated. The store and
the dispatcher multiply with the interfaces a service publishes, not with the
service — one store and one dispatcher per published interface, under one
logical name.
14.6 How components relate to services (forward reference)
Interfaces and services are ridl (the contract SSOT); offering services and
requiring interfaces is rsdl. In brief (full treatment in rsdl §3.2 and §8): a
component offers a service whole (implements the members of every
interface the service lists — produces their signals/events, accepts their
commands/queries) and requires each interface it consumes, one per line. An
inline-shape service is required by its dotted name, which names its one
interface. The verbs differ because the sides differ: the service is the unit of
publication and addressing, the interface the unit of contract. A requires
line never names a provider; the compiler resolves it to the one component that
offers the owning service. A pure behaviour (model, rmdl) knows nothing of any
of this — it is a contract-blind reaction; binding a reaction to a component's
interface members is reserved until rmdl is scheduled (rsdl §12). This keeps the
layers clean: ridl declares contracts, rmdl computes, rsdl connects.
15. Conventions
- Signals for state, events for occurrences — if you are tempted to publish an "event" carrying full current state, it is a signal
- Commands mutate, queries read; observable command outcomes return as state (signals), not as return values
- Name signals and fixed values as nouns (
currentSpeed), events as past-tense occurrences (doorOpened), commands as imperatives (setGear), queries asget…/stream… - Reuse error types across queries whose failure domains genuinely coincide —
not one
error enumper query by reflex; compose several with anerror union - Prefer
@[min..max]change-driven signals over strict@Xmsunless a consumer genuinely needs isochronous samples (rmdl clocks do)
A long-running operation is a triple, not a construct. An operation that runs long enough for a consumer to watch it — a calibration, a software update, a route computation, a long user action at the person boundary — is written as three interactions on one interface:
interface Calibration {
command startCalibration(mode: CalMode) @[..50ms] // the goal
signal calibrationProgress : CalProgress @[..1s] // the feedback
event calibrationFinished : CalOutcome @[..1s] // the result
}
The command carries the goal and its response bound covers acceptance alone
(§9.3) — starting the work, not finishing it. The signal carries progress as
state, so a consumer that subscribes late reads where the operation is now
(§4.4) rather than missing the steps that passed before it subscribed. The
event carries the outcome once. Where the result must be fetchable afterwards,
add a query beside the event rather than replacing it: an event is not
replayed (§5.1). Cancellation is a second command.
This is convention, and deliberately not sugar. An action keyword would
generate exactly these three interactions with exactly these ordinals, so it
would add no fact to the IR and no obligation a consumer could rely on — the
general form §4.1 deletion test rejects it. It would also fix three decisions
the author should make per operation: whether progress is state or occurrences,
whether the outcome is an event or a query, and what each of the three bounds
is. What would earn the sugar is a second fact a triple cannot express — a
relation between the three declarations that a generator must know — and the
mechanism for that is the correspondence-obligation pair (§3.3), not a keyword.
16. Diagnostics
Coded RIDL-, grouped by hundreds, same lifecycle rules as typl §16 (codes
never renumbered or reused). The tables below are the RIDL- profile codes
only. A .ridl file also draws the two namespaces no profile owns — FORM-
(surface syntax: lexical, parse, and the attribute-block rules) and MANI- (the
manifest). Both are tabulated once in the family overview §7 and are not
restated here.
16.1 Timing (RIDL-1xx)
| Code | Rule | Severity |
|---|---|---|
| RIDL-100 | signal or event without a timing annotation — default [100ms..1000ms] (or configured [defaults].timing) applied | warning; error if active profile requires explicit timing |
| RIDL-101 | timing range @[X..Y] with X > Y, also an @[X..] on a command or a query whose default maximum is shorter than X (§9.3), unless the range did not parse whole: such a range draws FORM-101 and no RIDL-101 from the default maximum; a range with a bound that drew FORM-102 draws no RIDL-101 | error |
| RIDL-102 | zero or negative timing duration | error |
| RIDL-103 | strict-periodic @Xms on a kind other than signal (§9.2, §9.3) — widened from event only by ADR-0015 | error |
| RIDL-104 | explicit return type on a command | error |
| RIDL-105 | query returning () | error |
| RIDL-106 | timing annotation on fixed, or attribute block on fixed — fixed is the one kind that carries no timing (§9) and no attribute block (§8); narrowed by ADR-0015 | error |
| RIDL-107 | type declaration inside an interface or service body — raised at parse time, where the declaration is recognised and recovered | error |
| RIDL-108 | degenerate timing range @[X..X] — the rate floor equal to its staleness bound (§9.2); every kind that admits the range; also an @[X..] on a command or a query whose default maximum equals X (§9.3), unless the range did not parse whole: such a range draws FORM-101 and no RIDL-108 from the default maximum; a range with a bound that drew FORM-102 draws no RIDL-108 | warning |
| RIDL-109 | signal payload has no derivable init and no = value override (§4.4) | error |
| RIDL-110 | signal = value init override violates the payload constraints (kind, finite float backing domain, range, step, length, pattern, enum discriminant, or supported value spelling) | error |
| RIDL-112 | command or query takes the default response bound (§9.3) — a bare declaration, or the half-open @[min..]; [defaults].command_timing or query_timing, else [..1s] or [..3s], applied | warning; error if active profile requires a response bound |
Init validation — RIDL-110. Signal overrides use the same declared-init validation as typl types and struct fields (typl §5.8). Literal kinds and resolved constant kinds must match the backing, and unresolved references are invalid; numeric values must satisfy range and step constraints, and floats must fit the finite binary64 backing domain; strings must satisfy length and pattern constraints. An enum override must be an exactly integral declared discriminant. Struct, union, tuple, collection and enum-set overrides are rejected because the init syntax defines no value spelling for them. This closes the M2 portion of driftsys/ridl#172; the other items in that issue retain their own dispositions.
16.2 Streams (RIDL-2xx)
| Code | Rule | Severity |
|---|---|---|
| RIDL-201 | stream <T> on a signal or event payload | error |
| RIDL-202 | stream element type not a named type, string, or bytes | error |
16.3 Contracts and Errors (RIDL-3xx)
| Code | Rule | Severity |
|---|---|---|
| RIDL-301 | require or ensure on signal, event, or fixed | error |
| RIDL-302 | ensure on command | error |
| RIDL-303 | fallible query return with no success path — a bare error type in return position, or an error-typed left arm of an inline T | E | error |
| RIDL-304 | error-typed or result-union parameter on a command or query — failure flowing toward a provider | warning |
| RIDL-305 | ensure clause that never references result | warning |
| RIDL-306 | require/ensure expression outside the guaranteed subset (expr-core specification §8) | error |
| RIDL-307 | contract-error category name declared in an error enum (INVALID_VALUE, …) | warning |
| RIDL-308 | named result union in query return position — the inline T | E spelling is canonical there (§10.1) | warning |
16.4 Evolution and Profile (RIDL-4xx)
| Code | Rule | Severity |
|---|---|---|
| RIDL-401 | interaction re-declared under a reserved name | error |
| RIDL-402 | duplicate interaction name in one interaction body — an interface or a service's inline shape | error |
| RIDL-403 | behaviour, user-interaction, or architecture declaration in a ridl context | error |
| RIDL-404 | query named like a mutation — the name begins with set, reset, clear, apply, write, or update followed by an upper-case letter (setGear, resetCounters) | warning |
| RIDL-405 | one error type shared across unrelated failure domains — it is the failure arm of queries in 3 or more interaction scopes (heuristic) | info |
| RIDL-406 | payload struct re-declares envelope metadata (§3.1) — a signal or event payload struct declaring timestamp, time, seq, seqNo, sequence, sequenceNumber, frameCounter, or frameNo; domain time or a domain counter distinct from transport metadata is legitimate | info |
| RIDL-407 | interaction, struct field, or union arm ordinal changed against the published baseline (§11; typl §7.4) — the desk-time drift check, emitted by ridl check, never by ridlc; for a field or arm the desk reads the verdict ridl diff gates on: an insertion, a removal (with or without a tombstone, until the diff reads a body's reserved entries), a reorder or tombstone shift in an edit that adds or removes no member, and an append beside such a change each draw it, and an append alone does not — even a struct field ridl diff reports breaking for its type (typl §7.4), which moves no ordinal — except an arm added to a result union (ADR-0008 decision 4); an enum value's or enum-set bit's reorder changes nothing on any wire (typl §8, §9; ridl diff reports it as enum_reordered, compatible) and draws no warning | warning |
| RIDL-408 | interaction removed, its tombstone dropped or moved, or its retired name redeclared — refused at publication (§11) — an interaction removed with no tombstone, retired with a tombstone at an ordinal other than its own, whose existing tombstone was dropped, or whose retired name is declared live again; emitted by ridl baseline alone, for the interaction level only (the interface level is the lock's — RIDL-409 at the build, RIDL-411 and RIDL-412 at publication — and a whole service removed is not refused, §17.14), which refuses to replace a baseline whose record of the ordinal would be lost | error |
| RIDL-409 | live interfaces.lock entry with no declaration (§11) — the interface it numbers was renamed or removed and the lock does not record which; emitted by the compiler on the entry's own line of the lock file, ridlc and ridl alike; the fix is ridl lock <pkg> --retire Old when the interface is gone, or ridl lock <pkg> --rename Old=New when a declaration without an entry is the same interface under a new name — the message names --retire alone when the package has no declaration without an entry, and ridl check adds a label naming the one --rename when the published baseline shows exactly one declaration without an entry with Old's shape | error |
| RIDL-410 | interfaces.lock is malformed — no next line, next not above every entry's number, one number on two entries, one live key on two entries, or a line that does not parse, git conflict markers included (§11) — emitted by the compiler on the offending line of the lock file, ridlc and ridl alike; resolve the conflict or restore the file from version control, then run ridl lock | error |
| RIDL-411 | provisional interface number refused at publication (§11) — an interface in the snapshot ridl baseline is about to publish has no entry in interfaces.lock; a provisional number is no identity, so the snapshot would record nothing a later comparison can hold the interface to; emitted by ridl baseline alone, at the declaration's name, for a first publication as for a replacement; run plain ridl lock to record the number, then publish | error |
| RIDL-412 | published interface number dropped without a retired entry (§11) — a number the published baseline holds is absent from the fresh snapshot and not among its interfaces.lock retired entries, which is a lock line deleted by hand (a live entry with no declaration is RIDL-409 at the build) or a package or a whole unit removed without retiring its numbers; publishing would lose the only record that the number was allocated; emitted by ridl baseline alone, for a published number other than 0 (a snapshot published before the lock existed carries 0 and is matched by name); restore the entry's line from version control, with retired after the number when the interface is gone, or, for a whole unit removed on purpose, delete that unit's snapshots from .ridl/baseline/ | error |
| RIDL-413 | parameter name declared twice in one parameter list (§6.1, §7.1) — a name identifies one parameter; distinct from RIDL-149, which fires only when the two source names collide after a pinned name transform, because these two source names are already the same | error |
| RIDL-414 | interface members form disconnected type-sharing groups | info |
| RIDL-416 | interfaces.lock in a subdirectory of a unit is not read — the unit's lock is the file beside its ridl.toml | warning |
| RIDL-417 | .rxdl or .rmdl file is not compiled: its profile is not supported — the loader compiles .typl, .ridl and .rsdl files and the rxdl and rmdl profiles have no implementation; emitted by the loader on a .rxdl or .rmdl file in a package directory, on disk or as an overlay, once per file. A lone .rxdl or .rmdl entry is a load error instead | warning |
| RIDL-140 | duplicate service name across the workspace — the service catalog is a flat global namespace | error |
| RIDL-141 | service names a type that is not an interface — and has no inline shape | error |
| RIDL-143 | service publishes an internal interface — a global published address must name a public shape (§14.5) | error |
| RIDL-144 | duplicate member name across a service's interfaces — flat addressing (§14.5) cannot give service.member two referents | error |
| RIDL-145 | the same interface named twice in one service — its own code, so the mistake is one diagnostic rather than one RIDL-144 per member; the duplicate listing holds no slot | error |
| RIDL-146 | retired by the lock (2026-09-15; ADR-0015 decision 18 as amended) — was: interface re-declared under a service-level reserved name; a service's list holds no tombstone | retired |
| RIDL-147 | retired by the lock (2026-09-15; ADR-0015 decision 24 as amended) — was: two shapes of one service whose interface names collide even though their references differ; a binding keys the ordinal spaces on (unit, interface number), so two names may collide | retired |
| RIDL-148 | retired by the lock (2026-09-15; ADR-0015 decision 24 as amended) — was: service-level reserved tombstone without an interface name; reserved in a service's list is a parse error | retired |
| RIDL-149 | two names in one scope collide after a pinned name transform — no transform is injective and no case-folding transform can be, so two names distinct in source can project to one identifier; scoped to the members of one interface, the parameters of one interaction, the fields of one struct, the arms of one union, and the values of one enum. The first three are checked under snake_case; a union's arms are checked under snake_case and camel_case both, because an arm reaches both namespaces and the two collision sets are incomparable; an enum's values are checked under pascal_case alone, because its collision set contains snake_case's. The message names the transform that collided (ADR-0016 decisions 3 and 4, as amended) | error |
| RIDL-150 | payload above the payload ceiling or with no finite bound — the largest encoded size of a signal, event, command or query payload, in an encoding that any [backend.<name>] table of the workspace manifest enables through encodings (FlatBuffers when no table names one), is above 16,256 bytes or has no finite bound; a fallible reply with a success type and an error type is checked through both types; reported on the interaction that carries the payload, so a type no interaction carries is not reported. The payload ceiling is 16,256 bytes, which the codecs do not yet enforce (driftsys/ridl#996; ADR-0030 decisions 2 and 7) | warning |
| RIDL-151 | payload above its declared limit or with no finite bound — a [limits] entry of the unit gives the interaction a limit (its [limits.interaction] entry, else the entry of its kind, else default), and the largest encoded size of its payload, in an encoding that any [backend.<name>] table of the workspace manifest enables (FlatBuffers when no table names one), is above that limit or has no finite bound; a fixed is sized too, which RIDL-150 does not do; a command or query checks its request and its reply against its one limit; reported on the interaction. Where no entry gives a limit, only RIDL-150 applies. A payload above the 16,256-byte ceiling under a declared limit draws both RIDL-150 (warning) and RIDL-151. A package that RIDL-150 skips because it has an error, or depends on a package that has one, gets neither RIDL-151 nor RIDL-152. An error in a manifest stops RIDL-150 in every package, and RIDL-151 and RIDL-152 only in the packages beneath that manifest (ADR-0030 decisions 12 and 14) | error |
| RIDL-152 | a [limits] entry names no interaction of its unit — a [limits.interaction] key must be Interface.member, the interface name, or the last segment of the dotted name of an inline service, without the package, then ., then the member; a key may name an interface of any package of the same unit; a key that names an interface of another unit, one written package.Interface.member, and one whose name is shared by two shapes of the unit (reported as ambiguous) are reported on the key in the unit's ridl.toml (ADR-0030 decision 12) | error |
| RIDL-153 | type too large for podbuf — a struct, tuple or union whose largest podbuf encoding, computed from its declared bounds, is above u32::MAX (4,294,967,295) bytes, because a u32 offset cannot address all of it; reported on the declaration name, naming the type, or, for the argument struct a call with several parameters induces and for a tuple reply, on the interaction, naming its request or reply. Raised only when a [backend.<name>] table of the workspace manifest lists podbuf in its encodings. A type that has no bound only because an array or map inside it is past a descriptor limit draws RIDL-154 instead | error |
| RIDL-154 | array or map exceeds the podbuf descriptor limits — a field with an array or map that declares more than 65,535 elements, or whose element or entry is larger than 65,535 bytes, because the descriptor records the count and the element size as u16; reported on the field, naming the type and the field. Raised only when a [backend.<name>] table lists podbuf in its encodings | error |
| RIDL-155 | deprecated field has no valid podbuf value — a non-optional struct field with a @deprecated doc tag, no declared init value, no derivable init value (typl §5.8) and a type that excludes 0, so the podbuf encoder has no valid value to write for it; reported on the field, naming the type and the field. Raised only when a [backend.<name>] table lists podbuf in its encodings | error |
| RIDL-156 | struct field order wastes podbuf padding — a struct whose declared field order lays out in a podbuf head larger than the head of its fields sorted by descending alignment (declaration order kept among fields of equal alignment), when no baseline snapshot declares the struct; with no baseline every struct is unreleased. Reported on the struct name, naming both head sizes and the saving order. Raised by ridl check only, and only when a [backend.<name>] table of the workspace manifest lists podbuf in its encodings. Lint podbuf-padding-order | info |
RIDL-141 and RIDL-143 apply per interface in the service's list (ADR-0015 decision 18): neither rule changed when composition arrived, only the span each reports against — the offending list element, not the reference position as a whole.
(Classifying a reorder, insert, or delete as breaking or compatible is
ridl-diff's jurisdiction, not the compiler's — typl §7.4 discussion applies.
RIDL-407 is the desk-time warning that an ordinal moved at all: it is emitted by
the ridl check facade against a workspace-local baseline, which is outside
ridlc's source-to-IR function, and it neither classifies nor gates.)
A public interface exposing an internal typl declaration is TYPL-005
(typl §3.3), not a RIDL code: it is the same rule the vocabulary layer states,
applied to the interaction positions — a signal, event or fixed payload, a
command or query parameter, a query return, a tuple-return field, an array or
stream element, either arm of an inline T | E, a collection length bound, and
a constant or enum type named by a require/ensure clause. A service is
RIDL-143 instead, because what leaks there is an interface rather than a type
and a service takes no internal modifier.
The two clause kinds do not expose the same names, because they do not bind the
same names: a require reads the interface's own signals (§13), so a signal
spelled like a package constant shadows it, while an ensure reads no signal
and the same spelling is the constant.
16.5 Families and Obligations (RIDL-5xx)
| Code | Rule | Severity |
|---|---|---|
| RIDL-501 | interaction kind not admitted by its family (§3.2) — an operation on presentation, a continuous value on intent, an occurrence on control, and the rest of that table | error |
| RIDL-502 | operation shape's parameter rule violated (§3.2.1) — parameters on activate/toggle/dismiss, or not exactly one on select/adjust | error |
| RIDL-503 | intent operation with no shape — the shape set is closed and has no generic form (§3.2.1) | error |
| RIDL-504 | non-dispatch interaction declaring no correspondence obligation (§3.3) — a family tag with nothing behind it | warning |
| RIDL-505 | availability predicate on an intent interaction referencing state the consumer cannot evaluate (§3.4) | error |
| RIDL-506 | obligation attribute on a dispatch interaction — between software peers the datum is the truth, so there is nothing to correspond to | error |
| RIDL-507 | derives names an interaction in a family that cannot be its reference (§3.3 direction rule) | error |
| RIDL-508 | a control interaction with no paired acquisition observing its effect (heuristic, §3.3) | info |
17. Open Questions
Each item below is either resolved — its rule now lives in the section it changes, and the row is struck through with a record of what was asked and what answered it — or deferred to a named version, with the observation that reopens it. After the disposition pass of 2026-09-16 three remain deferred, all to ridl v0.3: items 8, 10 and 11. Nothing in this section is normative; the sections it points at are.
-
Selective broadcasts.Resolved — subscription granularity is the interaction (§5.1). The question was whether Franca'sbroadcast selective(per-client delivery) and SOME/IP eventgroups (subscription granularity below the interface) need a ridl surface. Neither is a contract term under the general form §4.1 deletion test. Who receives an occurrence is not the provider's to declare: where the recipient is part of what is being said, it is part of the payload, and a contract that names its consumers is no longer one contract. Grouping for subscription efficiency is a transport-level arrangement a binding derives — the same contract is realized with one eventgroup per interface or with several, and no consumer can tell which from the contract — so it belongs to the binding and to rsdl. §5.1 states the rule and both exclusions; Appendix B keeps the eventgroup mapping. -
Interaction-set reuse.Answered by ADR-0015 — composition, not mixins. Flat interfaces (§14.1) mean recurring interaction patterns — the heartbeat, version and diagnostics triad — used to be duplicated, and the candidate recorded here was compile-time flattening mixins (include DiagBlock). Composition (§14.5: a service carries a comma-separated list of interfaces) is the better answer, and the reason is the identity model: mixins flatten, and ordinals are per-interface (§11), so one shared block folded into three interfaces would get three unrelated ordinal sets and editing the block would renumber all three. ComposingDiagBlockbeside each service's own interface leaves every ordinal space intact and independent of what it sits beside. The accepted cost is §14.5's: interfaces written to be composed must not collide on member names (RIDL-144). -
Signal groups / atomic multi-signal updates.Closed by ADR-0015 — the struct idiom is confirmed. The provisional answer here — a coherent multi-value sample is "make the payload a struct" — holds, and the rule it sits inside is now stated in §14.5: the signals of one provided interface are published coherently, and whether a consumer observes the grouping depends on the binding. A struct payload is one payload on one channel, so it is atomic on every transport — the form to use where the guarantee must survive an arbitrary binding. Where a binding cannot preserve the interface grouping, that is a deploy-time constraint (§14.5), not a weaker contract. -
Actions (long-running operations).Resolved — documented convention, and no sugar (§15). The question was whether the goal + feedback + result composition — ROS 2's action, and the same triple the person boundary needs for a long user operation — is convention or deserves sugar. §15 now carries the worked triple: acommandfor the goal whose bound covers acceptance alone, a progresssignalso a late subscriber reads where the operation is now, a completioneventfor the outcome, and aquerybeside it where the result must be fetchable afterwards. Sugar is not taken: anactionkeyword would expand to exactly those interactions with exactly those ordinals, so it adds no IR fact and no obligation a consumer could rely on — the §4.1 deletion test — while fixing three decisions an author should make per operation. What would earn it is a declared relation between the three declarations, and the mechanism for that is the correspondence-obligation pair (§3.3), not a keyword. -
QoS beyond timing.Answered by ADR-0015 — the absorption principle. The earlier wording here — QoS "deliberately absent from the contract" — described exclusion, and reading it literally is what produced the recurring request for a QoS block. The boundary it drew was correct; the description of it was not. The principle is:ridl expresses QoS as semantic obligation, never as a transport knob. A binding maps each obligation onto its native QoS; a transport lacking the mechanism either satisfies the obligation by construction or fails at bind time.
That is what preserves the §1.1 transport-neutrality claim: the same contract binds to SOME/IP, proto/gRPC, DBC, AIDL, MQTT, or an in-process broker without carrying any of their vocabulary, because it carries none of their mechanisms — only the outcomes a consumer may rely on. Mapping the contract-bearing DDS policy set against ridl:
DDS policy ridl verdict RELIABILITY the interaction kind is the reliability class (§3) covered by construction DURABILITY (TRANSIENT_LOCAL) §4.4 last-value guarantee; events explicitly carry none (§5.1) covered DEADLINE §9 maxstaleness boundcovered LIVELINESS §9 freshness SLO, §10.3 detection covered TIME_BASED_FILTER §9 minrate floor, derived as debouncecovered LIFESPAN §9 max, derived as event TTL (§5.2)covered OWNERSHIP / OWNERSHIP_STRENGTH §4.2 — exactly one owning provider per flow covered by construction DESTINATION_ORDER §3.1 envelope timestamp and per-channel sequence number covered RESOURCE_LIMITS (payload) typl bounded collections and length bounds covered, in the type system HISTORY (KEEP_LAST N) §4 signal is latest-value; §5.1 rules out event replay out of scope, deliberately DURABILITY (PERSISTENT) persist, reserved by ADR-0008 decision 3reserved LATENCY_BUDGET a delivery-delay hint permitting batching; the DDS specification is explicit it is not a commitment no analogue needed, none wanted RPC reply timeout (DDS-RPC) @[..max]response bound oncommand/querycovered — §9.3 (ADR-0015) The table is the contract-bearing subset — PARTITION, TRANSPORT_PRIORITY, the writer/reader lifecycle policies and the reader-side content filters are deployment or reader-local concerns (rsdl). The last row was the one genuine gap, and it is not a core DDS QoS policy at all: the reply timeout lives in DDS-RPC, and its relatives are gRPC's deadline, SOME/IP's configured timeout, and AIDL's transaction timeout. §9.3 closed it.
-
Mid-stream invalid elements(§12.4). Resolved — the element type decides, and it already can. The question was quarantine-and-continue versus abort, and whether the choice is a per-binding policy or a contract-level one. It is neither a policy nor a new construct. An element violating its typl constraints is a Stratum 2INVALID_VALUE, a stream lives inside an RPC (§12.3), and that stratum's RPC surface ends the call (§10.2) — so the stream ends, which is also what §4.5 requires: a consumer that silently receives fewer elements than were sent cannot tell a complete transfer from an incomplete one. A producer whose stream must survive a failing element declares that failure as data by making the element type a result union, which moves it to Stratum 1, where it does not end the call and the consumer handles it element by element. What the element type records is which stratum a failure lands in; whether the producer closes after an error element is behaviour, not contract, and a consumer writes for both because a stream may end at any point in any case. §12.4 states both halves, and the word "quarantined" is gone from it. -
Reflection/discovery service.Resolved — no normativeridl.reflectpackage. The question was whether the spy/control bridge (concept note §9.2) needs a generated meta-interface in the language, to be specified once the IR specification landed. It landed (ADR-0014), and with it the answer moved out of the language: the enumeration surface is the catalog descriptor — a per-unit artifact carrying each interface, its members and its numbers in a form an engine reads without decoding, specified in the runtime-descriptors design and built (described in the catalog descriptor design record), whereridl describeprints it at the desk. Nothing about it is a language construct, and a bridge that exposes that catalog at runtime is an ordinary component with an ordinary ridl service, written where the bridge lives; the bridge itself is a runtime concern the roadmap parks. No name is reserved for it. -
Failure management and safety/HA properties (§10.4). Deferred to ridl v0.3. Failsafe states, fallbacks, degraded modes, health/halt management — a safety/quality-management layer over the runtime's total failure detection. Likely split: supervision topology and fallback wiring in rsdl; supervision hooks in the
ridl-rtruntime spec; assurance policy via@labelsprofiles. The family may later grow declarative property surfaces for this — e.g. a signal's failsafe value in ridl, degraded-mode models in rmdl, failover and voting requirements over the redundant provider sets rsdl derives (rsdl §7) — always properties (what must hold), never mechanisms (how). Reopened by a consumer whose safety argument needs a property the §10.4 division of labour cannot carry. It needs its own document before any keyword lands; start via profile vocabulary, promote to syntax only when earned. -
uxdl divergence budget.Closed by ADR-0012. The question was which rules here are shared with uxdl and which are ridl-only. The answer is that none diverge: uxdl is retired, every rule in this document is family-neutral, and what a non-dispatch family adds is the four correspondence obligations rather than a different rule set. Transports remain a binding concern, not a family one. -
The explicit one-time read. The
acquisitionrequest/response cell is reserved (§3.2). Deferred to ridl v0.3. What separates it from a continuous acquisition is maintenance, not duration: a continuous acquisition obliges the provider to hold a current value, and forcing an on-request read into it would make the binding poll forever for something wanted only occasionally. Periodic and on-change acquisition need no new cell —@[min..max]already distinguishes rate floor from staleness bound (§9.2). Reopened by a world-boundary contract that needs an on-request read of a quantity too expensive to hold current, such as a diagnostic measurement. Until then the cell stays reserved and the combination is RIDL-501; rxdl §11 carries the same question and moves with this one. -
The influence quantity (VIM). A quantity that does not affect the measurand but does affect the relation between indication and result — ambient temperature shifting a pressure sensor's characteristic. It is neither uncertainty, which is dispersion, nor the relationship, because it is what perturbs the relationship. Whether it becomes a fifth obligation or a qualifier on the relationship (§3.3) is deferred to ridl v0.3, because the obligation attributes are themselves working spellings that settle with the obligation implementation, and deciding this one ahead of them would fix a shape with nothing to check it against. Reopened by an acquisition contract whose transfer function depends on a declared environmental condition.
-
Whether the publication gate should refuse a moved existing tombstone(§11). Resolved — it does not, and §11 says why. A baseline that retires an interaction withreserved bat ordinal N, replaced by a source that retires it withreserved bat ordinal M, reportsInteractionReordered, notInteractionRemoved, and the RIDL-408 gate readsInteractionRemovedchanges and the interaction-levelReservedNameRedeclared, neverInteractionReordered. The same is true of a live interaction inserted mid-body or reordered, so a moved tombstone is not a special shape. The gate is not a second compatibility gate: it protects the record of a retirement, and a move leaves that record complete — every name in the published snapshot still carries an ordinal. The move is breaking,ridl diffreports it and exits 1, andridl checkwarns with RIDL-407 at the desk. §11 now states the division of labour between the two. -
A whole package removed or renamed bypasses the publication gate(§11). Resolved — the interaction level does not refuse it, the interface level does.ridl-diffreports oneDeclRemovedfor a package whose whole unit is present only on the baseline side and does not descend into it; for a package removed from a unit that remains it reports each shape on its own line. Either way the RIDL-408 gate sees no refused interaction, and publication deletes the package's snapshot along with every ordinal record it held. The interface numbers the package held are refused at publication (RIDL-412) unless the unit'sinterfaces.lockretires them or another package of the unit declares them. This reverses the earlier resolution, which rested on the language having no package-level retirement: with one catalog per unit, the unit's lock retires the numbers of a package removed from a unit that remains. For a whole unit removed or renamed, the refusal catches the accidental case (a moved directory, a changed manifest) that would otherwise lose the record silently; the deliberate case costs one deletion of that unit's snapshots from.ridl/baseline/, which the message names. What would reopen the question is a unit-level retirement construct.ridl diffreports the removal as breaking. -
A whole service removed, or a service whose form switches, bypasses the publication gate(§11). Resolved — neither is refused, because neither moves an identity.ridl-diffreports oneDeclRemovedfor a service present only on the baseline side and oneServiceChangedfor a service whose form switches between inline and named (ADR-0015 decision 15), neither of which the RIDL-408 gate reads. The interface half of this item closed with the lock (2026-09-15): an interface is identified by itsinterfaces.locknumber, so a whole interface removed with its entry live fails the build (RIDL-409), and a published number the fresh snapshot neither carries nor retires is refused at publication (RIDL-412) — an inline shape's number included, so an inline-form service removed whole is refused for its shape's number unless the entry is retired, while the service itself is not refused. The service half closes the same way: a named-form service carries no number of its own, its list is a set (§14.5) the gate does not read, and a form switch that leaves the inline shape's lock entry behind with no declaration is already RIDL-409 at the build — with the entry renamed instead, it is the sanctioned refactor, still breaking on the wire for the fallible-return identity (ADR-0008 decision 4) and reported so byridl diff.
Appendix A — Full Example
package veh.cluster
import veh.common.Speed
import veh.common.Temperature
import veh.common.MAX_SPEED
import veh.common.GearPosition
import veh.common.WarningFlags
// --- vocabulary local to this contract (typl declarations, package level) ---
struct DoorPayload {
sensorId : integer [0..15]
isOpen : boolean
}
struct DiagFilter {
severity : integer [0..5]
category : Label?
}
struct FaultEvent {
code : integer [0..65535]
message : Message
timestamp : Timestamp
}
error enum DiagError { // failure vocabulary — typl §10.1
FILTER_INVALID = 0
STORAGE_BUSY = 1
ACCESS_DENIED = 2
}
struct FaultPage {
faults : [FaultEvent; 0..64]
}
// --- the contract ---
/**
* Main vehicle status interface.
* @labels SIL_B, CAL_2, PRIVATE
*/
interface VehicleStatus {
/// Current vehicle speed — isochronous, drives rmdl clocks downstream
signal currentSpeed : Speed @10ms
/// Engine temperature — change-driven, 100ms freshness SLO
signal engineTemp : Temperature @[20ms..100ms]
/// Active warnings — last-value delivered on subscribe (§4.4)
signal warnings : WarningFlags @[50ms..1s]
/// Raised on every door state change; stale after 500ms
event doorOpened : DoorPayload @[50ms..500ms]
/// Request a gear change — outcome observed via currentGear, not returned;
/// callers throttle to one request per 20ms, acceptance within 200ms (§9)
command setGear(position: GearPosition) [
require position != GearPosition.PARK || currentSpeed == 0.0
] @[20ms..200ms]
reserved resetCounters // retired ordinal — never reused
/// Sliding-window average — replied within 50ms (§9)
query getAverageSpeed(window: Duration): Speed [
require window > 0ms
ensure result >= 0.0
] @[10ms..50ms]
/// Fault history as a finite stream
query streamFaults(filter: DiagFilter): <FaultEvent> @[..1s]
/// Paged fault snapshot — fallible query via inline `T | E` (§10.1)
query getFaultPage(filter: DiagFilter): FaultPage | DiagError @[..100ms]
fixed softwareVersion : Version
fixed capabilities : [Label; 0..32]
}
Appendix B — Codegen Targets
Interaction mapping per target. Type/width mapping is typl Appendix D; the two compose.
| ridl | SOME/IP | proto3 / gRPC | AIDL | DDS | MQTT / AsyncAPI |
|---|---|---|---|---|---|
signal | field notifier (+ auto-derived getter from last-value cache) | server-streaming RPC or pub/sub sidecar | callback / oneway listener | topic, TRANSIENT_LOCAL durability, DEADLINE = max | retained message on channel |
| coherent-set delivery (§14.5) | per-field only — no grouping across notifiers | per-message only — no grouping across streams | per-parcel only — one callback per signal | GROUP-scope PRESENTATION with coherent_access | per-message only, per channel |
event | event (eventgroup) | server-streaming RPC | callback | topic, VOLATILE durability | non-retained publish |
command | request w/ empty response (= ack, §6.1) | unary RPC → Empty (= ack) | oneway + runtime ack shim | reliable-QoS request topic (DDS ack) | publish QoS 1 (puback = ack) |
query | request/response method | unary/streaming RPC | method | request/reply (RPC over DDS) | request/reply channel pair |
RPC response bound, max (§9.3) | derived request/response timeout, supervised by the binding | server-side deadline; per-call client override stays Stratum 3 | transaction-timeout supervision in the runtime shim | DDS-RPC reply timeout | reply-channel timeout |
RPC call throttle, min (§9.3) | admission-side rate limit at the provider | server-side admission rate limit | admission check in the runtime shim | requester-side rate limit | inbound publish rate limit |
fixed | field with getter only | unary getter RPC (cacheable) | constant/property | — | retained provisioning channel |
| result-union error arm (§10.1) | method return code table | google.rpc.Status + typed detail | ServiceSpecificException code | reply union arm | error payload schema |
| ordinals (§11) | method ID = ordinal; event ID = ordinal + event flag; eventgroup = interface, keyed on (unit, interface number) | RPC name (identity is nominal) | in the runtime's binder contract, as code or field | topic name suffix | channel path segment |
| Stratum 2 (§10.2) | E_MALFORMED_MESSAGE / E_NOT_OK / E_UNKNOWN_METHOD | INVALID_ARGUMENT / FAILED_PRECONDITION / UNIMPLEMENTED | IllegalArgumentException mapping | reply status | error topic convention |
Notes. The command delivery acknowledgment (§6.1) is realised with each
transport's cheapest confirmed primitive, as shown in the command row — where a
transport's fire-and-forget primitive has no confirmation (SOME/IP fire&forget,
AIDL oneway), the binding uses the confirmed variant or adds a runtime shim;
the ack never surfaces in generated application APIs as a return value. The AIDL
column names what a runtime's binder contract can use: ridl specifies no Binder
layout and no transaction code, so the ordinal stays stable whether that
contract carries it as a transaction code or as a field
(frame specification §11.2, which records that
driftsys/ridl#516 reversed a per-interface binding with the ordinal as the
transaction code). The §4.4 last-value guarantee is what makes the SOME/IP
getter derivable — a ridl signal generates the full SOME/IP field triple minus
setter (setters are explicit commands, by design). DBC/CAN binds signals only
(event/command/query do not exist on classic CAN — profile error), and a
coherent set survives only within one frame (§14.5); a set that must stay
simultaneous on every target is one struct payload (§17.3). The WASM component
target maps query streams to WIT stream<T> (native in WASI 0.3) — the
rmdl-behind-an-interface story stays whole in the browser.
Appendix C — Formal Grammar (EBNF)
The ridl profile adds to the typl grammar (typl Appendix E — definition gains
interface_def and service_def; everything else inherited):
definition = [ "internal" ] ( typl_definition | interface_def )
| service_def ; (* a service takes no `internal` — §14.5 *)
interface_def = doc_comment? "interface" CamelCase_id "{" { interaction sep? } "}" ;
service_def = doc_comment? "service" dotted_name
( ":" service_shape { "," service_shape } [ "," ]
(* the list, a set — §14.5; commas are
REQUIRED between shapes, the one
non-optional separator in the family
(ADR-0015 decision 13): the list has
no closing token, so it ends where
the next declaration begins *)
| "{" { interaction sep? } "}" ) ; (* one inline shape — §14.5; never both
forms (ADR-0015 decision 14) *)
service_shape = type_ref ; (* a composed interface — §14.5; the
list is a set with no tombstone: a
removed interface is retired in
`interfaces.lock` (§11) *)
dotted_name = camelCase_id { "." camelCase_id } ; (* reverse-domain global name,
every segment lowercase — §14.5 *)
interaction = signal_def | event_def | command_def | query_def | fixed_def | reserved ;
signal_def = doc_comment? "signal" camelCase_id ":" type_ref init_value? timing?
attr_block? ;
event_def = doc_comment? "event" camelCase_id ":" type_ref init_value? timing?
attr_block? ;
(* the parser admits an init; writing one is FORM-102 *)
(* untimed → configurable default [100ms..1000ms], §9.1 *)
(* There is no family clause. The family (§3.2) is selected by the
declaring keyword, and `.ridl` spells only `dispatch` — so every
production here is a dispatch interaction. The non-dispatch
spellings, and the operation shapes, are the rxdl reference's;
they lower to these same kinds carrying a family and a shape.
The rules they can violate are still this document's, reported
as RIDL-501 through RIDL-508 (§16.5). *)
init_value = "=" ( literal | SCREAMING_SNAKE_ID ) ; (* bare init override — §4.4 *)
command_def = doc_comment? "command" camelCase_id "(" param_list ")" ( ":" return_type )?
timing? attr_block? ;
(* the parser admits a return type; writing one is RIDL-104, §6.1 *)
query_def = doc_comment? "query" camelCase_id "(" param_list ")" ":" return_type
timing? attr_block? ;
(* range form only on command and query — a strict period is RIDL-103, §9.3 *)
(* no error syntax — a fallible_type return makes a query fallible, §10.1 *)
fixed_def = doc_comment? "fixed" camelCase_id ":" fixed_type init_value? timing?
attr_block? ;
(* the parser admits an init and both annotations; an init is
FORM-102; either annotation is RIDL-106, §8, §9.2 *)
param_list = "" | param { "," param } ;
param = doc_comment? camelCase_id ":" param_type ;
param_type = type_ref | stream_type ;
return_type = type_ref | tuple_type | fallible_type | stream_type ;
fallible_type = type_ref "|" type_ref ; (* inline T | E — §10.1, gf §6.1;
both arms are named types, the
right one an `error` type *)
fixed_type = type_ref | array_type ; (* array_type per typl grammar *)
(* correspondence obligations (§3.3) are ordinary attributes in the tail
`attr_block` — assignment form for tolerance, resolution, lag, span and
`derives`; predicate form for `plausible` and `ensure`. They are legal
only on a non-dispatch family — RIDL-506 *)
stream_type = "<" ( type_ref | "string" | "bytes" ) ">" ;
(* ---------- Timing ---------- *)
timing = "@" duration
| "@" "[" timing_range "]" ;
timing_range = duration ".." duration | duration ".." | ".." duration ;
duration = int_lit ( "us" | "ms" | "s" | "min" | "h" ) ; (* §2.1 *)
(* ---------- Attribute Block ---------- *)
attr_block = "[" { attribute sep? } "]" ;
attribute = "require" expr | "ensure" expr ;
expr = (* expr-core specification §3.1 — the guaranteed subset; §13 *) ;
(* reserved, sep, type_ref, tuple_type, identifiers, literals: typl Appendix E *)
Appendix D — Standards References
Additions over typl Appendix C:
| Reference | Used for |
|---|---|
| AUTOSAR Classic R22-11 | signal/event split (isQueued), CalibrationParameter, signal groups |
| SOME/IP (AUTOSAR PRS) | field/method/event mapping, return codes, IDs |
| Franca IDL | attribute/method/broadcast precedent, error enums, rejected version block |
| OMG DDS 1.4 / DDS-RPC | durability, deadline, liveliness — QoS-in-contract comparison |
| gRPC / google.rpc.Status | status model, streaming RPC |
| Android AIDL | oneway, transaction codes |
| Fuchsia FIDL | method ordinals (hash model rejected), protocol evolution |
| WASM Component Model / WIT | stream<T>/future<T> (WASI 0.3), resource model |
| AsyncAPI 3.0 | channel/operation vocabulary for the MQTT binding |
| ROS 2 | msg/srv/action decomposition |
| Eclipse uProtocol / COVESA uServices | contemporary transport-neutral automotive service layer |
| Eiffel, SPARK Ada | Design by Contract |
Appendix E — Prior Art Survey
What each interface language contributed to, or was rejected from, this design:
| Language | Taken / rejected |
|---|---|
| CORBA IDL | the interface grouping and the cautionary tale: inout params, exceptions as control flow, and interface inheritance all rejected here as evolution and codegen hazards — data-carrying failures are error structs in result unions instead |
| Franca IDL | closest European-automotive ancestor: attributes ≈ signals, broadcasts ≈ events, fireAndForget ≈ command, error enums → typl error types. Rejected: extends, in-language version, selective broadcasts (→ §5.1), deployment .fdepl files (rsdl + ridl.toml instead) |
| AUTOSAR Classic | the signal/event split (isQueued), CalibrationParameter → fixed, signal groups → the struct idiom (§14.5). ridl exists partly to be the legible source ARXML is generated from |
| SOME/IP | field triple (getter derivable from §4.4, setter as explicit command, notifier = signal), return codes → Stratum 2 mapping, method/event IDs → ordinal derivation |
| DDS | the QoS lesson: DEADLINE/durability/liveliness are contract-relevant — ridl promotes exactly the state-vs-occurrence and freshness subset into the language (@, §4.4) and leaves reliability/history to deployment (§17.5). DDS proves both that QoS matters and that 22 orthogonal QoS policies on one topic is too many degrees of freedom for a contract |
| gRPC / proto | streaming model (§12 direction-by-position), google.rpc.Status → Stratum 2 categories, Empty-returning unary → command mapping. Rejected: errors as open-ended status strings — ridl functional errors are closed typl types, and errors are data, not a status channel |
| AIDL | oneway validates fire-and-forget as a first-class kind; transaction-code stability → ordinals |
| FIDL | strict/flexible interaction evolution and hashed method ordinals studied; hashing rejected (renames silently break wire, IDs unreadable from source) in favour of positional ordinals + reserved |
| WIT / component model | stream<T>/future<T> (now native in WASI 0.3) confirm the stream container at the interface boundary; the WASM backend maps ridl streams onto them directly |
| AsyncAPI | naming for the MQTT/broker binding; validates "schema language + interaction language as siblings over one type vocabulary" (its schemas are JSON Schema — structurally the typl/ridl split) |
| ROS 2 | msg/srv ≈ signal/query; actions (goal+feedback+result) written as the composition idiom → §15 |
| OPC-UA | industrial precedent for typed nodes with subscriptions; its reference model is heavier than a contract language needs — profiles/labels carry the assurance metadata instead |
| MQTT | retained messages are the §4.4 last-value guarantee in the wild; Sparkplug's birth/death certificates prefigure provider-activation semantics (initial publish, §4.4) |
| uProtocol / COVESA uServices | the contemporary validation: transport-neutral pub/sub+RPC over SOME/IP·Zenoh·MQTT with proto-defined services. ridl differs by owning the vocabulary layer (typl ranges/units vs bare proto types), timing in the contract, and contracts/behaviour above it |
Appendix F — Coverage Analysis: Other Interface Languages
Method as in typl Appendix G: can ridl express what a contract author reaches for in each major interface language? ✓ covered, ≈ covered differently (usually stricter), ✗ not expressible (deliberate or open).
| Foreign construct | ridl equivalent | Status |
|---|---|---|
| CORBA/Franca attribute (readable, subscribable state) | signal + §4.4 last-value | ✓ |
| attribute setter | explicit command (never implicit) | ≈ deliberate — mutation is always a visible verb |
| Franca broadcast | event | ✓ |
| Franca selective broadcast (per-client) | — (the recipient is a payload field, §5.1) | ✗ deliberate §5.1 |
| Franca/AIDL fireAndForget / oneway | command | ✓ first-class |
| method with error (Franca error enum, SOME/IP return code) | result-union return; error arm → native error channel | ✓ |
| exception hierarchies with payloads (CORBA, Java) | error struct / error union arms in result unions | ≈ deliberate — closed sets, no hierarchies, errors are data |
| gRPC unary / server-stream / client-stream / bidi | query T / query : <T> / command(<T>) or query(<T>) / query(<T>): <U> | ✓ |
| gRPC deadline | @[..max] response bound on command/query (§9.3); the per-call override stays Stratum 3 | ✓ in-contract (ADR-0015) |
| DDS DEADLINE QoS | @[..max] refresh ceiling / freshness SLO | ✓ in-contract |
| DDS durability (TRANSIENT_LOCAL) | §4.4 last-value guarantee (signals) | ✓ normative, not tunable |
| DDS reliability / history depth | — (transport/rsdl) | ✗ deliberate §17.5 |
| DDS liveliness | staleness via freshness bounds + observability | ≈ |
| SOME/IP field get/set/notify | signal (get derived, notify native) + command (set) | ✓ decomposed |
| SOME/IP eventgroups | no equivalent: a consumer subscribes per interaction (§5.1), and Appendix B maps an eventgroup to an interface | ✗ deliberate — grouping for subscription efficiency is the binding's |
| AUTOSAR signal groups (atomic sample) | struct payload on one signal | ≈ idiom, confirmed (§14.5, ADR-0015) |
| AUTOSAR client/server + sender/receiver ports | query/command + signal/event | ✓ |
| AIDL in/out/inout parameters | parameters in, tuple returns out | ≈ deliberate — no out-params |
| FIDL events (server-initiated on protocol) | event | ✓ |
| FIDL strict/flexible evolution | ordinals + reserved + ridl-diff categories | ≈ |
| ROS 2 action (goal/feedback/result) | command + progress signal + result query/event | ≈ composition idiom §15 |
| WIT resource (capability handle with methods) | — | ✗ open — relevant when rmdl/WASM host interfaces mature |
| WIT future / stream | query return / <T> | ✓ |
| AsyncAPI channel + operation | interaction + transport binding | ✓ |
| MQTT retained message | §4.4 | ✓ |
| OPC-UA historical access | — (test/observability plane, not contract) | ≈ relocated |
| service discovery (SOME/IP-SD, DDS discovery) | rsdl topology + the catalog descriptor (§17.7) | ≈ relocated |
interface versioning (Franca version, SOME/IP major/minor) | package version + ridl-diff | ≈ relocated, mechanical |
Verdict. ridl covers the working set of every surveyed interface language
through five kinds plus two orthogonal clauses (timing, contracts) — where the
others reach for per-feature keywords (field triples, selective broadcasts,
exception clauses, QoS matrices), ridl decomposes into the same five primitives,
expresses failure as vocabulary, and pushes tunables to deployment. The honest
gap is WIT-style resource handles; per-client delivery and the action idiom's
lack of sugar are decided exclusions rather than gaps (§5.1, §15), and neither
blocks v0.2. What no surveyed language has that ridl does: timing as first-class
contract with derived freshness SLOs, the three-strata error model with
errors-as-data (no error syntax at all) and derived Stratum 2, typl vocabulary
(units/ranges) under every payload, and one evolution mechanism (ordinals +
reserved + diff) uniform from struct fields to interface methods.
Appendix G — Glossary
| Term | Definition |
|---|---|
| RIDL (capitals) | the platform and family name; ridl (lowercase) is this language, the family's interaction layer and flagship member |
| family | the four languages — typl, ridl, rmdl, rsdl — sharing one grammar, one toolchain, one IR; rxdl is a profile and a spelling layer over ridl, not a fifth language |
| interaction family | who is on the other side of a boundary — dispatch · presentation · intent · acquisition · control (§3.2). A property of the declaration, a closed enum in the IR |
| correspondence obligation | what a non-dispatch contract must declare about the gap between a datum and its referent: relationship, uncertainty, latency of correspondence, failure to correspond (§3.3) |
| reference / realisation | in a non-dispatch family, the causally upstream and downstream sides; the realisation must correspond to the reference within declared bounds (§3.3) |
| operation shape | activate · toggle · select · adjust · dismiss — a closed set constraining an intent operation's parameters and carrying machine-readable gesture semantics (§3.2.1) |
| indication | metrology's term (VIM) for the value a measuring instrument provides; a presentation-family value is one (§3.3) |
| acquisition span | the interval over which a value acquired by sweeping corresponds to the world, as against an instant offset (§3.3) |
| profile | the restriction of the family grammar accepted by a file extension; .ridl accepts interactions + typl declarations; .rxdl is the total profile accepting every layer |
| core | a reusable semantic unit beneath the surface languages: ns (namespacing), typl-core (types), expr (predicates), time (timing), interact (interaction primitives) |
| interaction | a named, typed, directed exchange on a contract boundary; ridl defines five kinds |
| signal | pub/sub interaction carrying a continuous state value — latest sample matters, intermediate samples may be missed |
| event | pub/sub interaction carrying a discrete occurrence — every occurrence matters, queued not coalesced |
| command | fire-and-forget RPC at the contract level — no functional reply; the runtime carries a delivery acknowledgment beneath it |
| delivery acknowledgment (ack) | runtime-level confirmation that a command was received and accepted (validated, precondition passed) — or negatively acknowledged with a Stratum 2 category; enables retries and supervision, never visible in the contract |
| query | request/response RPC — reply mandatory; an inline T | E return makes it fallible |
| fixed | a value provisioned externally (build/factory/FOTA), immutable for the software-instance lifetime, safe to cache |
| provider | the component that owns an interface — the one component that offers the service listing it (rsdl §3.2, §8): publishes its signals/events, executes its commands/queries |
| consumer | any component bound to an interface it does not own: subscribes, calls |
| state vs occurrence | the load-bearing distinction behind signal/event: state exists while unchanged and may be cached; an occurrence happens once and is meaningful individually |
| timing annotation | the @ clause making publication timing part of the contract: @Xms strict periodic or @[min..max] |
| rate floor / staleness bound | the one generic meaning of min / max on any timing range (§9): minimum interval between publications / maximum age on envelope timestamps before the value or occurrence is stale |
| debounce / refresh ceiling | the signal derivation of rate floor / staleness bound (§9): coalesce updates faster than min / re-publish at least every max even unchanged |
| throttle / TTL | the event derivation of rate floor / staleness bound (§9): the provider must not raise faster than min / an occurrence older than max is discarded |
| default timing | the compile-time-configurable range ([100ms..1000ms], ridl.toml [defaults].timing) applied to untimed signals and events, with command_timing and query_timing ([..1s], [..3s]) for the response bound of a command or query; always resolved to concrete bounds in the IR |
| freshness SLO | the alertable staleness bound derived from a signal's timing — the contract's definition of "late" |
| last-value guarantee | §4.4: a signal channel is never empty — subscribing delivers a value immediately (init before first publication, latest published value after); the normative demand behind broker caches, MQTT retained, DDS TRANSIENT_LOCAL |
| late joiner | a subscriber that binds after publication began; served by the last-value guarantee on signals, receives nothing retroactive on events |
| quarantine | withholding a constraint-violating payload from application code, with the violation recorded. No quarantine is silent: an invalid stream element ends the call (§12.4), a signal's invalidity is channel state (§4.5), and an invalid event is still delivered (§5.1) |
| functional error (Stratum 1) | a domain-level failure expressed as data — the error arm of a fallible query's return; the provider answered: no |
| contract error (Stratum 2) | an implicit, standardized violation derived from the contract itself: INVALID_VALUE, PRECONDITION_FAILED, CONTRACT_BROKEN, UNKNOWN_INTERACTION — never declared, never an error-type value |
| transport error (Stratum 3) | infrastructure failure — detected, undeclared (general form §6.4): a timeout, broker loss or reset, observed by the runtime and carried by runtime types, with no vocabulary in the contract language |
| error type | a typl enum/struct/union carrying the error modifier — failure vocabulary, ordinary data (typl §10.1) |
fallible query / inline T | E | a query whose return names a success type and one error type — the family's Result<T, E>, written at the signature (§10.1, general form §6.1); bindings map the error arm to native transport error channels, exhaustively handled in every codegen target |
| result union | the two-arm named union (one success arm, one error arm) of typl §10.2 — legal data anywhere, but in query-return position it draws RIDL-308 steering to the inline spelling |
require / ensure | pre-/postcondition contract clauses (expr core); violations are Stratum 2, and each assertion also runs as CI property test, online observer, and rmdl oracle check |
| stream | the <T> container: an unbounded element sequence, valid only in interaction position; direction determined by position (parameter = consumer produces, return = provider produces) |
| ordinal | an interaction's implicit 1-based declaration-order identity within its interface; source of transport IDs (typl §7.4 model) |
reserved | tombstone keeping a retired interaction's ordinal slot occupied so wire identities are never reused |
| append-only | the evolution discipline implied by ordinals: new interactions at the end, deletions by tombstone, reorder = wire break |
| interface | the abstract contract shape — a reusable, identity-less group of interactions; a contract type, realized by services (interface : service :: type : instance) |
| service | a global, named, published declaration of an interface — the SSOT catalog entry, addressed service.member; posture-neutral (can deploy static or discovered; deriving the posture is reserved, rsdl §12); what a component offers |
| service catalog | the flat global namespace of all service declarations — the system-wide SSOT of contracts |
| posture | how a service is realized on the wire — static (bus signals/events, Classic) or discovered (SOME/IP/DDS/uProtocol, Adaptive); a deployment matter, not in the contract; deriving it per deployment is reserved (rsdl §12) |
| binding | generated per-transport code realising a contract: validation, caching, error mapping, (de)serialization |
| envelope | runtime-supplied metadata on every interaction instance — timestamp + sequence number (scoped per interaction kind) — never declared, never in payloads; powers timing evaluation, dedup, loss detection, E2E counters, and replay (§3.1) |
| system time | the platform's one synchronized time base (gPTP/PTP or shared realtime clock) — an assumed platform property; envelope timestamps live in it and are comparable system-wide (§3.1) |
| epoch (platform) | 1970-01-01 00:00:00 TAI (the PTP epoch); platform time = int64 microseconds since it — continuous, leap-second-free; civil datetime is presentation only |
| init value | the value a signal channel holds before the provider's first publication — the payload type's init (typl §5.8) or the signal's bare = value override (§4.4); no keyword — init is rmdl's alone |
| invalid state | the propagated channel state entered when a received payload violates typl constraints — visible to all subscribers with last-good value retained; realised as SNA sentinels on CAN (§4.5) |
| provenance (channel) | the subscriber-visible origin of a signal's current value: init / live / invalid |
| broker | the asynchronous message plane between components; ridl contracts cross it, rmdl models never do (the sync/async wall) |
| IR | the stable serialized intermediate representation — resolved names, types, timings, ordinals — consumed by every backend and tool |
ridl-diff | the IR-comparison tool classifying contract changes as breaking/compatible; plumbing-grade CI gate enforcing the evolution rules |
| profile (assurance) | an external plug-in validating @labels vocabulary and escalating optional rules (explicit timing, explicit bounds) to errors — distinct from grammar profile |
| SSOT | single source of truth — the design goal: one contract file from which bindings, docs, tests, and topologies derive |
End of ridl Language Reference v0.2.0.
Specified, not built. No compiler in this repository accepts rxdl. The parser has three profiles, typl, ridl and rsdl, so the loader reports a
.rxdlfile in a package directory with the warning RIDL-417 (unsupported-source-file) and does not compile it, and refuses a lone.rxdlfile as the entry. This reference becomes normative when rxdl is implemented, and ridl's boundary-model core (E3) is its precondition. Nothing below can be compiled today. See what is built.
rxdl Language Reference
The unrestricted profile and the domain spellings — the layer of the RIDL
family that lifts restrictions rather than adding semantics. A .rxdl file may
carry any layer and any interaction domain; the domain spellings give the person
and world boundaries readable keywords over ridl's kinds.
Version: 0.1.0 — Draft
Status: draft. No compiler accepts rxdl yet, and part of this reference waits for rmdl; the scope note below says which part.
Provenance. rxdl replaces uxdl. ADR-0012 retired uxdl as a family member on the finding that its content was ridl's and its shape was not: at every boundary with the non-software world the datum and its referent come apart, and the obligations that follow are the same four at every boundary, so they belong in one place. That place is ridl. What is left over — readable words for the combinations ridl already understands, and a file kind that forbids nothing — is this document. Its precursor lineage runs uxil → uxdl → rxdl; uxil's eleven interaction verbs and its corpus-registry discipline are mined in Appendix B.
This document is deliberately thin, and that is the point. ADR-0012 decision 7 gives an extension no grammar, no IR nodes, and no semantics. Everything normative about families, obligations, availability, timing, evolution, and errors lives in the ridl reference and is cited, never restated. uxdl's defect was a document that was mostly pointers while claiming to be a language; rxdl claims to be a spelling table, so pointers are the correct content. If a rule appears here that is not in ridl, it is a bug in this document.
Specified, not built. No compiler in this repository accepts rxdl. This reference lands as normative with the unrestricted profile; ridl's boundary-model core is its precondition. Part of that core is parked; see
docs/ROADMAP.md.Scope, as of 2026-09-12. rxdl stays a member of the family, and the part of it the plan carries is narrower than this document. The unrestricted profile covers types, interfaces and wiring in one file, with the per-package tightening of §9, which the current release plan sequences. The model layer of that profile, the domain spellings of §3 to §5, and the bindings they generate wait for rmdl: the roadmap parks the ecosystem half of rxdl and the spellings with their bindings as one block, reopened by rmdl. Nothing here is retired, only partly deferred. Recorded in
docs/wip/2026-09-12-release-scope-and-plugin-system-design.md§3.2.
Table of Contents
- What rxdl Is
- The Unrestricted Profile
- Domains and Families
- The hmi Domain
- The env Domain
- Lowering
- Admission Tests
- What rxdl Is Not
- Package Tightening
- Diagnostics
- Open Questions
1. What rxdl Is
Two things, and nothing else:
- A file kind that restricts nothing (§2). Every other profile is a
restriction —
.typlaccepts type declarations,.ridlaccepts dispatch-family interactions,.rmdlaccepts behaviour..rxdlaccepts all of it. - Spelling tables for the non-dispatch families (§4, §5).
presentis not a new kind; it is a readable way to write the combination ridl already describes as a continuous state value at the person boundary.
The x reads both ways at once — the wildcard over the family pattern (r·i·dl,
r·m·dl, r·s·dl) and extended. One rule covers both: .rxdl is the profile
with no restrictions.
2. The Unrestricted Profile
| Extension | Accepts |
|---|---|
.typl | type declarations |
.ridl | dispatch-family interactions, over typl |
.rmdl | behaviour declarations |
.rsdl | architecture declarations |
.rxdl | any layer, and any interaction domain |
Two restrictions are lifted, independently:
- the layer restriction — types, interfaces, behaviour, and wiring may share one file. This is the adoption gradient: a demo, a getting-started guide, or a solo developer writes one file (concept note §4).
- the domain restriction — the person and world spellings of §4 and §5 are
available. In a
.ridlfile only the dispatch family is spelled.
Consequence, recorded. Because a production package that uses the hmi
spellings must be .rxdl, the file extension no longer answers "which files
contain executable behaviour?" on its own. Package tightening (§9) is the
mitigation, and it is where that question is answered.
3. Domains and Families
A family is a property of a declaration and is ridl's (ADR-0012 decision 2).
A domain is a packaging unit: which spellings a file may use, and which
backends consume them. Domains do not appear in the IR — code generation selects
on family.
| Domain | Families |
|---|---|
hmi | presentation · intent |
env | acquisition · control |
| — | dispatch (always spelled) |
The four correspondence obligations each family carries, what they mean, and how they compose along a path are ridl §3.3, not restated here. The families themselves, and which kinds each admits, are ridl §3.2.
4. The hmi Domain
The person boundary. presentation is what the system offers to be perceived; intent is what the person means and the system captures.
| Family | continuous | occurrence | operation (no return) | operation (returns) |
|---|---|---|---|---|
| presentation | present | notify | — | — |
| intent | — | (open) | activate toggle select adjust dismiss | — |
present clusterSpeed: IndicatedSpeed @[50ms..200ms]
present nowPlaying: TrackInfo
notify overspeedChime: Alert
activate playButton
toggle muteButton
select trackItem(id: TrackId)
adjust volumeSlider(level: Ratio)
dismiss errorBanner
Payload rules on the operation shapes — none for activate, toggle, and
dismiss; exactly one key for select; exactly one ranged value for adjust —
are ridl §3.2.1 (RIDL-502), and are what earns those five their keywords
under §7.
The set is closed and has no generic. A person performs one act at a time; a
multi-parameter operation is a function call, not a gesture. Sending a message
is three interactions, not one. Drag-and-drop, stripped of modality, is select
then adjust.
The empty cells are consequences, not gaps. Nothing can be invoked on an
agent, so presentation has no operations. A person's continuous state is
knowable only by measuring a physical proxy, so a pedal is measure (§5), not
an intent spelling. A system cannot block on a human, so intent has no query:
"are you sure?" is present plus activate/dismiss.
5. The env Domain
The physical boundary. acquisition is what the system takes from the world; control is what the system causes in it.
| Family | continuous | occurrence | operation (no return) | operation (returns) |
|---|---|---|---|---|
| acquisition | measure | detect | — | (open) |
| control | actuate | — | trigger | — |
measure wheelSpeed: Speed @10ms
measure pedalPosition: Ratio @20ms
detect wheelSlip: SlipEvent
actuate targetAngle: Angle
trigger airbagDeploy(zone: Zone)
Both words are metrology's, not this document's choice: VIM (JCGM 200 /
ISO-IEC Guide 99) gives measurement as the process term, and defines a
detector as a device indicating the presence of a phenomenon when a
threshold is exceeded — which is detect exactly. acquire and sense were
weighed and rejected; ADR-0012 decision 4 records why. The obligations those
words carry — measurand, calibration, measurement uncertainty, metrological
traceability — are ridl's, and VIM is the reference for naming anything further
in that layer.
Note pedalPosition. A pedal is measured, not intended: there is no
interpretation gap between a foot's position and its meaning, and all four
obligations instantiate exactly as they do for a wheel-speed sensor. A person
appears at both boundaries — as an agent that means things (hmi) and as a
physical object that is measured (env) — and which one applies is decided by
whether the value carries meaning or magnitude.
The empty cells, again as consequences. Commanding a device to recalibrate is system → world, so it is control, not acquisition. Nothing leaves the system uninvited, so control has no occurrence. Effect is observed by measuring back and never returned, so control has no query.
6. Lowering
Every spelling resolves to a triple ridl already understands. The mapping is bijective.
| Spelling | kind | family | shape |
|---|---|---|---|
signal | signal | dispatch | — |
event | event | dispatch | — |
command | command | dispatch | — |
query | query | dispatch | — |
present | signal | presentation | — |
notify | event | presentation | — |
activate … dismiss | command | intent | each |
measure | signal | acquisition | — |
detect | event | acquisition | — |
actuate | signal | control | — |
trigger | command | control | — |
fixed is family-neutral: a provisioned constant carries no correspondence at
any boundary.
Three properties follow from bijectivity, and each is load-bearing:
- Round-tripping.
ridl fmt, IR rendering, and diagnostics reproduce the authored spelling. Apresentnever renders back as asignal. - One way to say it. The family is an IR field, not an attribute, so
[ presentation ]is not an alternative spelling anyone can hand-write. - No drift. There is no desugaring that could change between releases and make a stored diff baseline disagree with a fresh snapshot on identical source.
(command, intent, no shape) is not a valid triple — §4's closed-set rule
expressed structurally rather than as a lint.
7. Admission Tests
Two tests bind every future spelling. Both are ADR-0012 decision 4.
Modality independence. If two realisations afford the same capability they
must produce the same declaration. A word naming how is rejected. This is what
rules out scroll and drag (gestures — one of several ways to express "show
me more" or "put this elsewhere"), scan (a sensing mechanism — the decisive
case being a mechanical lidar swapped for a solid-state one: same capability, no
sweep, and the contract must not change), and display (visual — a speaker does
not display, and a haptic seat has no view).
Each rejected mechanism word named something real one layer down, in the
obligation layer rather than here: sample names the sample instant, and scan
the acquisition span — a swept frame corresponds to the world over an interval,
not an instant. Both are ridl's to carry (ADR-0012 decision 3), not spellings.
Constraint bundling. A spelling is warranted if and only if it bundles constraints that would otherwise be re-declared by hand at every use. A word adding nothing over its core kind is rejected — which is precisely the test uxdl failed.
8. What rxdl Is Not
- Not a language. No grammar of its own. A
.rxdlfile is parsed by the one family grammar, with no profile restriction applied. - Not semantics. Families (ridl §3.2), the four obligations (§3.3), availability and its five sources (§3.4), timing, init and invalid channels, errors, ordinals and evolution are all ridl's. This document defines none of them and may not.
- Not IR.
familyandshapeare ridl's IR fields. rxdl adds no node, no field, and no attribute key. - Not layout, styling, navigation, or localisation. A
togglemay scaffold a switch; nothing here says switch, checkbox, or voice command. Screen flow is a graph over interactions and has no home yet (§11). - Not a second evolution model.
ridl-diffclassifies a lowered declaration by its core kind, so a spelling cannot weaken the gate.
9. Package Tightening
.rxdl forbids nothing, so strictness is a package decision, declared in
ridl.toml (ADR-0002's profile-purity mechanism, not a new one). A production
package narrows what its files may contain — permitted layers, permitted domains
— and the compiler enforces the narrowing.
This is where "which files contain executable behaviour?" is answered once
.rxdl is in production use (§2).
10. Diagnostics
No RXDL- code family exists, and none should. Every rule a spelling can
violate is a ridl rule, reported under RIDL-: a payload rule on select, an
invalid (kind, family, shape) triple, an availability predicate a consumer
cannot evaluate, an obligation missing where its family requires one.
The rules a spelling can violate are allocated: RIDL-501 (kind not admitted
by its family), RIDL-502 (shape parameter rule), RIDL-503 (intent
operation with no shape), RIDL-504 (non-dispatch interaction with no
obligation), RIDL-505 (availability a consumer cannot evaluate),
RIDL-506 (obligation on a dispatch interaction), RIDL-507 (derives
across an impossible direction), RIDL-508 (control with no paired
acquisition).
Two diagnostics are genuinely rxdl's, and both concern the profile rather than the spellings — a domain spelling used in a file whose profile does not permit it, and a layer used in a package that has tightened against it. Their codes are allocated with E7a.
11. Open Questions
- The intent occurrence keyword. The person-supplies-content spelling —
typing, scanning, dictation. Event-shaped: debounce, TTL, no acknowledgment.
supplyis the working candidate;provideandtellremain live.enterwas rejected for modality,submitfor implying a commit and an acknowledgment an occurrence does not have. - The acquisition/query cell. An explicit one-time read. Deferred to ridl
v0.3 with ridl §17.10, which owns it — the cell is reserved in ridl §3.2,
and a spelling here cannot land before the cell does. What separates it from
measureis maintenance, not duration:measureobliges the provider to hold a current value, and forcing an explicit read into it would make the binding poll forever for something wanted only on request. Periodic and on-change acquisition need no new cell —@[min..max]already distinguishes them, rate floor against staleness bound.readis the candidate spelling. Reopened by a world-boundary contract that needs an on-request read of a quantity too expensive to hold current, which is the observation ridl §17.10 names. - Interaction citation paths. The projection from a declaration to the stable identifier that specifications, journeys, tests, and telemetry cite — uxil's founding problem. Not rxdl's: the observability semantic conventions need it for every interaction, so it is ridl's, and it is recorded here only because uxil's readers will look for it.
- Journeys and screen flow. A sequence of interactions across surfaces. Neither ridl nor rsdl expresses a graph over interfaces. Likely a requirements-and-traceability artifact rather than a language.
- Accessibility metadata. Roles, labels, reading order. The operation shapes already carry the machine-readable gesture semantics such tooling consumes; whether more is needed is unproven.
Appendix A — Worked Example
package veh.cockpit.cluster
import veh.common.Speed
import veh.common.Angle
import veh.common.Ratio
// --- vocabulary (typl) ---
type IndicatedSpeed: km/h [0.0..250.0 step 1.0]
// --- one interface, four families ---
interface Cluster {
/// The sensor's reading of the world.
measure wheelSpeed: Speed @10ms
/// What the driver reads. Legally may never indicate below true speed.
present clusterSpeed: IndicatedSpeed @[50ms..200ms]
/// The driver's foot — measured, not intended: position is meaning.
measure pedalPosition: Ratio @20ms
/// Audible warning; momentary, no init value.
notify overspeedChime: Alert
/// The setpoint the steering actuator tracks.
actuate targetAngle: Angle
/// The driver's acts.
activate resetTrip
toggle headlamps
adjust brightness(level: Ratio)
}
Obligation attributes are omitted above; ridl §3.3 carries the working spellings, which settle with the boundary model. The point of the example is that four families sit in one interface, each declaring what it corresponds to, and that the first word of every line says which.
Appendix B — Prior Art: uxil and uxdl
| Source | Taken / rejected |
|---|---|
| uxil (markspec, ADR-034) | Taken: the closed interaction-verb set, compiler-enforced; the discipline of a corpus registry with stable diagnostics; five of the eleven verbs survive as the intent operation shapes. Rejected: scroll and drag (modality — §7); prose-embedded declarations; untyped string key templates, which become typed parameters. Deferred: navigate (§11.4), observe, ask. Its citation grammar is the open question of §11.3 and became ridl's, not rxdl's. |
| uxdl v0.1 (retired, ADR-0012) | Taken: the operation-shape taxonomy and its payload rules; the states-and-availability model; the coverage analysis against MVVM and its lineage. Rejected: view as a container (an interface is one); display, input, action, fetch as kinds (renames of ridl's, and display fails the modality test); fetch entirely (a person perceives a result, never a request); a separate UXDL- diagnostic family. |
| MVVM, Elm, Compose, SwiftUI, QML | The seam itself — observable state and commands across a viewmodel boundary — and the confirmation that a seeded state value is the natural binding for a continuous presentation. Unchanged from uxdl's survey; see the archived reference for the full table. |
| AUTOSAR HMI and cluster practice | The reason init, invalid, staleness, and derivation are contractual at the person boundary: a cluster telltale has legally relevant init and invalid renderings, and an indicated speed has a legally constrained relationship to the true one. This is what became the correspondence obligations, and it generalised to sensors and actuators. |
The full uxdl v0.1 reference is preserved at
../archive/uxdl-language-reference-v0.1.md.
Read it as prior work, never as current design.
End of rxdl Language Reference v0.1.0 — Draft.
Specified, not built. No compiler in this repository accepts rmdl. The parser has three profiles, typl, ridl and rsdl, so the loader reports a
.rmdlfile in a package directory with the warning RIDL-417 (unsupported-source-file) and does not compile it, and refuses a lone.rmdlfile as the entry. This reference is complete enough to design against; nothing below can be compiled today, and none of the execution semantics it describes is implemented. See what is built.
rmdl Language Reference
Reactive Model Description Language — the behaviour layer of the RIDL family: pure total functions and reactive state models, executed in runtime-scheduled steps, over the typl vocabulary. Models are contract-blind; binding a reaction to a service is rsdl's job.
Version: 0.1.0 — Draft
Provenance. rmdl is the family's only executable language (concept note §2) and the only surface adding genuinely new semantics: memory (
last,init) and the step. Its lineage is the synchronous dataflow tradition (Lustre/SCADE) with one deliberate departure — execution is reactive, not periodic: steps are scheduled by the runtime on input arrival and timing constraints, only when required (§6). Everything below the behaviour layer is inherited: types/units/ranges (typl), contracts and their timing (ridl), the envelope and system time (ridl §3.1), errors-as-data (ridl §10), evolution (typl §7.4). This document specifies the two layers the user-facing design settled: a total function layer shared with the familyexprcore, and the model — the unified reactive construct (no separatenode).
Table of Contents
- Scope and Position in the Family
- Lexical Additions
- The Function Layer
- Expressions
- The Model
- Execution — Steps, Not Ticks
- Models Are Contract-Blind
- Step Faults
- Observers
- Conventions
- Diagnostics
- Open Questions
- Appendix A — Full Example
- Appendix B — Codegen Targets
- Appendix C — Formal Grammar (EBNF)
- Appendix D — Prior Art Survey
- Appendix E — Coverage Analysis: Behaviour Languages
- Appendix F — Glossary
- Appendix G — ridl.std.flow: The Retained Operator Set
1. Scope and Position in the Family
1.1 What rmdl is
rmdl answers: how does a component compute its outputs from its inputs? An rmdl model is described behaviour — deterministic, analyzable, replayable — that the toolchain generates into processing code (Rust native, WASM component). A model is a pure reaction — it computes output flows from input flows and state, knowing nothing of any contract. A component (rsdl) is what binds a reaction to a service at any boundary (ridl); the same model can back either. This keeps the layers clean: ridl declares contracts, rmdl computes, rsdl connects.
A .rmdl file accepts function and model declarations plus everything typl
accepts. It rejects interaction declarations (ridl) and architecture (rsdl).
1.2 Two layers, one discipline
| Layer | Keyword | Nature | Owns |
|---|---|---|---|
| functions | function | pure, total, timeless — mathematics | algorithms: let y = a*x + b |
| models | model | reactive, stateful over steps | memory (last, init), composition |
The separation is semantic, not stylistic: a function has no notion of time or
state and may therefore be evaluated anywhere — inside a model step, inside a
require/ensure clause, inside a test oracle, at compile time in constraint
positions. Functions are the family expr core's function layer (decision
on record): one expression language across contracts, behaviour, and the test
plane — concept note open question 6, resolved in the affirmative.
1.3 The sync/async wall, restated
Inside a model, composition is synchronous: instantiated models step together, atomically (§6.5). Across a broker there is no shared step — writing model dataflow across an async boundary is a compile error (RMDL-401). Models talk to the world only through their signature flows, wired by rsdl components.
1.4 Naming decision — model, no node
The reactive construct is the model itself — the concept-note's model/node
split is collapsed (decision on record, provisional: model a priori, unless
better is found). One construct: a model has typed input/output flows, per-step
semantics, and helpers are just other models it instantiates. Rejected: node
(Lustre jargon, meaningless to the audiences, mentally collides with topology
vocabulary rsdl needs), reactor (Lingua Franca's term, foreign to automotive),
block (Simulink-familiar but generic), process (OS connotation), machine
(pre-empts the deferred state-machine sugar). "Model" is the word control
engineers already use — Simulink model, plant model, and this family's own
reference-oracle story deploys "the model" beside the implementation.
2. Lexical Additions
rmdl inherits the family lexical conventions (typl §2) and ridl's duration literals.
Keywords used by the rmdl profile, beyond typl's set:
function let model
last init if then else case
when emit signal event
Ambient time values — not keywords. now (the current logical instant) and
dt (elapsed since the previous activation) are contextual identifiers
available inside model bodies and contract clauses — the result-in-ensure
mechanism, not reserved words. typl fields named now or dt remain legal
everywhere; declaring a flow named now/dt inside a model is an error
(RMDL-110). step is not a surface word at all — the step is
runtime/semantics vocabulary (§6), never syntax, by the same doctrine that keeps
async/await out of the language.
(init is shared with ridl's channel-init attribute — one concept,
initialization, two positions, like match. pre and the followed-by -> were
considered and dropped for last/init — §5.3 decision note; -> survives
only as the case/when arm arrow.)
signal and event here are the interact core's concept words surfacing in
model signatures (§5.1) — the same two concepts ridl's families profile, marking
flow kinds at the model boundary. One keyword, one concept, three surfaces —
registry-clean.
New token: ?: (default-value operator, Kotlin heritage — §4.4). -> is the
case/when arm arrow only.
Reserved for future rmdl use: merge, current (clock calculus, if ever needed
— §12.1), state, transition, automaton (state-machine sugar — §12.3).
when is no longer reserved — it found its real job (§5.6).
3. The Function Layer
3.1 Declaration
A function is a named, pure, total computation — Kotlin/OCaml style,
let-bindings, expression-bodied:
// expression form
function celsiusToRatio(t: Temperature): Ratio = (t + 40.0) / 165.0 * 100.0
// block form — let bindings, last expression is the result
function interpolate(x: Speed, a: float, b: float): Speed {
let scaled = a * x
let offset = scaled + b
clamp(offset, 0.0, MAX_SPEED)
}
function clamp(x: Speed, lo: Speed, hi: Speed): Speed =
if x < lo then lo else if x > hi then hi else x
- Parameters and result are typl types (named types preferred; bare
integer/floatpermitted as in struct fields) letbinds immutable locals; no reassignment, no shadowing (RMDL-104)- Package-scoped, importable,
internal-able — functions are vocabulary-adjacent citizens under the samensrules
3.2 Totality — the contract of the layer
A function always terminates and never faults on its own:
- No recursion, direct or mutual (RMDL-101)
- No unbounded loops — there is no loop statement at all. Iteration exists
only as bounded combinators over typl bounded collections (§4.5):
map,fold,any,all,count. Every bound is a typl bound, so every iteration count is statically known - No state, no time —
last,init,now, anddtare model-layer constructs; inside a function they are errors (RMDL-102, RMDL-103). A computation that needs elapsed time takes it as an ordinary parameter, staying timeless
What totality buys, and why it is non-negotiable: per-call WCET is decidable
(safety scheduling), functions are legal in contract positions
(require/ensure, and the const-evaluable subset in typl constraint bounds —
the fencing typl §17.7 demanded), and the test plane can evaluate any function
as an oracle without a sandbox.
The two residual runtime hazards — division by zero and range violation at a typed boundary — are not function faults but step faults, defined once in §8 (a function is total over its valid inputs; guarding divisors is the author's obligation, checked where decidable, RMDL-105 otherwise).
3.3 Unit discipline
Functions inherit typl's nominal unit safety (typl §5.7):
- same-type addition/subtraction/comparison:
Speed + Speed : Speed✓ - scalar multiplication/division:
Speed * float : Speed✓ - cross-unit arithmetic (
Speed + Torque,Speed * RPM) is an error (RMDL-106) — until the unit-algebra question (typl §17.5) is settled, crossing units requires an explicit named conversion function, which is itself ordinary vocabulary
Intermediate expressions compute in the language layer (float64/int64 — typl
§4) and are unconstrained; constraints re-apply at every typed boundary — a
function's result, a model output, a contract flow (§8).
4. Expressions
The expression grammar below is shared verbatim with the expr core: what is
written here is what require/ensure accept (ridl §13), with contract
positions restricted to the const-evaluable subset.
4.1 Operators
Arithmetic + - * / %, comparison == != < <= > >=, boolean && || ! —
C/Kotlin lineage, standard precedence. Integer division truncates toward zero;
% follows the dividend's sign (C17).
4.2 Conditionals
if c then a else b is an expression; else is mandatory (totality —
RMDL-107).
4.3 case — exhaustive dispatch
case dispatches on enums, unions, and optionals; arms use ->; exhaustiveness
is required, with else as the explicit catch-all (RMDL-108 when incomplete):
case gear {
PARK -> 0.0
REVERSE -> -maxCreep
else -> creepFor(gear)
}
case result { // union dispatch binds the arm's payload
ok v -> v.value
err e -> fallbackFor(e)
}
(case, not match — the registry assigns each keyword one concept, and the
family's dispatch triad is: case chooses by shape/value — as an
expression here, and as a reactive mode-dispatch equation in models (§5.2:
exactly one branch per step, total definition); when chooses by
occurrence/time (§5.6 — ordered, first-match, hold semantics); match
names pattern conformance (typl constraint position today, expr infix
predicate later — typl §17.7c). Migration traps are known and diagnosed:
Rust/OCaml/GRust hands typing match x { and Kotlin hands typing when (x) for
value dispatch both get a fix-it suggesting case. case is also Ada's word —
the safety audience's own lineage. A ~ sigil for pattern matching was
considered and rejected: the family is deliberately sigil-poor for its
non-programmer audiences.)
4.4 Optionals
An optional value (T?) is consumed by case (some v -> … , none -> …) or by
the default operator:
let cmd = setLever ?: LeverCmd.NONE // occurrence absent this step → NONE
4.5 Bounded combinators
Over typl bounded arrays/maps — the only iteration in the language:
function meanSpeed(xs: [Speed; 1..16]): Speed =
fold(xs, 0.0, function(acc, x) = acc + x) / count(xs)
function anyCritical(fs: [FaultEvent; 0..32]): boolean =
any(fs, function(f) = f.severity >= 4)
Anonymous function(…) = expr literals are permitted only as combinator
arguments (no closures over mutable anything — there is nothing mutable; no
escaping function values — RMDL-109). map preserves bounds; fold consumes
them; iteration count ≤ the typl bound, always.
5. The Model
5.1 Declaration
A model is declared with an explicit signature — inputs, then outputs after :.
There is no realizes clause (dropped by decision — §7): a model names no
contract. Every flow carries a kind — signal (continuous state, sampled)
or event (discrete occurrence) — the interact core's two data-carrying
primitives at the model boundary:
// a reusable behaviour brick — pure reaction, contract-blind
model Latch(signal set: boolean, signal reset: boolean) : (signal out: boolean) {
init out = false
out = if reset then false
else if set then true
else last out
}
// a controller — still just a signature; the CruiseControl service is bound
// to this reaction by an rsdl component, not named here
model CruiseController(signal current: Speed, signal brake: boolean, event lever: LeverCmd)
: (signal engaged: boolean, signal target: Speed) {
...
}
signal name : T— a state flow: inside the body it reads as a plainT, sampled at step start (latest value, provenance-aware)event name : T— an occurrence flow: inside the body it reads asT?— present with its payload in a step where the occurrence arrived, absent otherwise. A payloadless occurrence isevent name(unit payload)- Unmarked parameters default to
signal(the common case); assurance profiles may require explicit kinds - All inputs are just parameters. There is no distinction between "contract" inputs and "abstract" inputs anymore — a model has inputs, full stop. An rsdl component wires each input to a real signal (a service member or another reaction's output) and binds the outputs to a service it provides (family open question 1, resolved: rsdl-wired)
- A model body is a set of equations (§5.2),
whenblocks (§5.6),letlocals, and instantiations. Equations are unordered — the compiler schedules them by data dependency (§5.4)
5.2 Equations
Each output and each local flow is defined by exactly one equation (RMDL-201: multiply defined; RMDL-202: undefined output):
out = expr // flow definition
let mid = expr // local flow (single assignment, like everything)
Case equations — mode dispatch. case also has a reactive form (GRust's
match equation): dispatch on a value that exists every step — typically a mode
signal — selecting which set of equations is active:
case drivingMode {
ECO -> { torqueLimit = ECO_LIMIT, boost = 0.0 }
SPORT -> { torqueLimit = MAX_TORQUE, boost = boostCurve(rpm) }
else -> { torqueLimit = STD_LIMIT, boost = 0.0 }
}
Semantics — deliberately the opposite discipline from when (§5.6):
case equation | when block | |
|---|---|---|
| selected by | a value, present every step | an occurrence or edge |
| branches per step | exactly one, always | zero or one |
| definition | total — every branch defines the same flow set (RMDL-213) | partial — signals hold where unassigned |
| exhaustiveness | required, else as catch-all (RMDL-108) | not applicable (no branch matching is normal) |
emit | not permitted — a mode is not an occurrence (RMDL-212) | the only place events are raised |
One keyword, one concept, two positions — case chooses by shape/value whether
as an expression (§4.3) or as an equation block; the same pattern as match and
init. The single-definition rule (above) holds at block granularity: the case
block is the equation of every flow it defines.
5.3 Memory — last and init
(GRust's memory model, adopted — replacing the draft's Lustre pre/->;
decision note below.) Two constructs:
last x— the value of flowxat the previous step. Total: defined even at the first step, because every memory has a seedinit x = e— the seed equation: whatlast xyields at the first step.eis evaluated once, at the first step; it may reference constants and the first-step values of inputs (capture-at-t₀), and nothing else (RMDL-210)
Seeding rules:
- When an rsdl component binds a flow to a typed channel (a service member),
that flow is implicitly seeded with its channel init value (ridl §4.4,
typl §5.8): the never-empty channel gives never-empty memory, one doctrine end
to end. A standalone model flow with no
initand no binding is seeded per the derived rules of typl §5.8 - Any other flow read through
lastrequires an explicitinitequation (RMDL-203 — a presence check, not a dominance analysis) - A
whenblock'sinitbranch is the init equations of the signals it defines (§5.6) — one initialization mechanism everywhere
init out = false
out = if reset then false
else if set then true
else last out // the latch idiom
edgeUp = x && !last x // edge detection — x seeded by its channel init
init n = 0
n = last n + 1 // counts 1, 2, 3, … — the seed is PRE-HISTORY, not the first output
Note the counter: the seed is the value before the first step; the first
output is computed from it. (Lustre's 0 -> pre n + 1 made the init the first
output — the seed model is the one that composes with when-init and channel
init values.)
Decision note — why last/init over pre/-> (settled in design
review): memory is total by construction — no nil value at the first instant,
and no initialization-dominance analysis to specify, implement, and
tool-qualify; last speed reads as English where pre is Lustre jargon (the
argument that killed node, applied consistently); one initialization mechanism
instead of the draft's two coexisting styles (-> in equations, init branches
in when); and seeds existing at construction align with the ridl §4.4 channel
doctrine. Given up: ->'s expression-valued first outputs — covered in
practice by when init branches and by init expressions referencing
first-step inputs (the capture-at-t₀ case GRust's constant-only init could not
express).
5.4 Causality
Within a step, the dependency graph of equations must be acyclic except
through last — last is the only legal way a flow depends on itself
(RMDL-204: instantaneous cycle). The compiler's schedule is a topological order;
authors never order equations by hand.
5.5 Composition — instantiation
Models instantiate models; each instantiation site owns its instance's state:
model CruiseController(signal current: Speed, signal brake: boolean, event lever: LeverCmd)
: (signal engaged: boolean) {
let cmd = lever ?: LeverCmd.NONE
let (engagedRaw) = Latch(cmd == LeverCmd.ENGAGE,
brake || cmd == LeverCmd.CANCEL)
engaged = engagedRaw
...
}
Instantiated models step synchronously with their parent — one step, one atomic reaction through the whole tree (§6.5). Instantiation is static: no conditional instantiation, no arrays of instances in v0.1 (§12.4).
5.6 Event Flows — when and emit
Adopted from GRust (Appendix D), adapted to family syntax. The when block is
the event-triggered equation form — the natural surface of the scheduled-step
execution model: it says what happens when, and its trigger conditions are
exactly the step-activation causes of §6.1.
when {
init -> { engaged = false, targetSpeed = SPEED_LIMIT_EU }
brake -> { engaged = false }
setLever? if setLever == LeverCmd.CANCEL
-> { engaged = false }
setLever? if setLever == LeverCmd.ENGAGE
-> { engaged = true, targetSpeed = current
emit engagedChanged = true }
}
Branch conditions — five forms:
| Condition | Triggers when |
|---|---|
init | the first step — initializes the signals this block defines |
e? | the occurrence e arrived this step |
e? if c | e arrived and c holds |
(e1?, e2?) | both occurrences arrived this step |
boolean expression c | c has a rising edge — false at the previous step, true now |
Semantics:
- Branches are ordered; the first matching branch runs, alone — one branch per step. A branch subsumed by an earlier, less restrictive one is a linter finding (RMDL-207)
- Signals hold: a signal defined by a
whenblock keeps its previous value in any step where no branch assigns it — the latch generalized. Theinitbranch supplies the first value (mandatory for every signal the block defines, RMDL-209); it is initialization, not default — once another branch has run,initis unreachable - Events don't hold: an event output is raised with
emit name = exprinside a branch and is absent in every step where nothing emits it.emitis valid only insidewhenbranches and only targetsevent-kind flows (RMDL-208) - Inside a branch, ordinary equations and
lets apply; across branches, the block is one equation per flow it defines (§5.2's single-definition rule holds at block granularity)
last/init remain the primitive memory; when compiles to them. Use plain
last-recurrence equations for numeric state evolving every step (integrators,
filters); use when for mode logic driven by occurrences and edges.
5.7 Side Effects Are Emissions
A model never calls anything. The only way behaviour affects the world is data: signal outputs (state) and event emissions (occurrences). An emitted event becomes a side effect only at the wiring layer — rsdl binds a model's output event to another service's command:
# rsdl (manifest sketch)
instance cruise : CruiseController
bind engagedChanged -> veh.cluster.Indicators.setCruiseLamp # event → command
This is the family's whole side-effect story, and it is load-bearing: the model stays pure and replayable (its step trace fully determines its emissions), the command's ack/retry machinery (ridl §6.1) belongs to the wiring not the behaviour, and the test plane can observe every would-be side effect as data without executing it.
6. Execution — Steps, Not Ticks
The defining decision of this language (settled in design review): rmdl is reactive but not periodic. A model is a state machine whose step — one synchronous reaction — is scheduled by the runtime, based on inputs and constraints, only when required. There is no free-running base clock; there is no polling.
6.1 What triggers a step
The runtime activates a model when:
- An input arrives — an occurrence or update on an input flow, or
- An output deadline falls due — a refresh ceiling (
@[..max]) or strict period (@Xms) of an output flow, demanded by the service the binding component provides (§7), or an occurrence TTL expiry
and coalesces activations per those same bounds: an output's min bound
(debounce) is a floor on step spacing attributable to that flow — the bound
service's @[min..max] is the scheduler's constraint set, supplied by the
binding component. Strict periodic (@10ms) degenerates to periodic stepping:
classic Lustre is the special case, not the default.
Between steps the model is quiescent: state persists, nothing computes, nothing is polled. A model with no input changes and no deadline pending consumes nothing.
The when branch conditions (§5.6) are these activation causes made syntactic:
e? is input-triggered activation, a rising-edge condition is change-triggered
activation — what schedules the step is what the equation reads.
6.2 The step contract
A step is atomic and synchronous: inputs are sampled once at step start (a consistent snapshot), all equations evaluate against that snapshot per the causal schedule, outputs publish at step end. The synchronous hypothesis holds as in the Lustre tradition — the reaction is logically instantaneous relative to its environment; physically, per-step WCET is decidable (§3.2) and the deadline analysis is the scheduler's obligation.
Input sampling per kind: state flows (signals/displays, abstract inputs)
sample their latest value — provenance included (init/live/invalid, ridl
§4.5), so a model can see that an input is invalid and behave accordingly;
occurrence flows (events/commands/inputs) deliver at most one occurrence
per step, in envelope order — a queue drains one occurrence per activation,
preserving determinism (a burst of N commands yields N steps, not one step with
a list).
6.3 Step context — now and dt
Aperiodic execution makes elapsed time a first-class need (an integrator cannot assume a fixed period). Every model body may read the ambient time values — the step never appears; the author reads the current instant and the elapsed time:
| Expression | Type | Meaning |
|---|---|---|
now | Timestamp | the reaction's logical time — the time of its cause, assigned by the runtime (below); system time domain (ridl §3.1). Conceptually an implicit ambient signal of time — the FRP Behavior of time, sampled at each activation |
dt | Duration | elapsed logical time since this instance's previous activation — definitionally now - last now, seed making it 0 at the first reaction |
time(f) | Timestamp | the envelope timestamp of flow f's current value (signal) or this step's occurrence (event) — sender-stamped (ridl §3.1) |
Logical time, not execution time (decision on record). now is the time
of the step's cause, never the wall-clock moment the code happens to run:
for an input-triggered step, the triggering occurrence's envelope (sender)
timestamp; for a deadline-triggered step, the deadline instant itself. The
runtime assigns it; the model computes with it. Consequences:
- Time physics is exact and jitter-immune. An integrator (
last x + v * dt) integrates over the time that physically elapsed between causes, regardless of scheduling latency, CPU load, or how late the step actually executed - Replay is not a special mode. Replaying a step trace — or computing over
past events in a backlog — feeds the recorded logical times: the model runs
the same instants, the same
dts, the same physics, bit-for-bit. There is no "recompute against today's clock" failure class, because today's clock is not reachable from a model - Scheduler lag is observable, not corrupting. The runtime tracks
physical − logicalper step as a health metric (feeding freshness/deadline supervision, ridl §10.4); when lag exceeds the contract's bounds that is a detected timing failure — the model's arithmetic stays correct either way
Time is language, not library (decision on record). GRust exposed current
time as a library operator (time() in GReact); rmdl deliberately promotes it:
now, dt, and time(f) are language-level, because time is what contracts
constrain (§9.2) and what the scheduler reasons about — it cannot be an optional
import. The complementary decision: async/await is runtime, not language
(§6.6) — the two concepts sit on opposite sides of the surface, by design.
One time, and only one (decision on record). The language knows a single notion of time: the platform instant — system time domain, TAI/PTP epoch (ridl §3.1) — frozen per step. There is no datetime type, no wall-clock, no second clock domain at the surface; civil datetime, time zones, leap handling, and clock-domain mechanics are absorbed by the runtime and the presentation edges. Any confusion between "time" and "datetime" is therefore not expressible in a model — which is the point.
// aperiodic integrator — correct under any activation pattern
init distance = 0.0
distance = last distance + current * dt
step is a keyword; the context is read-only and step-scoped. Functions cannot
read it (§3.2) — a computation that needs dt takes it as an ordinary
parameter, staying timeless.
6.4 Determinism and replay
A model's execution is fully determined by its step trace: the sequence of
(input snapshot, logical now) pairs. Same trace, same outputs — bit-for-bit,
on every target (Rust native and WASM produce identical traces; float evaluation
is IEEE 754, no fused reassociation). Because now is logical (§6.3), live
execution, backlog processing, and replay are the same computation over the
same times — the runtime records step traces from the envelope (ridl §3.1),
and replaying a field incident is re-feeding a trace. This is the
reference-oracle machinery of concept note §9.2, inherited by construction.
6.5 Composition semantics
An instantiated model has no scheduler of its own — it steps when its parent
steps, synchronously. One activation, one atomic reaction through the
instantiation tree; now/dt are uniform across the tree for a given
activation. (Whether a sub-model may declare its own activation constraints —
multi-activation, the successor of Lustre's multi-rate clocks under this
execution model — is deferred: §12.1.)
6.6 Parallelism by Causality
The causal analysis (§5.4) produces more than a schedule — it produces a partition. Equations with no dependency path between them form independent islands of the dataflow graph, and the compiler may compile each island as its own step unit, activated by its own triggering inputs and constraints, executed concurrently by the runtime (the GRust precedent: causal analysis feeding an asynchronous Rust runtime).
The guarantee is observational equivalence: per flow, the sequence of values and timestamps is identical to the single-step synchronous semantics — parallelism is a compiler/runtime optimization, never a semantic choice the author makes. Consequences:
async/awaitnever appear in the language. Concurrency, task spawning, and completion areridl-rt's business; generated code may be a single loop or a task-per-island — the model text and its step traces are identical either way- An island that shares no inputs with another needs no synchronization with it — this is what dissolves the "everything wakes at 10ms" bus-and-CPU waste the thesis documents (97% unused periodic traffic at Ampère): only the island whose input changed computes
- Islands are a unit of replay too: a per-island step trace is smaller and independently re-runnable
- The partition is derived, never declared — no
parallelkeyword exists or will
6.7 The Scheduler Has No Clock — It Has a Timeline
The runtime scheduler is not driven by a clock; it maintains a timeline: an ordered set of due instants, each carrying its cause, projected from inputs and real time:
| Instant source | Projected from |
|---|---|
| input arrival | the occurrence's sender-stamped envelope time (logical) |
| output deadline | the bound service's @[..max] refresh ceiling / @Xms period, anchored to the last publication (supplied by the binding component, §7) |
| coalescing floor | the contract's min bound — the earliest next instant a flow may cause |
| timer | future instants a watchdog/timeout registers (§12.9) |
The scheduler advances along the timeline, executing each instant as a step
whose now is that instant (§6.3). Real time has exactly one role: gating.
In live mode, an instant may not execute before the synchronized clock reaches
it — and executes as soon after as the platform allows, the difference being the
lag metric. In replay or backlog processing, the gate is simply open: the
timeline drains at compute speed, and nothing else changes — same instants, same
steps, same physics.
This is what "reactive, not periodic" means operationally: there is no tick looking for work — instants exist only where causes exist, and a quiescent system has an empty timeline. (Precedent: Lingua Franca's event queue over logical time; the timeline is that idea with the instants derived from the contracts rather than declared.)
7. Models Are Contract-Blind
Decision on record (this section replaces a former "Realization" section). A
model does not name, realize, or expose a contract. It is a pure reaction:
(O, S) = M(I, S), a signature of typed input and output flows with internal
state. Binding that reaction to a ridl service is entirely an rsdl
component's job. rsdl v0.1 bound it with application notation
(docs/archive/rsdl-language-reference-v0.1.md §3–§4); the rewritten rsdl has
no binding notation and reserves it until rmdl is scheduled (rsdl §12). This
purifies the layer boundary: ridl declares contracts, rmdl computes, rsdl
connects — and rmdl references ridl only for the kinds (signal/event) and
typl for types, never for contracts.
What used to be "realization" is now three things the component supplies from outside, none of them in the model:
| Concern | Where it lives now |
|---|---|
| which contract member each flow maps to | the component's binding — application notation (engaged, target) = M(current, brake, lever) in rsdl v0.1 §4 (archived); reserved in the rewritten rsdl (rsdl §12) |
output timing (@10ms, refresh, TTL) | the service the component offers (ridl §14.5); its bounds drive the model's output-deadline demand (§6.1). The model itself carries no timing |
| output init value | the bound channel's init (ridl §4.4); if the model also writes init out = …, the component checks the two agree (a boundary check in rsdl once binding returns, rsdl §12; not here) |
contract require/ensure | the service's clauses, compiled by the component, once rsdl binding returns (rsdl §12), as observers over the bound flows (§9). A model may also carry its own require/ensure (§9.2); the two compose |
Consequences for the model author: write inputs and outputs as plain kinded
flows; do not annotate timing (there is nothing to annotate — a model reacts to
input arrivals and to output-deadline demands passed in by its binding, §6.1);
seed memory with init where first-step behaviour matters. The reaction is
complete and testable on its own — the test plane drives its inputs and reads
its outputs without any contract present.
Command / query, at the model boundary. A ridl command a service accepts
arrives at the reaction as an event input (T? per step, §6.2), typically
consumed in a when branch; the component routes it. A query is not realized
by model equations in v0.1 — the direction of travel is a pure function over
the model's state flows (§12.2), invoked by the component, consuming no step.
8. Step Faults
Total functions leave exactly two runtime hazards: division by zero (and %
by zero) and range violation when a value crosses a typed boundary (function
result, model output, contract flow — typl constraints re-apply there, §3.3).
Both are step faults, and the semantics are transactional:
- The faulting step aborts atomically — no partial state update, no partial
outputs; the model's memory (
laststate) remains that of the last completed step - Output flows transition to the invalid state (ridl §4.5) — subscribers see that the producer faulted, with last-good values retained; nothing is silently stale
- The fault is recorded — step trace, flow, equation, fault kind — through the observability hooks; a negative ack is returned where the triggering input was an acked occurrence (ridl §6.1)
- What happens next — retry the step without the poisoning input, hold, degrade, failsafe — is failure management (ridl §10.4), not language semantics. The language's job ends at: no fault is silent, no fault corrupts state
Statically excluded faults stay excluded: exhaustiveness (§4.3), causality (§5.4), initialization (§5.3), and unit discipline (§3.3) are compile-time; where a divisor or boundary can be proven safe (ranges exclude zero; result range ⊆ target range), the check is discharged at compile time (RMDL-105 asks for a guard only when it cannot).
9. Observers
An observer is a boolean flow evaluated every step, with no effect on the model's outputs — the four-way assertion machinery (concept note §9.2) anchored in the language:
- Model-local observers: a model's own
require/ensureclauses (§9.2), evaluated every step — a violatedensureis aCONTRACT_BROKEN-class finding (ridl §10.2), surfaced per §8's recording rules - Service observers: when an rsdl component binds this reaction to a
service, that service's
require/ensureclauses are compiled by the component as observers over the bound flows — they apply at the component, not written here (§7)
Observers never write flows, never fault the step (a false observer is a finding, not a fault), and are compiled out of production builds or kept, per deployment (rsdl) — the reference-oracle build keeps them all.
9.2 Contracts on Functions and Models
GReusot-inspired (the thesis's verification extension), family syntax.
Functions and models carry the same [ require … ensure … ] attribute block as
ridl interactions — the grammar supports expressing safety and performance
requirements directly on behaviour:
function isqrt(x: integer [0..1000000]): integer [
ensure result * result <= x
] { ... }
model CruiseController(signal current: Speed, signal brake: boolean, event lever: LeverCmd)
: (signal engaged: boolean, signal target: Speed,
event disengaged: DisengageCause) [
// safety invariant — evaluated every step
ensure engaged implies !brake
// performance requirement — time is in the language (§6.3), so deadlines are contract terms:
// whenever disengagement is emitted, it came within 50ms of the brake edge
ensure disengaged? implies (now - time(brake)) <= 50ms
] { ... }
require— assumption on inputs, checked at the boundary before the body runs (violation is a Stratum 2 contract error, ridl §10.2)ensure— guarantee over outputs/flows: on a function, overresult; on a model, evaluated every step as an observer (§9.1)- Contract terms use the full expression grammar plus the temporal vocabulary:
last(past values),now/time(f)(deadlines, latency, freshness), ande? implies …— the eventful conditional (GReusot'swhen EPat ⇒ Term) - Verification is layered: the four ways (static where decidable, CI property tests, online observers, reference oracle) plus a fifth the thesis proved out — deductive proof: the clause grammar is deliberately Creusot-compatible, so function and model contracts can be discharged by a deductive verifier where they are provable, not just tested
This resolves the §9.1 deferral in the affirmative for the positions; the full term grammar still lands with the expr core.
10. Conventions
- Functions for mathematics, models for memory — if a model reads no
last, it should be a function (linter, RMDL-501) - Name models as controllers/filters/estimators (
CruiseController,SpeedFilter); functions as verbs or formulae (clamp,interpolate,meanSpeed) - Write the
initequation adjacent to the equation that readslast— the seed and the recurrence review as a pair. Channel-bound flows are implicitly seeded (§5.3), but an explicitiniton one is legal and preferred when first-step behaviour is safety-relevant - Guard divisors with
if/?:at the use site, not with wide try-anything ranges - Keep abstract inputs few and typed narrow — every abstract input is a wiring obligation on rsdl and a test-plane injection point
11. Diagnostics
Coded RMDL-, same lifecycle rules as typl §16.
11.1 Functions and Expressions (RMDL-1xx)
| Code | Rule | Severity |
|---|---|---|
| RMDL-101 | recursion, direct or mutual | error |
| RMDL-102 | last or init inside a function | error |
| RMDL-103 | now or dt inside a function | error |
| RMDL-104 | let reassignment or shadowing | error |
| RMDL-105 | division/modulo whose divisor is not provably nonzero and not guarded | error |
| RMDL-106 | cross-unit arithmetic without an explicit conversion | error |
| RMDL-107 | if without else | error |
| RMDL-108 | non-exhaustive case (no else, arms incomplete) | error |
| RMDL-109 | function literal outside a combinator argument position | error |
| RMDL-110 | flow declared with the name now or dt inside a model | error |
11.2 Models (RMDL-2xx)
| Code | Rule | Severity |
|---|---|---|
| RMDL-201 | flow defined by more than one equation | error |
| RMDL-202 | declared output with no defining equation | error |
| RMDL-203 | flow read through last with neither a channel seed nor an init equation | error |
| RMDL-204 | instantaneous dependency cycle (cycle not broken by last) | error |
| RMDL-205 | conditional or dynamic instantiation | error |
| RMDL-206 | unused input or abstract input | warning |
| RMDL-207 | when branch unreachable — subsumed by an earlier, less restrictive branch | warning |
| RMDL-208 | emit outside a when branch, or targeting a non-event flow | error |
| RMDL-209 | signal defined by a when block without an init branch value | error |
| RMDL-210 | init expression references anything beyond constants and first-step inputs | error |
| RMDL-211 | duplicate init equation for the same flow | error |
| RMDL-212 | emit inside a case equation — a mode is not an occurrence | error |
| RMDL-213 | case equation branches define differing flow sets | error |
11.3 Signatures (RMDL-3xx)
| Code | Rule | Severity |
|---|---|---|
| RMDL-301 | declared output flow with no defining equation | error |
| RMDL-302 | realizes (or any contract reference) in a model — models are contract-blind (§7) | error |
| RMDL-303 | input flow never read | warning |
(Contract mapping, timing transfer, and init-consistency checks moved to rsdl, where the component binds a reaction to a service — reserved in rsdl §12 until rmdl is scheduled.)
11.4 Boundaries (RMDL-4xx / 5xx)
| Code | Rule | Severity |
|---|---|---|
| RMDL-401 | model dataflow across an async boundary (the sync/async wall) | error |
| RMDL-402 | behaviour declaration in a non-.rmdl/.rxdl context | error |
| RMDL-501 | model reading no last (should be a function) | warning |
12. Open Questions
- Multi-activation (successor of multi-rate clocks).
whenfound its role as the event-triggered equation form (§5.6); what remains open is whether a sub-model may declare its own activation constraints (step slower/faster than its parent) —merge/currentstay reserved for that clock calculus if it is ever needed. - Query/fetch behaviour. Direction of travel: a query answered by a pure function over the model's state flows — request arrives, function evaluates against current state, no step consumed (or an explicitly step-consuming variant for mutating queries, which CQRS says shouldn't exist). The component routes the query to that function; needs the expr/function story §3 proved first.
- State-machine sugar.
automaton/state/transitionreserved; compiles to enum + latch equations; lands once the equation core is proven in real models. - Static instance arrays.
[Latch; 4]-style instantiation for symmetric structure (per-wheel controllers). Bounded, so admissible in principle; deferred for grammar and diff-story clarity. - Saturation vs fault at typed boundaries. §8 makes range violation a step
fault; control practice sometimes wants declared saturation instead (
clampsemantics at the boundary). Candidate: a per-flow attribute ([ saturate ]) opting into clamping — property, not mechanism, per the safety-extension doctrine (ridl §10.4). Evidence first. - Standard behaviour library — semantics re-derived against the
signal/event/timeline model. Two Rust-implemented stdlib packages:
ridl.std.math(purefunctions: clamp, lerp, abs/min/max, interpolation tables,dt-correct filter/integrator kernels) andridl.std.flow(adaptermodels). GReact's vocabulary is re-based with audience-correct names (itsscan/throttlecollide with Rx tradition) and with kind signatures, seed behaviour, and timeline classification made explicit — the analysis GReact could skip because its services were quasi-periodic, ticks ambient:
Instant-preserving adapters — ride existing steps, create no timeline instants; implementable with v0.1 semantics as ordinary models:
| Adapter | Kind signature | Semantics (seed behaviour included) |
|---|---|---|
Changes(s) | signal T → event T | emits when s != last s; the seed is the comparison base, so a first live value equal to the channel init emits nothing — no spurious start-of-world occurrence (GReact on_change) |
Hold(e, init) | event T → signal T | zero-order hold; init seeds the output channel explicitly (GReact persist, which left the pre-first-occurrence value implicit) |
Deadband(s, Δ) | signal T → signal T | forwards only when |value − last forwarded| > Δ; seeded by the channel init (GReact throttle — renamed: mainstream throttle is time-rate limiting; this is the control term) |
Accumulate(e, init, f) | event T → signal U | running fold over occurrences — scan's actual Rx meaning, restored under its honest name; sugar for init acc = init + when e? -> acc = f(last acc, e) |
Merge(e1, e2) | event T × event T → event T | forwards whichever occurrence is present. Same-step simultaneity is real in our model (two flows may each deliver in one step — the when (e1?, e2?) case): e1 wins, e2's occurrence is dropped and counted (observability), because a step emits at most one occurrence per flow and inputs cannot be pushed back. GReact's priority rule, made loss-explicit — prefer binding both events into one model's when branches when every occurrence matters |
Instant-creating adapters — they register timer instants on the timeline (§6.7); gated on the §12.9 timer capability, because they are scheduling constructs, not dataflow:
| Adapter | Kind signature | Semantics |
|---|---|---|
Clock(d: Duration) : event () | the primitive instant source: an occurrence at every multiple of d from instantiation | see rationale below |
Sample(s, d) | signal T → event T | emits s's current value at each timer instant of period d. Kind corrected from GReact's scan(s,d) : signal — a periodic reading is a sequence of occurrences; it only becomes state again if something Holds it |
Coalesce(e, d) | event T → event T | at most one occurrence per window d, keeping the latest; the consumer-side counterpart of the contract's provider-side min throttle |
Watchdog(e, d) | event T → event () | absence detection: arms a timer instant at time(e) + d, re-armed on each occurrence, fires if reached (GReact timeout) |
The architectural finding: several GReact operators are, in this family,
not library calls but channel timing annotations — scan(s, 10) on an
exported flow is realizing the signal @10ms; time-throttling is the contract's
min bound; TTL is max. GRust needed them as operators because timing lived
in service bodies; we pushed timing into contracts, so ridl.std.flow only
serves internal flows and consumer-side reshaping. time() is likewise not an
operator here — time is language (§6.3). The retained set with normative
definitions: Appendix G. 9. Timeout / reaction to absence. A watchdog —
raise an event if no occurrence within d (GRust's timeout(e, d)) — needs the
scheduler to fire timer-triggered steps, a third activation cause beyond
§6.1's two. Semantically clean under scheduled steps; deferred until the
scheduler is formalized. Likely surface: a stdlib Timeout model or a
when … timeout d -> branch form. 7. WCET annotation surface. Whether
models may declare per-step budgets (@wcet 200us?) for the scheduler to verify
against, or budgets stay entirely in deployment (rsdl). Leaning rsdl. 8. Unit
algebra. Cross-unit multiplication/division with derived dimensions
(Speed * Duration : Length) — typl §17.5's question, felt most acutely here.
RMDL-106's explicit-conversion rule is the stopgap.
Appendix A — Full Example
The cruise control of concept note §6, reworked to the settled design — unified
model, abstract inputs, scheduled steps, dt:
package veh.adas.model
import veh.common.Speed
import veh.common.LeverCmd
import veh.common.DisengageCause
import veh.common.SPEED_LIMIT_EU
import veh.common.DisengageCause
// ---------- function layer — pure, total, shared with expr ----------
function clamp(x: Speed, lo: Speed, hi: Speed): Speed =
if x < lo then lo else if x > hi then hi else x
// ---------- the model — a pure reaction, contract-blind (§7) ----------
/**
* Cruise control behaviour. Inputs and outputs are plain kinded flows;
* no contract is named. An rsdl component binds this reaction to the
* CruiseControl service, wiring `current`/`brake`/`lever` to real signals
* and providing `engaged`/`target`/`disengaged`.
* @labels SIL_2
*/
model CruiseController(signal current: Speed, signal brake: boolean, event lever: LeverCmd)
: (signal engaged: boolean, signal target: Speed,
event disengaged: DisengageCause) {
// mode logic — event-triggered, signals hold between branches (§5.6)
when {
init -> { engaged = false, target = SPEED_LIMIT_EU }
brake -> { engaged = false // rising edge of a signal
emit disengaged = DisengageCause.BRAKE }
lever? if lever == LeverCmd.CANCEL
-> { engaged = false
emit disengaged = DisengageCause.DRIVER }
lever? if lever == LeverCmd.ENGAGE
-> { engaged = true, target = current } // capture-on-engage
}
// numeric state — evolves every step, aperiodic-safe (§6.3)
let err = target - current
init integ = 0.0
let integ = if engaged then clamp(last integ + err * dt, -50.0, 50.0)
else 0.0
}
The seam, end to end: Speed arrives with unit, range, and step (typl) — the
equations inherit dimensional checking and boundary enforcement; the model names
no timing and no contract — an rsdl component binds it to
CruiseControl, and the service's @20ms becomes the scheduler's deadline
(§6.1) at that binding; lever is the command routed in as an event; dt
makes the integral correct whether steps come at 20ms, on command bursts, or on
refresh deadlines.
Appendix B — Codegen Targets
Per the platform decision (concept note §8.3): behaviour compiles twice — Rust native and WASM — bindings go everywhere.
| Aspect | Rust (native) | WASM component |
|---|---|---|
| model | a struct (state) + fn step(&mut self, inputs: &Inputs, ctx: StepCtx) -> Outputs | same, behind the component model; WIT signature generated from the contract |
| function | fn — pure, #[no_panic]-style discipline | core function |
last state (+ seeds) | struct fields, initialized at construction | linear memory, initialized at instantiation |
| step scheduling | ridl-rt scheduler: input arrival + deadline queue from contract bounds | host runtime drives step() — wasmtime on ECU/edge, browser at the person boundary |
| step faults | Result-shaped step return → invalid-state propagation via bindings | trap-free: fault is a returned status, never a WASM trap |
| determinism | IEEE 754 strict, no fast-math, fixed evaluation order | identical — the reference-oracle guarantee (§6.4) |
| observers | compiled in for oracle/test builds, out for production (rsdl choice) | always available in oracle builds |
Kotlin/TypeScript get bindings to models (call a WASM build, subscribe to its flows), never behaviour codegen — the matrix stays collapsed.
Appendix C — Formal Grammar (EBNF)
Additions to the typl grammar (shared productions per typl Appendix E):
definition = [ "internal" ] ( typl_definition | function_def | model_def ) ;
(* ---------- Functions ---------- *)
function_def = doc_comment? "function" camelCase_id "(" param_list ")" ":" type_ref
attr_block? fn_body ;
(* attr_block: [ require expr … ensure expr … ] — §9.2, ridl grammar *)
fn_body = "=" expr | block ;
block = "{" { let_binding sep? } expr "}" ;
let_binding = "let" camelCase_id [ ":" type_ref ] "=" expr ;
(* ---------- Models ---------- *)
model_def = doc_comment? "model" CamelCase_id signature
attr_block? "{" { model_item sep? } "}" ;
signature = "(" param_list ")" ":" "(" param_list ")" ;
(* no `realizes`, no `returns` — a model is a pure signature; contract
binding is an rsdl component's job (§7). Params carry kinds (§5.1). *)
model_item = equation | init_eq | let_binding | instance_bind | when_block | case_block ;
case_block = "case" expr "{" { case_eq_branch sep? } "}" ; (* mode dispatch — §5.2 *)
case_eq_branch = pattern "->" "{" { ( equation | let_binding ) sep? } "}" ;
equation = camelCase_id "=" expr ;
init_eq = "init" camelCase_id "=" expr ; (* seed — §5.3; expr limited to
constants and first-step inputs *)
instance_bind = "let" "(" camelCase_id { "," camelCase_id } ")" "="
CamelCase_id "(" arg_list ")" ;
(* ---------- When blocks — §5.6 ---------- *)
when_block = "when" "{" { when_branch sep? } "}" ;
when_branch = when_cond "->" "{" { ( equation | let_binding | emit_stmt ) sep? } "}" ;
when_cond = "init"
| camelCase_id "?" [ "if" expr ]
| "(" camelCase_id "?" { "," camelCase_id "?" } ")"
| expr ; (* rising edge *)
emit_stmt = "emit" camelCase_id "=" expr ;
(* ---------- Expressions ---------- *)
expr = expr binop expr | unop expr
| "if" expr "then" expr "else" expr
| "case" expr "{" { case_arm sep? } "}"
| expr "?:" expr (* default for optionals *)
| "last" camelCase_id (* previous-step value — model only *)
| "now" | "dt" (* ambient time values — contextual
identifiers, model context only *)
| camelCase_id "(" arg_list ")" (* function call / combinator *)
| fn_literal
| primary ;
case_arm = pattern "->" expr ;
pattern = SCREAMING_SNAKE_ID (* enum value *)
| camelCase_id camelCase_id (* union arm + binding *)
| "some" camelCase_id | "none" (* optional *)
| "else" ;
fn_literal = "function" "(" param_list ")" "=" expr ; (* combinator args only *)
binop = "+" | "-" | "*" | "/" | "%" | "==" | "!=" | "<" | "<=" | ">" | ">="
| "&&" | "||" ;
unop = "-" | "!" ;
primary = literal | camelCase_id | qualified_id | "(" expr ")"
| expr "." camelCase_id ; (* field access *)
Appendix D — Prior Art Survey
| Source | Taken / rejected |
|---|---|
| GRust (langrust — É. Thomé, doctoral thesis; reviewed) | the closest sibling effort, convergent by shared ancestry. Structure: GRfrp (async services + import/export interfaces) · GReact (Rx-inspired operator library) · GRsync (functions/components, Lustre-derived, clock-free by design) · GReusot (requires/ensures contracts, deductively verified via Creusot). Adopted: when/emit (§5.6), event flows as T?, signal-hold semantics, event→command side effects (§5.7), causality-partitioned async compilation (§6.6), contract clauses on functions/models with deductive proof as a verification way (§9.2). Adapted: GReact's operator library → ridl.std.flow with corrected naming (§12.6: scan/throttle collide with Rx tradition). Memory: last x + init x adopted outright (§5.3), replacing the draft's Lustre pre/-> — total memory, no nil, no dominance analysis; rmdl extends GRust's constant-only init to expressions over first-step inputs (capture-at-t₀) and adds implicit seeding from channel init values. Differs: component/service/interface split maps onto model + rsdl wiring + ridl contracts; time() as library operator → promoted to language (now, §6.3); untyped int/float vocabulary → typl units/ranges |
| Lustre | the foundation: equational dataflow, pre/->, causality analysis, the synchronous hypothesis. Departed from: the implicit periodic activation — rmdl steps are runtime-scheduled (§6); Lustre's semantics were always activation-agnostic in principle, rmdl makes it the definition |
| SCADE | the industrial proof that this lineage certifies (DO-178C/ISO 26262 qualified codegen); state-machine sugar studied and deferred (§12.3); its "one construct, hierarchic composition" informed collapsing node into model |
| Esterel / Signal | the imperative and relational siblings; Signal's polychrony is the intellectual ancestor of the multi-activation question (§12.1) |
| Lingua Franca | the closest contemporary for §6: event-driven reactions, logical time, deadlines as first-class scheduler constraints. Departed from: LF's distributed coordination ambitions — rmdl stays inside the sync island and lets rsdl own distribution |
| Simulink | the audience's home tool: model naming, the numeric-library expectation (§12.6), and the cautionary tale — untyped lines and implicit rate transitions are precisely what typl types and the sync/async wall forbid |
| Zélus / hybrid synchronous | continuous-time ODEs beside discrete flows — out of scope, but dt keeps the door open for host-side integration schemes |
| Kotlin / OCaml | the function surface: expression-bodied definitions, let bindings, ?:; OCaml's discipline that everything is an expression |
| SPARK Ada / MISRA | the totality doctrine: no recursion, no unbounded loops, decidable WCET as a language property rather than a coding rule |
| TLA+/model checkers | rejected as surface (specification-only, not executable-first), but the observer machinery (§9) is designed to hand equations to a checker later |
Appendix E — Coverage Analysis: Behaviour Languages
✓ covered, ≈ covered differently, ✗ not expressible (deliberate or open).
| Construct | rmdl equivalent | Status |
|---|---|---|
| Lustre node / SCADE operator | model | ✓ unified |
pre, -> | last + init (seed model) | ≈ adopted from GRust — total, no nil |
| node instantiation, hierarchic composition | model instantiation, synchronous | ✓ |
Lustre clocks when/merge/current | multi-activation | ✗ deferred §12.1, keywords reserved |
| SCADE state machines | enum + when mode logic; sugar deferred | ≈ §12.3 |
GRust when/emit event equations | when/emit (§5.6) | ✓ adopted |
| GRust flow operators (sample/scan/on_change/persist/throttle/merge) | stdlib adapter models | ≈ §12.6 candidate |
timeout / reaction to absence (GRust timeout) | timer-triggered steps | ✗ open §12.9 |
| periodic execution | degenerate case of scheduled steps (periodic output demand) | ✓ |
| event-driven execution (LF reactions) | input-triggered steps | ✓ native |
| LF deadlines | contract @[..max] → scheduler constraint | ✓ from the contract |
| elapsed time / timers | now, dt | ✓ |
| Simulink continuous blocks (ODE solvers) | — | ✗ out of scope (discrete only) |
| Simulink saturation blocks | clamp + §12.5 boundary policy | ≈ open |
| lookup tables / interpolation | bounded arrays + functions | ✓ |
| iterators over buses/vectors | bounded combinators | ✓ bounded by construction |
| recursion | — | ✗ deliberate (totality) |
| exceptions | — | ✗ deliberate: step faults (§8) + errors-as-data at contracts |
| side effects / imperative state | — | ✗ deliberate: last is the only memory |
| assertions/observers | inherited contract observers; local invariants pending expr | ≈ §9 |
| formal verification hooks | deterministic step semantics + observers → checker-ready | ≈ designed-for, not shipped |
Verdict. rmdl covers the discrete synchronous working set (Lustre/SCADE) and the event-driven working set (Lingua Franca) with one construct and two operators, because the scheduled-step semantics subsumes both periodic and reactive execution. The deliberate refusals — recursion, exceptions, side effects, continuous time — are each load-bearing for a guarantee (WCET, no-silent-fault, determinism, discreteness). The honest gaps are multi-activation and state-machine sugar, both reserved rather than improvised.
Appendix F — Glossary
Family terms are defined in the typl/ridl glossaries and mean the same here. rmdl-specific:
| Term | Definition |
|---|---|
| function | a pure, total, timeless computation — no state, no recursion, no unbounded iteration; the expr core's function layer |
| totality | the function-layer contract: always terminates, WCET decidable — bought by banning recursion and unbounded loops |
| model | the unified reactive construct: a pure reaction (O,S)=M(I,S) — typed kinded flows, equations, memory, per-step semantics; contract-blind (no realizes); there is no node |
| equation | the single defining expression of a flow; unordered in source, scheduled by data dependency |
| flow | a typed value evolving over steps — an input, output, or local of a model; every flow has a kind: signal or event |
| signal flow | a state-kind flow: reads as T, sampled at step start, holds between assignments |
| event flow | an occurrence-kind flow: reads as T? — present with payload when the occurrence arrived this step, absent otherwise; raised by emit, never held |
when equation | the event-triggered equation block (GRust heritage): ordered branches on init / e? / rising edges; first match runs; signals it defines hold, events it emits don't |
case equation | the mode-dispatch equation block (GRust's match equation): selects an equation set by a per-step value; exactly one branch every step, total definition, no hold — when's opposite discipline |
emit | raises an event flow with a payload inside a when branch — the only way behaviour produces occurrences; a side effect only once the event is bound to a command (§5.7), a binding rsdl reserves (rsdl §12) |
last | the value of a flow at the previous step — the only memory in the language; total, thanks to seeds |
init (equation) | the seed: what last x yields at the first step — explicit (init x = e) or implicit from the channel init value (§5.3) |
| step | one atomic synchronous reaction — inputs snapshotted, equations evaluated, outputs published; scheduled by the runtime on inputs and constraints, never by polling |
| activation | the runtime's decision to run a step — input-triggered or deadline-triggered, coalesced per contract min bounds |
| ambient time values | now and dt — contextual identifiers (not keywords) supplying logical time inside models and contracts; never read from a wall clock, never available in functions. The step itself is semantics vocabulary, not surface syntax |
| logical time | the time of a step's cause (triggering occurrence's sender timestamp, or the deadline instant) — what models compute with; identical in live execution, backlog, and replay. physical − logical is the runtime's lag metric, a health signal, never a model input |
| timeline | the scheduler's ordered set of due instants, projected from inputs (envelope times) and the contracts (deadlines, floors, timers); real time only gates it in live mode — in replay the gate is open (§6.7) |
| step trace | the sequence of (input snapshot, now) pairs — the total determinant of a model's behaviour; the replay artifact |
time(f) | language-level accessor for a flow's envelope timestamp — the basis of latency/deadline contract terms (§9.2) |
| island | a causally independent partition of a model's equation graph — compiled as its own step unit, run concurrently by the runtime with observational equivalence guaranteed (§6.6) |
| step fault | a runtime fault (division by zero, boundary range violation) aborting the step atomically — state preserved, output flows go invalid, everything recorded |
| observer | a boolean flow evaluated each step with no effect on outputs — the four-way assertion machinery anchored in the model |
| causality | the acyclicity requirement on same-step dependencies; last is the only legal self-reference |
| synchronous hypothesis | the reaction is logically instantaneous relative to its environment — inherited from the Lustre tradition, honoured per step |
Appendix G — ridl.std.flow: The Retained Operator Set
Normative sketch. Curation criteria: an adapter is retained only if it is
(a) not redundant with contract timing bounds (min/max already provide
provider-side debounce, throttle, refresh, TTL), (b) not a trivial inline
expression, and (c) precisely definable under the signal/event/timeline
semantics — preferably in rmdl itself, making the language its own stdlib
spec.
Two dispensations the stdlib enjoys and user code does not: adapters are
intrinsics — polymorphic in one payload type (the family rejects user-facing
generics; intrinsics get the same special status as typl's built-in collection
constructors), and they may take static function parameters
(compile-time-bound function references, no closures — the GRust
merge_int(order: …) precedent).
G.1 Tier 1 — Instant-preserving (v0.1; definitions are rmdl)
Hold(event e: T, init: T) : signal T — zero-order hold, the event→signal
bridge.
model Hold(event e: T, init0: T) : (signal out: T) {
when {
init -> { out = init0 }
e? -> { out = e }
} // hold semantics of `when` do the rest
}
Changes(signal s: T) : event T — the signal→event bridge. Emits the new
value when s != last s; the comparison seed is s's channel init, so a first
live value equal to the init emits nothing — no start-of-world occurrence.
model Changes(signal s: T) : (event out: T) {
when { s != last s -> { emit out = s } }
}
RisingEdge(signal s: boolean) : event () / FallingEdge(s) — edge
extraction; Changes specialised to the two boolean transitions. Named in full
so each is self-glossing out of context (Rising alone reads as "increasing" on
a numeric). Retained because button/flag edges are ubiquitous.
Filter(event e: T, p: function(T): boolean) : event T — keeps occurrences
satisfying p. The Rx essential GReact omitted. (Disambiguation note for
control readers: this is occurrence filtering, not frequency filtering — the
signature (event in, predicate arg) is the tell; frequency work lives in
std.control as LowPass.)
model Filter(event e: T, p: function(T): boolean) : (event out: T) {
when { e? if p(e) -> { emit out = e } }
}
Accumulate(event e: T, init: U, f: function(U, T): U) : signal U — running
fold over occurrences (scan's true Rx meaning, honest name). Subsumes
counters, totals, running min/max.
model Accumulate(event e: T, init0: U, f: function(U, T): U) : (signal acc: U) {
when {
init -> { acc = init0 }
e? -> { acc = f(last acc, e) }
}
}
Deadband(signal s: T, delta: T) : signal T — forwards only when the value
moved more than delta from the last forwarded value (not the last input).
T numeric/unit-typed; seeded by s's channel init.
model Deadband(signal s: T, delta: T) : (signal out: T) {
when {
init -> { out = s }
abs(s - last out) > delta -> { out = s }
}
}
Prefer(event e1: T, event e2: T) : event T — forwards whichever occurrence
is present; on same-step simultaneity e1 wins and e2's occurrence is
dropped and counted (observability). Renamed from Merge by the naming
review: Rx merge is famously lossless, and our simultaneity semantics make
loss real — Prefer puts the priority, and therefore the loss, in the name.
When every occurrence matters, bind both events into one model's when instead.
Latch(signal set: boolean, signal reset: boolean) : signal boolean — SR
latch, reset priority (the safety convention). Four lines everyone otherwise
writes differently; standardising the reset priority is the point.
G.2 Tier 2 — Instant-creating (gated on §12.9 timer steps)
These register instants on the timeline (§6.7); their definitions are scheduling semantics, not equations:
Clock(d: Duration) : event () — the primitive instant source: an
occurrence at every multiple of d from instantiation. Everything periodic
derives from it (GReact's period; Ticker was the draft name — rejected:
Go-only lineage plus the stock-ticker false friend, where Clock reads
instantly for EE, control, and synchronous-tradition audiences alike). The
doctrine, precisely layered: there is no clock in the runtime or the causal
analysis — the scheduler runs a timeline (§6.7) and causality never assumes
ticks; a clock in the functional description is legitimate and explicit — a
declared, local, owned source of instants, projected onto the timeline like any
other cause. The lineage is exact twice over: a hardware clock line is a
periodic pulse train, and a Lustre clock is the stream of instants a
computation activates on — making Clock the embryo of multi-activation
(§12.1): a sub-model activated "on" a Clock is Lustre's when, rediscovered.
Sample(signal s: T, d: Duration) : event T — s's current value at each
Clock(d) instant. (≡ when tick? -> emit out = s over a Clock —
definitional.) Output kind is event, deliberately: a periodic reading is a
sequence of occurrences; Hold it if state is wanted.
Coalesce(event e: T, d: Duration) : event T — burst conflation,
trailing-window: the first occurrence opens a window and schedules an
instant at time(e) + d; at that instant the latest occurrence received in
the window is emitted and the window closes. Bounded latency d, at most one
output per window, no occurrence-content lost except superseded values.
Watchdog(event e: T, d: Duration) : event () — absence detection: each
occurrence (re)arms an instant at time(e) + d; if reached without re-arm,
fires once and disarms until the next occurrence. Logical-time based (§6.3), so
replay reproduces watchdog fires exactly.
G.3 Control tier (ridl.std.control, instant-preserving, dt-correct)
Integrator(signal x: T, init: T, lo: T, hi: T) : signal T — saturating
rectangular integration: out = clamp(last out + x * dt, lo, hi).
RateLimiter(signal x: T, up: T, down: T) : signal T — slew limiting:
output moves toward x by at most up * dt / down * dt per step.
LowPass(signal x: T, tau: Duration) : signal T — first-order filter,
aperiodic-correct: alpha = dt / (tau + dt). (Doc alias: PT1 — the
German-automotive term for the same block.)
G.4 Cut, with reasons
| Not retained | Why |
|---|---|
| time-based debounce/throttle of a channel | the contract's min bound — annotation, not operator |
| TTL / staleness | the contract's max bound |
time() | language: now, time(f) (§6.3) |
Map over events | inline expression: emit out = f(e) |
Delay(e, d) | needs a bounded pending-occurrence queue (multiple in flight) — deferred until the bound story is designed |
Zip / windows / buffers | unbounded or alignment-fragile; no demonstrated automotive need |
Derivative | a noise amplifier shipped as a one-liner invites misuse; provide filtered variants in std.control when demanded |
End of rmdl Language Reference v0.1.0 — Draft.
Built, and read by no runtime. The compiler checks
.rsdlfiles against this reference, lowers the system to the IR beside the package IR (§13), andridl diffcompares at the system (§14). No runtime reads the lowered system yet, and §12's reserved items are not built. See Describing a system and what is built.
rsdl Language Reference
Reactive System Description Language — the architecture layer of the RIDL family: which components a system is made of, which services each offers and which interfaces each requires, what ships together, and where each copy runs.
Version: 0.2.0 — Draft
Provenance and supersession. This document replaces the rsdl reference v0.1.0 as a whole. v0.1.0 described components as situated reactions, wired them with application notation, placed them on capability-class targets, derived transport and posture, and shipped them in bundles. Every one of those constructs is retired here except two, which are reserved (§12): posture derivation, and the binding of a reaction to an interface's members that application notation also wrote. v0.1.0 is kept for provenance at
../archive/rsdl-language-reference-v0.1.md. The language stated here was settled in the rsdl rewrite decisions of 2026-09-12 and 2026-09-13 (decisions D-1 to D-11), over the topology vocabulary of 2026-09-08. Doc comments (/** … */,///) are CommonMark, as everywhere in the family.
Table of Contents
- Scope and Position in the Family
- Files, Packages, Imports and Keywords
- The Five Declarations
- Member Lines and Body Lines
- Attributes
- The Implicit Component
- Instances and Redundancy
- Resolution
- Placement
- Crossing Kinds
- Grants
- Reserved and Deferred
- What the Lowering Produces
ridl diffat the System- Conventions
- Diagnostics
- Open Questions
1. Scope and Position in the Family
1.1 What rsdl describes
rsdl answers one question: what is this system made of, and where does each
part run? A .rsdl file names the components of a system, the services each
component offers and the interfaces each component requires, the distributions
that ship them, and, per deployment, the machine that hosts each copy of each
component. The compiler lowers that description to the facts a runtime reads
(§13): who produces each service, every link between a consumer and a producer
with its crossing kind, the routing table, the grants, and the system's external
boundary.
rsdl records nothing of its own. The contracts are ridl's: a service, its
interfaces and their members are declared in .ridl files, and the numbers that
identify an interface on the wire come from a per-unit lock file, an input to
the lowering (§13). Behaviour is rmdl's and is not referenced in this release
(§12). What rsdl adds is structure — which parts exist, how they relate, and
where they run — and every fact it lowers is derived from that structure.
1.2 Position in the family
rsdl is the apex of the family lattice, typl ← {ridl, rmdl} ← rsdl. It is the
one layer that can never stand alone: a component offers services and requires
interfaces that only ridl can declare. rsdl composes by import and never by
inclusion, with the family's import rules unchanged (§2).
1.3 Three trees, not one hierarchy
A component appears in three trees, related to each in a different way:
| Tree | Shape | What it is for |
|---|---|---|
| distribution | distribution → components | version, sign, install |
| runtime | machine → component instance | execute |
| contract | service → interface → member (ridl) | publish and address |
The nouns, each answering one question:
| Noun | Answers | Layer |
|---|---|---|
| distribution | what is versioned, signed and shipped | rsdl |
| machine | what software is installed on; the unit of placement | rsdl |
| component | one execution context; offers services, requires interfaces | rsdl |
| service | what is addressed on the wire | ridl |
| interface | what is contracted | ridl |
| member | one typed interaction | ridl |
| catalog | a unit's interfaces, their numbers, one hash | ridl |
Only what is addressed has identity on the wire. A service's dotted name and a catalog do; distribution, machine, component and instance names never travel. They are build-time and deployment-time names, and a runtime artifact that carried one would couple two layers that are separated on purpose.
1.4 The component and what rsdl does not declare
A component is one execution context. rsdl declares no process, no pump kind, no priority and no other scheduling fact (§12). Whether two components on one machine share an address space is the backend's or the host program's decision; a crossing between two components keeps the same semantics whichever transport carries it, so that decision is an optimisation and never a change of meaning.
rsdl does not declare the transport over a crossing, or the network and machine fabric. Both are the implementer's configuration (§10). A backend that needs a fact rsdl does not own reads it from a namespaced attribute key (§5).
1.5 One system per workspace
A workspace declares at most one system (§3.1). The system is the closure
every completeness check quantifies over, every deployment is for it, and
ridl diff compares at it (§14).
2. Files, Packages, Imports and Keywords
An .rsdl file is a restricted profile of the family grammar (concept note §4):
it holds the five declarations of §3 and nothing else. A type, an interface or a
service declared in an .rsdl file is RSDL-604; the catalog is ridl's
(vocabulary V-15) and the vocabulary is typl's.
- Package. Every
.rsdlfile begins with onepackagedeclaration (typl §3.1). A package is a directory (ADR-0002) and may hold.typl,.ridland.rsdlfiles together. A package may hold several.rsdlfiles: a bench deployment commonly lives in its own file. - Imports follow typl §3.2 unchanged: a single named import
(
import veh.adas.LaneAssist) or a fully qualified name in place. There are no wildcards, no relative imports and no re-exports (TYPL-003). An unused import is TYPL-007. A service is named by its global dotted name (veh.adas.cruise) and is never imported (ridl §14.5). - An import makes names usable and does nothing else. It generates code for
the importer (ADR-0002) but never wires, grants or places; only a
requiresline does (vocabulary V-06). - One system per workspace. A workspace declares at most one
system(§3.1); a second is RSDL-601. - Modifiers. The five declarations take no
internalmodifier in this release: the closure is workspace-wide, so every declaration it names has to be visible from the system's package.internalbefore an rsdl keyword is a parse error (FORM-102).
Keywords the rsdl profile uses, all full words, no colon anywhere:
system component distribution deployment machine offers requires for
plus typl's package, import and as. Every family keyword is reserved in
every profile (typl §1.4). The v0.1 words provides, instance, assurance,
target, place, on, transport, bundle, time, base, redundant,
supervise and degraded are retired from rsdl, and so is the <- wiring
arrow. No other profile uses a retired word, so each has left the family
registry (typl §1.4) and the implemented registry and is a legal identifier in
every profile (let stays, rmdl's); offers, distribution and machine are
reserved in both registries and are not legal identifiers in any profile.
requires (a component line) and the predicate attribute key require (general
form §4.3) never share a position: require is recognised only inside [ ],
and no rsdl declaration or line admits it.
Case carries the role (general form R7):
| Spelling | Role | Example |
|---|---|---|
CamelCase | component, system, distribution, deployment, machine | AdasHpc |
camelCase | instance — a member of its component | Cruise.primary |
lowercase.dotted | package, service | veh.adas.cruise |
SCREAMING_SNAKE | label, tier value | ASIL_B, PLATFORM |
The unit instance's name Unit (§7) is never written in source, so R7 does not
govern it.
3. The Five Declarations
Every declaration is the family's container shape (general form §2, Shape 3):
keyword, CamelCase name, optional relation clause, optional attribute block,
body. Only deployment has a relation clause, for, in the slot states and
realizes use elsewhere (R5). Attributes sit between the name (or the clause)
and the opening brace (general form §4.4). Bodies follow R8: newline and comma
interchangeable, trailing comma legal, no semicolons. Doc comments (typl §14)
may precede any declaration or body line.
Doc comments. Each of the five declarations and each body line — a member
line, an offers or requires line, and a placement line — is a doc carrier
(typl §14.4). Every declaration must have a doc: rsdl has no visibility, so
missing-docs (TYPL-406) covers all five. A body line takes a doc and never
requires one. The docs of the declarations, of the system and distribution
member lines, and of the offers and requires lines reach the lowered system
(§13); the doc of a placement line is checked and shown by the editor, and the
lowered system has no field for it. A doc link in an .rsdl file resolves in
the view of the package that declares the line, as in a ridl file (typl §14.5),
and draws TYPL-401 when it does not resolve.
| Declaration | Clause | Body holds | rsdl-owned keys |
|---|---|---|---|
system | — | member lines: components, lone services (§4) | labels, deprecated; one slot reserved (§12) |
component | — | offers and requires lines (§4) | instances, external, labels, deprecated |
distribution | — | member lines: components, lone services | tier, labels, deprecated |
deployment | for System | machine declarations | depth, slots, budget, labels, deprecated |
machine | — | placement lines: components, instances, lone services | external, labels, deprecated |
Every declaration and every line also takes backend keys (§5), and a placement
line also takes the three sizing keys of the deployment row. A key not in a
declaration's row, and not one of those three on a placement line, is FORM-107;
a key no row and no backend namespace defines is FORM-106; a key twice in one
block is FORM-108.
The names of system, component, distribution and deployment declarations
are in their package's namespace, beside the package's typl and ridl
declarations: a second declaration of one name in a package, of any profile, is
TYPL-009 (typl §16), except two deployments of one name, which RSDL-708 reports
(§3.4). A machine name is scoped to its deployment (§3.5).
3.1 system
A system is the closure: the named set of components a deployment has to
account for, and nothing else. It has no wiring, no boundary of its own and no
composite body; the external boundary is derived from its external components
and machines (§13).
system Vehicle { Cruise, Lane, Panel, Backend, veh.diag.access }
- A member line names a declared component or a lone service (§6). A line that names neither is RSDL-602; a name listed twice in one body is RSDL-603.
- The closure is the listed components plus the implicit components of the listed services. A service is in the closure when a closure component offers it; an interface is in the closure when a closure service lists it.
- A component the closure does not list is not lowered. No diagnostic.
- Every completeness check of §8, §9 and §3.3 quantifies over the closure; every
deploymentisforthe system;ridl diffcompares at it (§14). - A workspace with no
systemlowers nothing and draws no diagnostic; a secondsystemis RSDL-601.
3.2 component
A component is one execution context (vocabulary V-07). It offers services and requires interfaces; the verbs differ because the sides differ (V-05): a service is the unit of publication and addressing, an interface the unit of contract, and half a service cannot be offered.
component Cruise [ instances = (primary, backup) ] {
offers veh.adas.cruise
requires LaneAssist
}
component Lane { offers veh.adas.lane }
component Panel { requires CruiseControl, requires LaneAssist }
component Backend [ external ] { requires CruiseControl }
offerstakes one service reference. A name that is not a service is RSDL-310.requirestakes one interface reference. An inline-shape service's dotted name is the name of its one interface (ridl §14.5, group identity), sorequires veh.diag.accessis legal. A service with a list of shapes afterrequiresis RSDL-311, and the diagnostic lists its interfaces; a name that is neither an interface nor an inline-shape service is RSDL-312.- One reference per line. A second reference on the same line is a new body item
(R8), so
requires A, Bis a parse error atB(FORM-102); writerequires A, requires B. - The same service on two
offerslines, or the same interface on tworequireslines, is RSDL-309. - A component that requires an interface listed by a service it offers is RSDL-308: the consumer would sit in its own provider set (§8). Reopens with arbitration (§12).
- A component with no lines is legal and lowers to an instance with no links.
instancesgives the component named copies (§7);externalmarks a component with no implementation in this workspace (§9, §13). An external component has the same body and keys as any other.
3.3 distribution
A distribution is what is versioned, signed and shipped together (vocabulary §1). Its body is bare member lines (§4): components and lone services. It declares no machine and no dependency; both are derived (§13).
distribution Adas [ tier = PLATFORM ] { Cruise, Lane, veh.diag.access }
distribution Hmi [ tier = APPLICATION ] { Panel }
- A member line names a closure component or a lone service whose implicit component is in the closure. A name outside the closure, or unknown, is RSDL-903; a name listed twice in one body is RSDL-906.
- When the workspace declares at least one distribution, every implemented closure component is in exactly one: in none is RSDL-904, in two is RSDL-905. An external component is in none; listing one is RSDL-907. A workspace with no distribution derives no installation and no dependency.
tieris optional,PLATFORMorAPPLICATION(§5). APLATFORMdistribution holding a component whoserequiresresolves (§8) to a component in anAPPLICATIONdistribution is RSDL-901; a distribution withouttier, on either side, is exempt.- These rules are stated for components. A later
assetdeclaration lists additively with no grammar change (§12) and brings its own rule.
3.4 deployment
A deployment is one placement of the system. It is a top-level declaration,
for the system, and contains machine declarations and nothing else.
deployment Production for Vehicle {
machine AdasHpc [ labels = (ASIL_B) ] { Cruise.primary, Lane, veh.diag.access }
machine Cockpit { Cruise.backup, Panel [ linux.cpuset = (2, 3) ] }
machine Cloud [ external ] { Backend }
}
fornames the workspace's system, bare or qualified. A name that resolves to no declaredsystemis RSDL-704.- Deployment names are unique in the workspace: two deployments with one name is
RSDL-708. Several deployments
forone system are the normal case: a production topology and a bench topology differ only here. - A deployment declares no transport, no fabric and no time base; those are configuration (§10, §12).
- The declaration and its placement lines take the three sizing keys
depth,slotsandbudget(§5).
3.5 machine
A machine is the unit of placement: it lists the instances it hosts (§9). Machines are CamelCase because a machine has a body and is a container (R7). A machine is declared only inside a deployment; its name is unique within that deployment, and a duplicate is RSDL-705. The same name in two deployments names two machines.
- A placement line names a component (every instance), one instance, or a lone service (its unit instance). The rules are §9.
externalmarks a machine outside the built system — a cloud endpoint, a peer vehicle, a bus node the workspace does not build. An external machine hosts external components only (RSDL-707, §9).
4. Member Lines and Body Lines
Two line forms occur in bodies, both references and neither a declaration:
- The bare member line — a reference alone, as ridl's
servicelists its interfaces (ridl §14.5). It is the body item ofsystem,distributionandmachine. Its kind is the kind of the declaration the name resolves to (D-11), so no kind keyword is written. - The keyword-plus-reference line —
offers X,requires X, as ridl'sreserved Nameand the file-levelimport. It is the body item ofcomponent.
Either form may carry the attribute block at end of line (general form §4.4); a line takes backend keys only, except that a placement line also takes the three sizing keys (§5). A line's attribute node is the line within its container (§13).
The reference. A reference is a dotted name of one or more segments, and its role is read from case (R7), then confirmed by lookup:
| Form | Names | Where legal |
|---|---|---|
Name | a component in scope; in for, the system | every body; for |
Name.inst | one declared instance of a component | machine |
pkg.Name | a component (or the system) by qualified name | as Name |
pkg.Name.inst | one instance, qualified | machine |
pkg.service | a service by its global dotted name | offers, requires, every body |
A CamelCase segment names a component (or, in for, the system); a camelCase
segment after a CamelCase one names an instance; a name of lowercase segments
only is a service, with the leading segments read as its package path where the
lookup needs one. In requires, Name and pkg.Name name an interface and
pkg.service names an inline-shape service (§3.2). A reference whose case says
one role and whose lookup finds another, or nothing, draws the unknown-name code
of its slot: RSDL-602 (system), RSDL-903 (distribution), RSDL-702
(machine), RSDL-310 to RSDL-312 (component), RSDL-704 (for).
5. Attributes
Every declaration and every line takes the family [ ] block (general form
§4.2). rsdl uses two of its three forms — the flag and the assignment; no rsdl
key takes the predicate form. The principle (D-6): a core key is allow-listed
per declaration kind and has a machine consumer; every other key belongs to a
backend and is carried uninterpreted. The table is the attribute registry as
built (ridl_core::attributes, ADR-0012 decision 8): one row per key, with its
owner (core, or backend for the backend.key pattern), its form, its sites,
its consumer and what ridl diff does with a change to it.
| Key | Form | Legal on | Consumer | Diff |
|---|---|---|---|---|
instances | = (a, b, …) camelCase names | component | the instance set, §7 | reported under the heading of its site, §14 |
external | flag | component, machine | placement and the surface set, §9, §13 | reported under the heading of its site, §14 |
tier | = PLATFORM or = APPLICATION | distribution | RSDL-901, §3.3 | reported under the heading of its site, §14 |
depth | = N, integer occurrences | deployment, placement line | the ring depth of an event channel, §13 | not reported: sizing, §14 |
slots | = N, integer table entries | deployment, placement line | the slot count of a command or query channel, §13 | not reported: sizing, §14 |
budget | = N, integer bytes | deployment, placement line | the byte budget of a command or query channel, §13 | not reported: sizing, §14 |
labels | = (LABEL, …) | the five declarations | assurance profiles (typl §14.3) | reported under the heading of its site, §14 |
deprecated | = "reason" | the five declarations | the family lint; rsdl adds no rule | reported under the heading of its site, §14 |
backend.key | flag or assignment | every declaration and line | that backend, uninterpreted | reported under the heading of its site, §14 |
-
instancesmust be a parenthesised list of one or more camelCase names:()andinstances = soloare RSDL-305.externalis a flag and takes no value:external = trueis RSDL-313.tiertakes exactly the two values; any other is RSDL-908. -
A key absent from the family attribute registry is FORM-106; a registered key on a declaration or line its row does not name is FORM-107 —
instanceson a line,deprecatedon a line,labelson a line,tieron a component; a key twice in one block is FORM-108. The registered predicatesrequireandensurehave no legal rsdl site and draw FORM-107. Lines take backend keys only, except the three sizing keys a placement line takes. -
The sizing keys
depth,slotsandbudgetare integer literals. A value the parser reads that is not an integer within the key's range, or a key written bare with no value, is RSDL-709, which blocks its own deployment only (§13); a value the parser refuses, such asslots = 50ms, is FORM-101 and blocks the whole build.depthis 1 to 4294967295 and replaces the depth the toolchain derives from the event's timing;slotsis 1 to 65536 and defaults to 16;budgetis 1 to 18446744073709551615 and has no default, so onlyslotsbounds the calls in flight.depthsizes event channels andslotsandbudgetsize command and query channels; a key written where no channel of that kind exists draws nothing. A value on a placement line applies to every link the placed instance consumes. A value on thedeploymentdeclaration applies to every link of the deployment. The precedence per link is the placement line, then thedeploymentdeclaration; a key declared at neither site takes the defaultslots, the deriveddepth, and nobudget. RSDL-805 warns when a declareddepthis below the contract boundceil(max / min)of an event a covered link consumes. RSDL-806 warns when an event whose contract bound is not derivable is consumed by a link with no declareddepth. -
A backend key is
backend.key—someip.serviceId,linux.cpuset,rust.crate— a camelCase namespace, a dot, a camelCase key (typl Appendix E'scamelCase_id, which admits no underscore). The compiler carries it into the attribute map of its node (§13) as declared: flag or value, never interpreted. A backend declares the keys it consumes through the plugin contract and validates its own namespace; its findings are the backend's. A namespace no configured backend claims is RSDL-804, a warning: the lowering proceeds and the key is carried.The manifest's
[backend.<name>]table (ADR-0002 §4, MANI-015) is a separate thing: it configures a backend for the whole build, for exampleencodings = ["flatbuffers"], and it is not an attribute and is not part of this namespace. -
labelson a declaration is carried per node beside the map, as the family's own fact; an rsdl-owned key is consumed into the fact it feeds and does not appear in the map.
This block is the single "later" mechanism (§12): the certified-level lint, a fabric fact, a tag-based transport's service number, a component's implementation all arrive as keys, earned by a consumer that reads them.
6. The Implicit Component
A service declared in ridl that no declared component offers may stand for an
implicit component: a single-service component of the same name, with the
unit instance (§7) and nothing else — no requires, no instances, not
external.
system Vehicle { Cruise, Lane, veh.diag.access }
distribution Adas { Cruise, Lane, veh.diag.access }
machine AdasHpc { Cruise.primary, Lane, veh.diag.access }
- The derived component name is the service's dotted name. It appears only in those lists, in diagnostics and in the IR; nothing else ever names it, so it is not an addressed identity (vocabulary V-02).
- A service name stands for a component only when no declared component in the workspace offers the service. Listing a service that a declared component offers is RSDL-504, and the diagnostic names the offerer.
- An implicit component is implemented (§3.3 applies: it is in exactly one
distribution when any exists). An external or stub provider needs an explicit
componentwith theexternalflag. - A system of hand-written services is a list of service names and nothing else (D-10).
The rule behind this, stated once: derive the unaddressed noun from the addressed one, never the reverse. The service is what the wire addresses (ridl §14.5); the component is derived from it. An implicit service named after a component would make the component name an address, which V-02 forbids; an interface declared inline in a component would have no name of its own and no catalog (D-1).
7. Instances and Redundancy
instances on a component names its copies. Instances are named, never
numbered, so a name tells a team what the copy is for.
component Cruise [ instances = (primary, backup) ] { offers veh.adas.cruise }
- The unit instance. A component without
instanceshas exactly one instance, namedUnitin the IR and in diagnostics (Cruise.Unit) and written in source by the bare component name:Cruisein a machine body placesCruise.Unit. Declaringinstancesreplaces the unit instance; it does not add to it. An implicit component (§6) has its unit instance only. Unitis never written.Cruise.Unitin a body andUnitin aninstanceslist are both RSDL-307. R7 keeps a declared instance camelCase, so the CamelCaseUnitcannot collide with one.- Instance names are unique within a component; a duplicate is RSDL-306.
Cruise.primaryandLane.primaryare two instances of two components. - Instances share the component's offers and requires, and are indistinguishable on the wire (V-02): no instance identifier travels.
- Redundancy is derived. An offering component with more than one instance
is that service's redundant provider set. The lowering derives it, lowers
every link to every member of the set (§13), and reports each
requiresthat resolves to such a set as RSDL-409 — a warning, "not yet realizable", because the runtime has no arbitration yet (failover, voting, two writers on one member; §12). Emission is not blocked. - Limit, deliberate. Instances share their offers, so a left sensor and a right sensor offering two different services are two component declarations, not two instances of one.
8. Resolution
A requires line names an interface and never a provider. The lowering resolves
it in three steps, each total over the closure:
- interface → owning service: the one closure service whose shape list contains the interface (an inline-shape service owns its one interface).
- service → offering component: the one closure component with an
offersline naming the service. - component → instances → machines: every instance of that component, and the machine each is placed on in the deployment at hand (§9).
Two closure rules make the lookup total, both errors:
- Every service in the closure is offered by exactly one closure component. Two is RSDL-502 — redundancy is a derived case of one component (§7), never a second offerer. A service no closure component offers is not in the closure at all.
- Every interface a closure component requires is listed by exactly one closure service. None is RSDL-403 (missing provider; the diagnostic names a component outside the closure that offers a listing service, when one exists). Two closure services listing one interface is RSDL-408, raised for every interface they list, required or not, because the routing key has no service in it and the interface would have two owners.
The result of resolution, per requires, is one link per (consumer instance,
producer instance), each with its crossing kind (§10). A component with two
instances requiring a service offered by a component with two instances yields
four links. Two components may require the same interface; each resolves
independently.
Every requires is mandatory in this release, with one provider (D-11): a
required interface that cannot be resolved blocks lowering for every deployment.
optional is reserved (§12).
9. Placement
Placement is membership: a machine body lists what it hosts. Nothing is placed
by a clause, and a service's machine is never declared — it is derived through
resolution (§8).
machine AdasHpc { Cruise.primary, Lane, veh.diag.access }
machine Cockpit { Cruise.backup, Panel [ linux.cpuset = (2, 3) ] }
- A bare component name places every instance of that component — the unit
instance when it declares no
instances, every declared instance otherwise.Cruise.primaryplaces one. A lone service name places its implicit component's unit instance. An attribute block on a bare component line applies to each placement it makes. - Every instance of every closure component is in exactly one machine per
deployment. An instance no machine of the deployment lists is RSDL-701. An
instance reached twice in one deployment — listed on two machines, or listed
twice on one, or reached by
Cruiseand again byCruise.primary— is RSDL-706. - A placement line naming a component, instance or service outside the closure, or a name that resolves to nothing, is RSDL-702.
- External components and machines. An external component is placed like any
other: an external system is an
externalcomponent on anexternalmachine, not an exception to RSDL-701. An external machine hosts external components only; an implemented component on one is RSDL-707. An external component may sit on an on-board machine — that is a stub (vocabulary V-20), and its links are then same-machine or different-machine crossings (§10). - Placement is per deployment. The same component is placed once in
Productionand once again, elsewhere, inBench; neither placement is visible to the contract (vocabulary V-21).
10. Crossing Kinds
Every link (§8) carries one of three crossing kinds, derived from the two machines its endpoints are placed on in the deployment:
| Crossing | When |
|---|---|
same machine | both instances are on one machine |
off-board | the machines differ and at least one of them is external |
different machine | the machines differ and neither is external |
- The kind is all the physical layer a link carries (D-5). The transport over a
crossing, and the network and machine fabric — VLANs, VM placement, CPU
clusters — are the implementer's configuration, outside the language. If they
ever enter rsdl they arrive as attributes on
machineor a block underdeployment, earned by a backend that reads them (§5). - Whether two components on one machine share an address space is the backend's or the host program's decision, not rsdl's (D-2): rsdl declares no process and carries no pump kind, priority or other scheduling fact. Under vocabulary V-09 a same-machine crossing optimised into a direct channel keeps the semantics of the store and the queue.
- A link with an
externalcomponent at both endpoints is not lowered: it crosses nothing the workspace builds. A link with an external component at exactly one endpoint is lowered with its kind and enters the surface set (§13); its enforcement is the external side's, not this runtime's. - Posture — static bus signals against a discovered service — is not derived in this release (§12).
11. Grants
A grant is derived per component, never written. The lowering resolves every
requires line through its one owning service (§8); the grant is the set of
catalog regions its requirements reach (vocabulary §6, D-9). The region an
interface reaches is the catalog that numbers it — the catalog of the unit that
declares the interface (vocabulary V-16, V-17), whichever unit declares the
owning service.
- A consumer names interfaces, so it is granted only the regions of the interfaces it names — least privilege — and a service that gains or splits interfaces does not touch its consumers' grants (V-05).
- A producer's write side is not a second list: it is read from the producers fact (§13) — the offering component reaches the regions of every interface its services list.
- A backend that groups components — into processes, or Classic components into OS-Applications — unions the grants per group (D-2).
- An external component's grant is derived and lowered with the
externalflag; enforcing it is outside this runtime.
12. Reserved and Deferred
Posture. A ridl service is posture-neutral: the same declaration can be
realized as static bus frames (signal-based, Classic) or as a discovered service
(service-oriented, Adaptive). Deriving that posture per deployment from
placement would let one contract deploy both ways, and would give a Classic to
Adaptive migration with no contract rewrite. A command or query member
cannot be realized on a pure static bus, because a bus carries dataflow and not
calls. rsdl derives no posture in this release. Posture derivation, the
timing-feasibility check RSDL-801 and the RPC-on-a-static-bus constraint
RSDL-803 reopen together with a bus-class backend; the two codes stay reserved
until then (§16).
Behaviour. A component's implementation is one fact in rsdl, present or not
(external, §3.2). What the implementation is — a crate, a language, later an
rmdl model — is a backend key (§5). The binding of an rmdl reaction to the
members of an interface, which v0.1 wrote in application notation, is not in
this release and returns when rmdl is scheduled.
Cardinality on requires. Every requires is mandatory with one provider
(D-11). An optional flag on the requires line is the attribute-first path
when variant handling arrives; it will be the one rsdl-owned key legal on a
line.
Redundancy arbitration. A redundant provider set (§7) is derived and reported not yet realizable (RSDL-409). Failover, voting and two writers on one member are engine questions; a redundant pair placed in one machine is a profile lint later. RSDL-308 reopens with arbitration.
The assurance-profile slot. One attribute key on system is reserved for
the assurance profile in force (v0.1's assurance automotive). The key is
profile-gated and not named in this release; assurance is no longer a keyword.
The certified-level lint. A machine is certified for a level; a component
whose labels demand a higher level, placed on it, is a lint under the
automotive profile, via labels — out of this release (D-2).
A partition under machine. rsdl declares no process (D-2). What reopens a
grouping noun is a consumer whose safety argument needs a mixed-level placement
rejected at design time; the shape would then be an optional partition grouping
under machine, with one implicit partition when none is declared.
Assets. A distribution's member lines carry no kind keyword, so a later
asset declaration lists additively (D-11); it reopens the member-kind question
only if a member kind ever has to exist without a declaration.
13. What the Lowering Produces
The lowering runs over the closure once and then once per deployment. It
produces facts, stated here as facts and not as a schema; a runtime's
descriptor is an emitter over them and is specified with the runtime. Inputs
from outside rsdl, cited once: the interface numbers, read from each unit's
generated lock file; the member ordinals, from position (ridl §11); and the
catalog hash per catalog, computed by ridl over the interfaces, their
numbers and every type they reach (D-8) — embedded here as an input, never
computed by rsdl. The lock file is interfaces.lock, one per unit, written by
ridl lock alone (ridl §11); an inline shape is numbered from it too, under the
key service: and the service's dotted name (ridl §14.5).
Per deployment:
- Producers — for every service in the closure: the offering component, its instances, and the machine each instance is placed on; a redundant provider set is visible as more than one instance and carries the not-yet-realizable marker of RSDL-409.
- Machines and placement — every machine of the deployment, with its name,
its
externalflag and itslabels; and every instance of every closure component, with its component, the component'sexternalflag and the machine the instance is placed on (§9), including an instance that has no link. - The link set — for every
requiresline, one link per (consumer instance, producer instance): the interface, its owning service, the two instances, their machines and the crossing kind (§10). A link with two external endpoints is absent. - The routing table — (catalog, interface number, member ordinal) to the producing instances and their machines, for every member of every interface a closure service lists.
- The permission list — per component, the set of catalog regions its requirements reach (§11). The write side is read from the producers fact.
- The surface set — the system's external boundary: every link with an external component at exactly one endpoint, with its interface and its direction (the external side consumes, or offers). Off-board stays a crossing kind on the link; the surface set is the boundary read from the consumer's side.
- The region map — one region per catalog the closure reaches: the catalog's
name (its unit name, V-16), its hash, and the interfaces of that catalog that
closure services list, each under its catalog name (ridl §11). A unit is one
ridl.tomland the source packages in its tree (ADR-0002 §1), so the interfaces of several source packages share one region. - The attribute map per node — every backend key, as declared, on every node
that has an attribute site: a declaration (
system,deployment,machine,component,distribution), a member line of asystemor adistribution, a placement line (where an instance's placement carries its keys), anoffersline, or arequiresline (the link's site).labelsride beside the map per declaration. - The sizing values — the declared
depth,slotsandbudgetof the deployment and of each placement line, as written. Their resolution per link, and the derived depth, belong to the codegen request (codegen plugins). - Distribution installation and dependency — per distribution, the machines
hosting at least one instance of its components (installation, which differs
per deployment); and the distributions it depends on:
Adepends onB, a different distribution, when a component inArequires an interface whose owning service a component inBoffers (dependency, the same in every deployment). RSDL-901 is checked over this fact. Absent when the workspace declares no distribution. - The catalog hashes of every catalog in the region map, as received.
- The docs — the doc text, the resolved doc links, the
@seetargets and the@sinceversions of the system, each component and itsoffersandrequireslines, each distribution, eachsystemanddistributionmember line, each deployment and each machine (typl §14, ADR-0026). They are documentation only: no other fact depends on them, andridl diffat the system ignores them.
Errors and warnings. An error in the closure — RSDL-3xx, 4xx, 5xx, 6xx or 9xx — blocks lowering for every deployment. An error in one deployment — RSDL-7xx, which includes the sizing-value rule RSDL-709 — blocks lowering for that deployment only. A warning (RSDL-409, RSDL-804, RSDL-805, RSDL-806, RSDL-807) never blocks: the facts are produced and carry the warned condition.
14. ridl diff at the System
ridl diff compares at the system (D-10): the unit of comparison is the
closure's contracts — the services in the closure, the interfaces they list, and
their members — classified by the ridl categories. It classifies contracts
only (D-11). The comparison at the system adds to the package comparison and
never narrows it: a contract change in a package the closure does not reach is
still reported.
- A change in a deployment — an instance moved to another machine, a machine
added, removed or made
external— changes every link derived from the old placement and leaves the contract untouched.ridl difflists it under its own heading, placement changed, with no verdict: neither compatible nor breaking. A changedlabels,deprecatedor backend key on the deployment, on a machine or on a placement line is listed under the same heading, as a before-and-after line. A changeddepth,slotsorbudget, on the deployment or on a placement line, is not listed: the attribute registry records the three sizing keys as not reported, with the reason that a sizing value changes no link and no contract, andridl diffreads neitherDeployment.sizingnorPlacement.sizing. - A change to the closure or to a component's lines — a component added to or
removed from the system, an
offersorrequiresline added or removed,instanceschanged, a component madeexternal, alabels,deprecated,tieror backend key changed on the system, on a component, on a distribution or on one of their lines — is listed under a second heading, composition changed, likewise with no verdict, because it changes the derived links and not the contract. An added or removed distribution is listed under this heading, and its attributes are compared with an empty side. A member line added to or removed from a distribution — a component moved from one distribution to another is both — is listed under this heading, and its backend keys are compared with an empty side. - Which of these changes are breaking, and for whom, is decided by the stability policy (IR specification), not here.
15. Conventions
- Name a component for the capability it runs (
Cruise,LaneAssistant), an instance for its role (primary,backup), a machine for what it is (AdasHpc,Cockpit), and a service by its dotted global name (veh.adas.cruise). - Let a lone service stand for its component (§6). Write an explicit
componentwhen one is needed: it offers more than one service, it only consumes, it requires interfaces, it has instances, or it is external. - Require the narrowest interface that serves the consumer. A consumer that requires one interface is unaffected when the service that owns it gains or splits interfaces, and its grant stays the smallest one (§11).
- Declare instances only for copies that offer the same services (§7). Two parts that offer different services are two component declarations.
- Keep each bench or test topology as its own
deployment, in its own file. One system, several deployments. - Keep transport, network and machine fabric out of rsdl. If a backend needs one of those facts, it arrives as that backend's namespaced key (§5).
16. Diagnostics
Coded RSDL-, same lifecycle rules as typl §16: codes are never renumbered, a
retired code is kept in the table and never reused. Grouped by hundreds:
3xx component and instances 6xx system and workspace
4xx resolution 7xx deployment and machine — placement
and sizing values
5xx services 8xx transport, posture, backend keys
and sizing warnings
9xx distribution
An .rsdl file also draws the namespaces no profile owns, FORM- and MANI-,
tabulated once in the family overview §7, and the documentation codes of typl
§16.5 (TYPL-401 and TYPL-404 to TYPL-410), because doc comments are typl §14 in
every language of the family. The attribute-block rules FORM-106 (unknown key),
FORM-107 (key not allowed on this declaration kind) and FORM-108 (duplicate key)
cover the rsdl-owned keys of §5; no RSDL- code is minted for them. Codes not
shown in any table were never allocated.
16.1 Codes in force
| Code | Rule | Severity | Section |
|---|---|---|---|
| RSDL-305 | instances is not a parenthesised list of one or more camelCase names ((), instances = solo) | error | §5 |
| RSDL-306 | duplicate instance name in one component | error | §7 |
| RSDL-307 | Unit written in source — as a declared instance name, or as the instance segment of a reference | error | §7 |
| RSDL-308 | a component requires an interface listed by a service it offers | error | §3.2 |
| RSDL-309 | the same service on two offers lines, or the same interface on two requires lines, of one component | error | §3.2 |
| RSDL-310 | an offers line names something that is not a service, or nothing | error | §3.2 |
| RSDL-311 | a requires line names a service whose shape is a list of interfaces — the diagnostic lists them | error | §3.2 |
| RSDL-312 | a requires line names something that is neither an interface nor an inline-shape service, or nothing | error | §3.2 |
| RSDL-313 | external written with a value — it is a flag | error | §5 |
| RSDL-403 | a closure component requires an interface that no closure service lists (missing provider) | error | §8 |
| RSDL-408 | an interface listed by two services of the closure — raised for every interface they list | error | §8 |
| RSDL-409 | a requires resolves to a redundant provider set — an offering component with more than one instance | warning (not yet realizable) | §7 |
| RSDL-502 | two closure components offer one service | error | §8 |
| RSDL-504 | a member line names a service by its name while a declared component offers it — names the offerer | error | §6 |
| RSDL-601 | more than one system in the workspace | error | §3.1 |
| RSDL-602 | a system member line names nothing that is a component or a service | error | §3.1 |
| RSDL-603 | a name listed twice in one system body | error | §3.1 |
| RSDL-604 | a declaration of another profile (a type, an interface, a service) in an .rsdl file | error | §2 |
| RSDL-701 | an instance of a closure component with no placement in a deployment | error | §9 |
| RSDL-702 | a placement line names a component, instance or service outside the closure, or a name that resolves to nothing | error | §9 |
| RSDL-704 | deployment … for Y where Y resolves to no declared system | error | §3.4 |
| RSDL-705 | two machines with one name in one deployment | error | §3.5 |
| RSDL-706 | an instance placed twice in one deployment — also Cruise together with Cruise.primary | error | §9 |
| RSDL-707 | an external machine lists an implemented component | error | §9 |
| RSDL-708 | two deployments with one name in the workspace | error | §3.4 |
| RSDL-709 | a depth, slots or budget value that the parser reads is not an integer within its range, or the key is written bare | error | §5 |
| RSDL-804 | a backend key whose namespace no configured backend claims | warning | §5 |
| RSDL-805 | a declared depth is below ceil(max / min) for an event a covered link consumes | warning | §5 |
| RSDL-806 | an event whose contract bound is not derivable is consumed by a link with no declared depth | warning | §5 |
| RSDL-807 | a link of the selected deployment carries an encoding the emitted backend's encodings list does not name | warning | §13 |
| RSDL-901 | a PLATFORM distribution holds a component whose requires resolves into an APPLICATION distribution (tier inversion); a distribution without tier is exempt | error | §3.3 |
| RSDL-903 | a distribution member line names something outside the closure, or nothing | error | §3.3 |
| RSDL-904 | an implemented closure component in no distribution, while the workspace declares at least one | error | §3.3 |
| RSDL-905 | a component listed by two distributions | error | §3.3 |
| RSDL-906 | a name listed twice in one distribution body | error | §3.3 |
| RSDL-907 | an external component listed by a distribution | error | §3.3 |
| RSDL-908 | tier value other than PLATFORM or APPLICATION | error | §5 |
16.2 Reserved
| Code | Reserved for | Reopens with |
|---|---|---|
| RSDL-801 | derived transport cannot meet a link's contract timing (feasibility) | posture, §12 |
| RSDL-803 | a command/query member forced into the static posture (a bus carries no call) | posture, §12 |
16.3 Retired
Each code below was in force in v0.1 and is retired with the construct it guarded. The numbers are never reused.
| Code | v0.1 rule, in brief | Reason retired |
|---|---|---|
| RSDL-301 | applied name is neither a model nor a component | application notation dropped; a component applies nothing |
| RSDL-302 | provided output flow with no defining equation | no flows or equations in a component body |
| RSDL-303 | provided service not fully covered by equations | a component offers a service whole; coverage is the implementation's |
| RSDL-304 | applied model signature mismatches the wired signals | no wiring; a model is rmdl's and is not applied here |
| RSDL-401 | conditional or dynamic instantiation | instances are a static attribute (§7); nothing is instantiated |
| RSDL-402 | wire connects flows of differing type | no wires; a link is derived from requires (§8) |
| RSDL-404 | provided output defined more than once | no outputs defined in source |
| RSDL-405 | kind-crossing wire other than event to command | no wiring; event-to-command needs a model to emit |
| RSDL-407 | instantaneous cycle among models within one leaf | no models composed in a component |
| RSDL-501 | consumer source distinguishes static wire from discovery | no posture exists to distinguish (§12) |
| RSDL-503 | published service name collides | the name is ridl's; RIDL-140 alone reports it |
| RSDL-703 | placement onto a target whose capability class cannot host | targets and capability classes replaced by named machines (§3.5) |
| RSDL-802 | no transport-policy entry for a (locality × kind) | transport is configuration, not a table in the grammar (D-5) |
| RSDL-902 | bundle includes an instance placed on a different target | a distribution spans machines; installation is derived (§13) |
| RSDL-950 | reserved resilience keyword used | redundant, supervise, degraded retired; redundancy derived (§7) |
17. Open Questions
Each open question of v0.1.0 §13, with its disposition, then the questions this version adds.
- Composition versus deployment boundary — answered.
systemandcomponentstate what exists and how it relates;deploymentandmachinestate where it runs. The time base that v0.1.0 declared in a deployment is retired. - Dynamic topology and orchestration — open. This version describes a static set of instances with static placement. Starting, stopping and moving workloads at runtime needs a dynamic layer that is not designed.
- Transport and posture policy — moved. Transport is the implementer's configuration (§10); posture is reserved (§12).
- Service discovery semantics — narrowed. More than one provider is now a derived redundant provider set (§7), whose arbitration waits for the runtime (§12). Versioned services and consumer preference remain open.
- End-to-end timing composition — deferred with timing feasibility and RSDL-801 (§12).
- Distribution dependency and versioning — narrowed. Dependencies between distributions are derived (§3.3). Version ranges and coordinated updates of several distributions remain open.
- Resilience realization — open. Redundancy is derived (§7); failover,
voting and supervision wait for the failure-management specification (ridl
§10.4). The v0.1.0 words
redundant,superviseanddegradedare retired. - Global service catalog scoping — unchanged. Service names stay one flat global namespace (RIDL-140).
- Service-number allocation — answered. The routing key holds a catalog, an interface and a member, and no service (§13). A tag-based transport that needs a service number reads it from its own backend key (§5).
- One owning service per interface across a workspace — open. rsdl checks that no two services in the closure list one interface (§8). Whether ridl should reject it across a whole workspace is a question for the ridl finalization.
- A member kind without a declaration — open. A distribution lists bare references whose kind comes from their declarations (§3.3). If a member kind ever has to exist without a declaration, such as an asset named by a file path, the member line needs a kind keyword.
- What a breaking deployment change is — open.
ridl difflists a deployment change under its own heading with no verdict (§14); the verdicts are decided with the IR stability policy.
Appendix A — Full Example
A small vehicle in one unit, veh (ADR-0002 §1): a ridl.toml whose
[package] name is veh and which holds no source, and four source packages in
its tree. Two ridl packages carry the contracts, one rsdl package carries the
closure, two distributions and two deployments. Every rule of §3 to §13 holds
over these files.
Contracts — veh/common/common.typl and veh/adas/adas.ridl, shown
briefly:
package veh.common
type Speed: km/h [0.0..250.0 step 0.5]
type Engaged: boolean
enum LeverCmd { SET = 1, CANCEL = 2, RESUME = 3 }
struct FaultReport { count: integer [0..255] }
package veh.adas
import veh.common.Engaged
import veh.common.Speed
import veh.common.LeverCmd
interface CruiseControl {
signal engaged: Engaged @[100ms..1s]
signal target: Speed @[100ms..1s]
command setLever(cmd: LeverCmd) @[..50ms]
}
interface LaneAssist {
signal active: Engaged @[100ms..1s]
}
service veh.adas.cruise : CruiseControl
service veh.adas.lane : LaneAssist
veh/diag/diag.ridl declares one inline-shape service; its dotted name is the
name of its one interface (§3.2):
package veh.diag
import veh.common.FaultReport
service veh.diag.access {
query readFaults(): FaultReport @[..100ms]
}
The closure and the distributions — veh/topology/system.rsdl:
package veh.topology
import veh.adas.CruiseControl
import veh.adas.LaneAssist
/// Adaptive cruise, two copies: one per compute node.
component Cruise [ instances = (primary, backup), labels = (ASIL_B) ] {
offers veh.adas.cruise
requires LaneAssist
}
component Lane { offers veh.adas.lane }
component Panel {
requires CruiseControl
requires LaneAssist
}
/// The fleet backend. No implementation in this workspace.
component Backend [ external ] {
requires CruiseControl
requires veh.diag.access
}
system Vehicle { Cruise, Lane, Panel, Backend, veh.diag.access }
distribution Adas [ tier = PLATFORM ] { Cruise, Lane, veh.diag.access }
distribution Hmi [ tier = APPLICATION ] { Panel }
Production — veh/topology/production.rsdl:
package veh.topology
deployment Production for Vehicle {
machine AdasHpc [ labels = (ASIL_B) ] { Cruise.primary, Lane, veh.diag.access }
machine Cockpit { Cruise.backup, Panel [ linux.cpuset = (2, 3) ] }
machine Cloud [ external ] { Backend }
}
Bench — veh/topology/bench.rsdl, one machine, the backend stubbed on it:
package veh.topology
deployment Bench for Vehicle {
machine DevBox { Cruise, Lane, Panel, veh.diag.access, Backend }
}
What the lowering derives, and what it reports:
- Closure:
Cruise,Lane,Panel,Backend, and the implicit componentveh.diag.access(§6). Services in the closure:veh.adas.cruise(offered byCruise),veh.adas.lane(Lane),veh.diag.access(its implicit component). Interfaces:CruiseControl,LaneAssist, andveh.diag.accessas its own interface. Each has one owning service and one offering component (§8). - Instances:
Cruise.primary,Cruise.backup,Lane.Unit,Panel.Unit,Backend.Unit,veh.diag.access.Unit(§7). - Links, Production (§8, §10):
Cruise.primary → Lane.Unitsame machine;Cruise.backup → Lane.Unitdifferent machine;Panel.Unit → Cruise.primarydifferent machine;Panel.Unit → Cruise.backupsame machine;Panel.Unit → Lane.Unitdifferent machine;Backend.Unit → Cruise.primary,Backend.Unit → Cruise.backupandBackend.Unit → veh.diag.access.Unitoff-board. The threeBackendlinks are the surface set (§13): the external side consumes. - Links, Bench: every link is same machine; the
Backendlinks stay in the surface set, because the flag and not the machine defines the boundary. - Warnings: RSDL-409 twice, for
Panel's andBackend'srequires CruiseControl—Cruiseis a redundant provider set. RSDL-804 forlinux.cpusetwhen nolinuxbackend is configured; the key is carried onPanel's placement line inCockpiteither way. - Regions (§13): one,
veh, because the three interfaces belong to one unit. Its interfaces areadas.CruiseControlandadas.LaneAssist, named relative to the unit, and the inline shapeveh.diag.access, which keeps its full dotted name. - Grants (§11):
Cruisereaches thevehregion (forLaneAssist);Panelreachesveh;Backendreachesveh, lowered with theexternalflag.Lanerequires nothing and holds no consumer grant; its write side is read from the producers fact. - Distributions (§3.3, §13):
Hmidepends onAdas(Panelrequires interfacesCruiseandLaneoffer);Adasdepends on nothing. No tier inversion.Backend, external, is in no distribution. InProduction,Adasis installed onAdasHpcandCockpit,HmionCockpit; inBench, both onDevBox.
Two edits that would each draw one error: listing veh.adas.cruise in Vehicle
(RSDL-504, Cruise offers it); writing Cruise in place of Cruise.primary on
AdasHpc (RSDL-706, Cruise.backup is reached twice).
Appendix B — Formal Grammar (EBNF)
The rsdl profile grammar — the restriction of the family grammar accepted in
.rsdl files. package, import, doc_comment, sep, qualified_id, id,
CamelCase_id, camelCase_id, SCREAMING_SNAKE_ID and literal are typl
Appendix E's; attr_block is the family's single production (general form
§4.2), restated here with the two value positions instances needs. A
declaration of any other profile is RSDL-604.
file = package { import } { declaration } ;
declaration = component_def | system_def | distribution_def | deployment_def ;
(* no modifier — §2; machine_def occurs only inside a deployment *)
(* ---------- The five declarations — Shape 3 ---------- *)
component_def = doc_comment? "component" CamelCase_id attr_block?
"{" { component_line sep? } "}" ;
component_line = doc_comment? ( "offers" reference | "requires" reference ) attr_block? ;
(* one reference per line — §3.2; `offers` a service,
`requires` an interface or an inline-shape service *)
system_def = doc_comment? "system" CamelCase_id attr_block?
"{" { member_line sep? } "}" ;
distribution_def = doc_comment? "distribution" CamelCase_id attr_block?
"{" { member_line sep? } "}" ;
member_line = doc_comment? reference attr_block? ;
(* a component, or a lone service — §4, §6 *)
deployment_def = doc_comment? "deployment" CamelCase_id "for" reference attr_block?
"{" { machine_def sep? } "}" ;
(* `for` names the system — §3.4; R5: clause, then attributes *)
machine_def = doc_comment? "machine" CamelCase_id attr_block?
"{" { placement_line sep? } "}" ;
placement_line = doc_comment? reference attr_block? ;
(* a component, one instance, or a lone service — §9 *)
(* ---------- References — one production, the role read from case ---------- *)
reference = qualified_id ;
(* Name · Name.inst · pkg.Name · pkg.Name.inst · pkg.service — §4.
A CamelCase segment names a component (the system, after `for`);
a camelCase segment after it names an instance; lowercase
segments only name a service. Lookup confirms the role. *)
(* ---------- Attribute block — general form §4.2, flag and assignment forms ---------- *)
attr_block = "[" { attribute sep? } "]" ;
attribute = key | key "=" attr_value ;
key = camelCase_id (* an rsdl-owned key — §5 *)
| camelCase_id "." camelCase_id ; (* backend.key — §5 *)
attr_value = literal | SCREAMING_SNAKE_ID | id
| "(" [ list_item { "," list_item } [ "," ] ] ")" ;
list_item = literal | SCREAMING_SNAKE_ID | id ;
(* `id` admits instance names in `instances = (…)` — §7 — and lets
`instances = solo` and a declared `Unit` parse, so that RSDL-305
and RSDL-307 report them rather than a parse error.
general form §4.2's const_value admits literals and constant
references only; rsdl widens it in these two positions, the
value and the list item *)
Appendix C — Glossary
Family terms are defined in the typl, ridl and rmdl glossaries and mean the same here. rsdl-specific:
| Term | Definition |
|---|---|
| attribute map | the namespaced backend keys of one node — a declaration or a line — carried into the lowered facts uninterpreted (§5, §13) |
| backend key | an attribute key namespaced by a backend name, backend.key, that the compiler carries and the backend validates (§5) |
| closure | the components a system lists, including the implicit components of listed services; every completeness check quantifies over it (§3.1) |
| component | one execution context; offers services, requires interfaces, may be external, has instances (§3.2) |
| crossing kind | where a link runs: same machine, different machine, or off-board (§10) |
| deployment | one placement of the system: a named set of machines, for the system (§3.4) |
| distribution | what is versioned, signed and shipped together; its installation and dependencies are derived (§3.3) |
| external | a flag: on a component, no implementation exists in this system; on a machine, it lies outside the system's boundary (§3.2, §3.5) |
| grant | the set of catalog regions a component's requirements reach, resolved through each interface's owning service (§11) |
| implicit component | the single-service component a lone service stands for, named by the service's name (§6) |
| instance | one named copy of a component; instances share the component's offers and requires and are indistinguishable on the wire (§7) |
| link | one consumer instance reaching one producer instance through a requires, with its crossing kind (§8, §10) |
| machine | what software is installed on; lists the instances it hosts (§3.5) |
| member line | a bare reference in a system, distribution or machine body; a reference, not a declaration (§4) |
| offers / requires | a component's two body lines: it offers a service and requires an interface (§3.2, §4) |
| placement | membership of an instance in a machine body; every instance of the closure is in exactly one machine per deployment (§9) |
| posture | how a service is realized on the wire, static or discovered; reserved, not derived in this release (§12) |
| redundant provider set | the instances of an offering component that has more than one; derived, and reported as not yet realizable (§7) |
| surface set | per deployment, the links with an external component at exactly one end: the system's external boundary (§13) |
| system | the closure: the components a deployment has to account for, and nothing else (§3.1) |
| tier | an optional attribute of a distribution, PLATFORM or APPLICATION, checked for inversion (§3.3) |
| unit instance | the one instance of a component without instances, named Unit in the IR and in diagnostics and never written in source (§7) |
End of rsdl Language Reference v0.2.0 — Draft.
Partly built. The guaranteed subset (V1) ships with the interface layer:
requireandensureclauses on ridl interactions are accepted and checked by the compiler in this repository. The function layer that extends it (V2) is specified and not built, as are the rmdl positions this grammar also serves. See what is built.
expr-core Specification
The contract-term language of the RIDL family — one expression grammar,
written once, surfacing in require/ensure contracts (ridl §13), in the
availability and obligation positions of ridl's boundary model, and as the
expression layer of rmdl functions and models (rmdl §4, which shares this
grammar verbatim). One grammar, two layers: the guaranteed subset shipped
with the interface layer (V1) and the function layer that extends it (V2).
Version: 0.1.0 — Draft
Provenance. This document closes the "expr core specification — not started" row of the family overview §2 inventory and the pending grammar reference in ridl §13 and ridl Appendix C. It is written under ADR-0008 decision 10: the checker implements only the guaranteed subset and rejects every other form with RIDL-306; this document fixes the full contract-term grammar that subset is verified against. The V1 sections are normative as implemented. The V2 sections are forward-looking by design — this is the one document whose purpose is to fix the shape of a layer that lands later, the same posture as the rmdl and rsdl references.
Table of Contents
- Scope and Position in the Family
- The Layer Model
- Grammar
- The Rejection List
- Typing Rules — the Guaranteed Subset
- The Reference Environment
- Evaluation Domains
- The RIDL-306 Boundary
- Worked Examples — the ridl Section 13 Contracts
- Alternatives Considered
1. Scope and Position in the Family
expr is the family's single expression language. It is not a sixth profile: it
is a shared core that the profiles surface in their own positions:
- ridl —
require/ensurepredicate attributes oncommandandquery(ridl §13; carrier productionattribute = "require" expr | "ensure" expr, ridl Appendix C; the three-form attribute model is the family general form §4.2) - ridl boundary model — availability predicates and the correspondence obligations (ADR-0012)
- rmdl — the expression layer of functions and models (rmdl §4 states: "The
expression grammar below is shared verbatim with the
exprcore") - typl — future
invariantpredicates on structs, and the const-evaluable subset in constraint bounds (typl §17.7)
One expression, multi-executable (family overview doctrine 14): a contract
written once in expr runs statically where decidable, as a CI property test,
as an online observer on live flows, and as a synchronous observer in the rmdl
reference oracle (ridl §13, concept note §9.2). Everything in this document
serves that multiplicity: the grammar is small, total, side-effect-free, and
exactly evaluable, so the same term means the same thing in every execution.
This document owns the grammar, the typing rules, the reference environment, and the evaluation domains. The carrier positions — which declarations may carry which predicate, and the diagnostics for misplacement (RIDL-301/302) — belong to the profile references and are not restated here.
2. The Layer Model
The grammar has two layers, released in sequence:
| Layer | Release | Status | Content |
|---|---|---|---|
| Guaranteed subset | V1 | Built | comparison, boolean connectives, arithmetic, enum access, tuple-field access, duration comparison — the forms ridl §13 names as guaranteed-supported |
| Function layer | V2 | Not built | let bindings, if/case/match expressions, total function definitions and calls, bounded combinators (all, any, count) over typl bounded collections |
Layer rule (normative). The guaranteed subset is a strict subset of the function layer: every expression legal in V1 is legal in V2, parses to the same tree, types to the same type under the same environment, and evaluates to the same value. The function layer extends the grammar; it never changes the meaning of a subset term. This is ADR-0008 decision 10's forward-compatibility requirement — the subset is never a throwaway.
The asymmetry between the layers is deliberate:
- The V1 productions, typing rules, and evaluation domains in this document are frozen — the checker (RIDL-306 boundary included) and the evaluator implement them, and a change here is a contract change.
- The V2 productions are forward-looking: the function layer may refine them (and adds the totality checks, RMDL-1xx, that only make sense with function definitions), but it must preserve the layer rule above. The rmdl reference §3–§4 is the semantic elaboration of the V2 layer; where this document and a landed function-layer implementation would diverge, the divergence is resolved by editing this document in the open, never silently.
3. Grammar
3.1 The guaranteed subset — V1 (normative as implemented)
The subset productions, exactly as the parser implements them (precedence-climbing; one production per precedence level):
expr = or_expr ;
or_expr = and_expr { "||" and_expr } ;
and_expr = cmp_expr { "&&" cmp_expr } ;
cmp_expr = add_expr [ ( "==" | "!=" | "<" | "<=" | ">" | ">=" )
add_expr ] ;
add_expr = mul_expr { ( "+" | "-" ) mul_expr } ;
mul_expr = unary_expr { ( "*" | "/" | "%" ) unary_expr } ;
unary_expr = [ "!" | "-" ] postfix_expr ;
postfix_expr= primary { "." ( camelCase_id | SCREAMING_SNAKE_ID ) } ;
primary = literal | duration_lit | path_head | "(" expr ")" ;
path_head = camelCase_id | CamelCase_id | SCREAMING_SNAKE_ID ;
literal is typl's (int_lit | float_lit | string_lit | bool_lit, typl
Appendix E); duration_lit is ridl's duration literal (int_lit plus a
us/ms/s suffix, ridl §2.1).
Structural rules fixed by these productions:
- Precedence, loosest to tightest:
||—&&— comparison — additive (+-) — multiplicative (*/%) — unary (!-) — member access (.). Soa || b && cparses asa || (b && c),!a && bas(!a) && b, anda + b * c < das(a + (b * c)) < d. - Comparison does not chain:
cmp_expradmits at most one comparison operator, soa < b < cis a parse error. Writea < b && b < c. - Member access admits member names only: after
.the grammar accepts acamelCase_id(tuple field) or aSCREAMING_SNAKE_ID(enum member), never aCamelCase_id. Qualified package paths (pkg.Name.MEMBER) are therefore not expressible — references resolve through the file's imports (ADR-0002), the same way type references do. <is always comparison inside an expression. The stream-type<T>reading of<exists only in parameter-type and return-type position; the two positions never overlap.- Zero durations are legal in expression position.
require window > 0ms(ridl §13) depends on0msas a comparison operand. ridl §2.1 currently defines the duration literal as "a positive integer followed by a time-unit suffix", scopes the token to timing annotations and time-typed contract expressions, and states "Zero duration is not permitted (RIDL-102)" with no scoping of that sentence. This document scopes it: the zero prohibition applies to timing annotations only — RIDL-102 fires in timing-annotation resolution (ridl §9, §16.1) — while the token itself (int_litplus suffix, ridl Appendix C) admits zero in expression position. The ridl §2.1 sentence needs the matching scope-narrowing edit; that is deferred to the ridl-reference doc-sync (ADR-0008 decision 1), not made here.
3.2 The function layer — V2 (forward-looking)
The V2 layer only adds alternatives to the subset productions — no V1
production changes shape, which is what makes the layer rule (§2) hold by
construction. Every production below is marked V2 and is illegal until the
function layer lands (§8):
(* V2 *) expr = or_expr | if_expr | case_expr | block_expr ;
(* V2 *) if_expr = "if" expr "then" expr "else" expr ;
(* V2 *) case_expr = "case" expr "{" case_arm { case_arm } "}" ;
(* V2 *) case_arm = case_pattern "->" expr ;
(* V2 *) case_pattern = SCREAMING_SNAKE_ID (* enum member *)
| camelCase_id camelCase_id (* union arm bind *)
| "some" camelCase_id | "none" (* optionals *)
| "else" ; (* catch-all *)
(* V2 *) block_expr = "{" { let_binding } expr "}" ;
(* V2 *) let_binding = "let" camelCase_id "=" expr ;
(* V2 *) cmp_expr = add_expr [ cmp_op add_expr
| "match" string_lit ] ;
(* V2 *) cmp_op = "==" | "!=" | "<" | "<=" | ">" | ">=" ;
(* V2 *) primary = literal | duration_lit | path_head
| "(" expr ")"
| call_expr | combinator_call ;
(* V2 *) call_expr = camelCase_id "(" [ expr { "," expr } ] ")" ;
(* V2 *) combinator_call = ( "all" | "any" | "count" )
"(" expr [ "," fn_value ] ")" ;
(* V2 *) fn_value = camelCase_id
| "function" "(" param_names ")" "=" expr ;
(* V2 *) param_names = camelCase_id { "," camelCase_id } ;
(* V2 *) function_def = "function" camelCase_id
"(" [ fn_param { "," fn_param } ] ")"
":" type_ref ( "=" expr | block_expr ) ;
(* V2 *) fn_param = camelCase_id ":" type_ref ;
Reading notes on the V2 layer (semantics owned by the rmdl reference):
ifis an expression andelseis mandatory — totality (rmdl §4.2, RMDL-107).casedispatches on enums, unions, and optionals, and must be exhaustive, withelseas the explicit catch-all (rmdl §4.3, RMDL-108).matchin expression position is the pattern-conformance predicate (value match "pattern"— boolean), the expression form of typl'smatchconstraint (typl §17.7c). It is not value dispatch — that iscase.letbinds immutable locals; no reassignment, no shadowing (rmdl §3.1, RMDL-104).- A
function_defdeclares a named, pure, total computation; it surfaces at rmdl package level (rmdl §3.1), never inside a contract predicate. A contract predicate may call declared total functions (call_expr) — and nothing else (§4). - The bounded combinators named here (
all,any,count) are the contract-position set. The full function-layer set —map,fold,any,all,countover typl bounded collections — is rmdl §4.5; anonymousfunction(…) = exprvalues are legal only as combinator arguments (RMDL-109). Every bound is a typl bound, so every iteration count is statically known. - rmdl §4.4's optional forms (
?:and thesome/nonepatterns above) arrive with the same layer; the?:production is fixed by the function layer together with the optional typing rules. - Keyword ownership stays with the profile references:
require/ensureare registry words activated by ridl (ridl §2.3); the V2 words (function,let,if,then,else,case,match, …) enter through rmdl §2 and the typl §1.4 registry.
4. The Rejection List
The following are excluded from expr at every layer — they are not pending
features, and the function layer does not lift them. This list is normative and
permanent:
- Recursion, direct or mutual (RMDL-101 once the function layer lands). Total functions cannot be self-referential; per-call WCET stays decidable.
- Loops. No loop statement exists at any layer. Iteration exists only as bounded combinators over typl bounded collections (rmdl §3.2, §4.5).
lastinside functions — and with itinit,now, anddt(RMDL-102/103). These are model-layer constructs;exprterms are timeless and stateless. A computation that needs elapsed time takes it as an ordinary parameter.- Side effects. No assignment, no mutation, no emission, no I/O in expression position. Side effects are emissions and belong to models (family overview doctrine 13, rmdl §5.7).
- Calls to anything except (V2) declared total functions. No host functions, no runtime intrinsics, no FFI. The callable set is closed by declaration, so every execution surface — checker, property runner, observer, oracle — can evaluate any contract without a sandbox.
5. Typing Rules — the Guaranteed Subset
These rules are normative for V1; the checker implements them. Every violation in the guaranteed subset surfaces as RIDL-306 (§8) with a message naming the offending form.
5.1 Type domains
A subset expression types into one of five domains:
| Domain | Inhabited by |
|---|---|
| boolean | bool_lit, comparison results, boolean connectives |
| numeric | a value of one named numeric type (Speed), a bare integer/float value, or a bare numeric literal |
| duration | duration_lit (500us, 10ms, 1s) and values of the standard Duration type |
| enum | an enum-typed value; Enum.MEMBER access |
| tuple | a tuple-typed value with named fields (a tuple-returning query's result) |
A reference typed outside these five domains is outside the subset. In
particular, field access on a struct-typed reference (filter.severity) is
grammatical (§3.1) but is not tuple-field access — it is RIDL-306 today.
5.2 Nominal typing — no implicit cross-type anything
Typing is nominal per typl §5.7. Two named types never unify, even with
identical constraints: Speed + Torque, speed == torque, and every other
cross-named-type form is a type error. A bare numeric literal unifies with any
numeric operand — this is the same rule that makes
const MAX_SPEED : Speed = 250.0 legal — and a bare integer/float typed
value unifies with literals and with values of the same bare primitive, never
with a named type. The subset checker unifies literals nominally; it does not
evaluate range conformance of literal operands (the test plane exercises
values).
5.3 Operator rules
| Operator | Operands | Result |
|---|---|---|
== != | two operands unifying in one domain: boolean, numeric of one type, duration, or one enum type | boolean |
< <= > >= | two operands unifying in one ordered domain: numeric of one type, or duration | boolean |
&& || | boolean, boolean | boolean |
! | boolean | boolean |
+ - * / | numeric operands, nominally consistent (one named type; literals unify) | the operand type |
% | as arithmetic, and integer-backed operands only | the operand type |
unary - | numeric | the operand type |
Enum.MEMBER | postfix SCREAMING_SNAKE_ID member on an enum type name in scope | that enum type |
value.field | postfix camelCase_id member on a tuple-typed value, naming one of its fields | the field's type |
Rules the table implies, stated explicitly:
- The root of a
require/ensureexpression must be boolean. A non-boolean root (require 3) is an error. - Duration supports comparison only. ridl §13 guarantees duration
comparison; duration arithmetic (
window + 10ms) is outside the subset and arrives with the function layer's unit discipline (rmdl §3.3). - Enums support equality only. Ordering enum members
(
gear < GearPosition.DRIVE) would depend on member numbering — an evolution hazard — and is not planned at any layer. - Arithmetic result type: when any operand is a named type, every non-literal operand must be of that one named type and the result is that type; when all operands are literals, the result is a bare numeric literal value (which continues to unify).
- Scalar multiplication across types (
Speed * float-typed value, rmdl §3.3) is not in the subset — today every non-literal arithmetic operand must be of one type. The function layer lifts exactly the rmdl §3.3 cases. - String and bytes operands have no operator in the subset.
string_litparses (it is a typl literal), but any operator over strings or bytes is outside the subset; the V2matchpredicate is the planned string form.
6. The Reference Environment
A contract expression resolves names against a fixed environment — and nothing
else. There is no ambient scope, no global mutable state, no platform
introspection. For the ridl require/ensure position (the implemented
carrier), the environment is, in resolution order:
- Parameters of the enclosing
command/query(camelCase_id). result— the query's return value; ensure only. In arequire,resultdoes not exist.- The enclosing interface's own signals (
camelCase_id); require only. Arequirereads the provider's latest published value (ridl §4.4's last-value guarantee makes this well-defined). The ridl §13 table scopesensuretoresultand parameters, so a signal read in anensureis an error. - Constants — package-local and imported
SCREAMING_SNAKE_IDconstants (ADR-0002 import rules). - Enum types (
CamelCase_id), forEnum.MEMBERaccess.
Any other name is an unresolved reference (RIDL-306 today). Other carrier positions (ridl's availability and obligation attributes; rmdl function and model contracts, rmdl §9.2) define their own environments in their references, under the same closed-environment principle.
7. Evaluation Domains
Evaluation semantics are normative for V1: the property runner and later the rmdl reference oracle must agree bit-for-bit, so the domains are exact — no IEEE-754 arithmetic anywhere in the contract plane.
- Numeric values are exact rationals. Literals, constants, and sampled
values evaluate as exact rational numbers (the typl exactness discipline:
ranges, steps, and constants are exact decimals — typl §4–§5 — carried by the
toolchain's exact-scalar representation).
0.1 + 0.2 == 0.3holds. Wire-width and float-representation concerns (typl §4.3) are boundary concerns; they do not leak into contract evaluation. - Durations are exact microsecond counts.
1s=1000ms=1000000us; duration comparison compares the counts. This matches platform time (int64microseconds, ridl §3.1) and the IR's exact-decimal microsecond timing rule (ADR-0008 decision 12). - Enum values evaluate as the member's declared value within its enum type; equality is member equality.
- Booleans:
&&and||evaluate left-to-right and short-circuit. With the division fault below, short-circuiting is observable (x != 0.0 && 1.0 / x > 2.0never faults), so it is normative. - Integer division and remainder over integer-backed operands follow C17
(rmdl §4.1):
/truncates toward zero,%takes the dividend's sign. Division over float-backed operands is exact rational division. - Totality. Evaluation of a well-typed subset expression terminates on every
environment — there is nothing in the grammar that can diverge. The single
evaluation fault is division by zero (
/or%with a zero divisor), which surfaces as a defined fault (the rmdl §8 step-fault lineage: guarding divisors is the author's obligation), never as undefined behavior. RMDL-105's provably-nonzero-divisor check (rmdl §3.2) applies tofunctiondefinitions only; it never applies to contract-position subset terms, whose division-by-zero semantics is this defined fault — so a V1-legal division stays V2-legal, as the layer rule (§2) requires.
8. The RIDL-306 Boundary
Today, any expression form outside the guaranteed subset is RIDL-306 (error) — one code for the whole boundary, with a message naming the offending form. That includes, non-exhaustively:
| Form | Example |
|---|---|
| any V2 production (§3.2) | if, let, case, calls, all(…) |
| unresolved reference / name outside the environment (§6) | require unknownName > 0 |
result in a require; a signal read in an ensure | ensure currentSpeed >= 0.0 |
| cross-named-type arithmetic or comparison (§5.2) | require speed + window > 0 |
| duration arithmetic (§5.3) | require window + 10ms < 1s |
| struct-field access — struct-typed base, not a tuple (§5.1) | require filter.severity >= 4 |
% over non-integer-backed operands (§5.3) | require speed % 0.5 == 0.0 |
| non-boolean root (§5.3) | require 3 |
| string/bytes operands; enum ordering (§5.3) | require name == "x" |
The boundary is lifted per-form as the function layer lands: when a V2 form
is implemented, it leaves RIDL-306's scope; forms that remain illegal in the
function layer move to their profile-assigned codes (RMDL-1xx, rmdl §11.1). The
§4 rejection list is never lifted. Related but distinct: RIDL-305 (warning)
flags an ensure that never references result — a well-typed but suspicious
contract, not a boundary violation.
9. Worked Examples — the ridl Section 13 Contracts
Every guaranteed-subset example in the ridl reference, type-checked under §5 and §6. The declarations are ridl §13/§14.0's:
command setRange(min: Speed, max: Speed)query getAverageSpeed(window: Duration): Speedcommand setGear(position: GearPosition), inside an interface that declaressignal currentSpeed : Speedconst MAX_SPEED : Speed(typl §5.7)
| Contract | Walk | Root |
|---|---|---|
require min < max | min, max — parameters, numeric Speed; ordering over one named type | boolean |
require max <= MAX_SPEED | max — parameter Speed; MAX_SPEED — constant Speed (env item 4); ordering over one named type | boolean |
require window > 0ms | window — parameter Duration; 0ms — duration literal (legal at zero in expression position, §3.1); duration ordering | boolean |
ensure result >= 0.0 | result — Speed (ensure position, env item 2); 0.0 — literal, unifies with Speed; ordering. References result, so no RIDL-305 | boolean |
require position != GearPosition.PARK || currentSpeed == 0.0 | position — parameter, enum GearPosition; GearPosition.PARK — enum access, same enum; equality. currentSpeed — own signal (require position, env item 3), Speed; 0.0 unifies; equality. || over boolean | boolean |
And the tuple-field form (the sixth guaranteed form, ridl §13; example shape
from the toolchain plan): given query getRange(): (min: Speed, max: Speed),
the contract ensure result.min >= 0.0 types as — result tuple (env item 2);
.min field access → Speed; literal unifies; ordering → boolean.
All six are well-typed under the subset rules with boolean roots.
10. Alternatives Considered
Recorded per the working-memory doctrine, so the rationale survives the session that produced it:
- Quoted / string predicates (
require "min < max"— assertions as annotation text, the proto-custom-option / doc-comment-assertion style). Rejected: an unchecked string cannot be type-checked, canonicalized, or diffed; it fails the general-form deletion test (an attribute must have a machine consumer, and a machine cannot consume what it cannot parse); and it silently becomes outdated as the interface evolves. The whole value ofrequire/ensureis that the compiler owns them. - The full function-layer grammar now (implement functions, conditionals, and combinators in the interface layer). Rejected: sequencing (ADR-0004, ADR-0008 decision 10). The function layer is L-sized, needs the totality checks (RMDL-1xx) that only make sense beside rmdl, and has no interface-layer consumer — the interface layer needs predicates, not programs. The interface layer ships the subset that the contract positions need; the function layer extends it in place.
- An external assertion language (OCL, JML-style clauses, CEL, or any embedded third-party expression language). Rejected: the one-grammar doctrine — one platform, four languages, one grammar, one IR. A second expression language would double the lexicon for non-programmer audiences, break the multi-execution story (the same term must run in the rmdl oracle, which speaks this grammar natively), and pull a foreign toolchain inside the ISO 26262 tool-qualification boundary.
Specified, and built in part. The frame below is what a runtime and a transport binding are written from. Its WebSocket binding (§11.1) is built:
ridl-transport-wsspeaks this frame over WebSocket as the providing side of a session, and the As-built note below says which rules it applies. On Android a runtime binds the ports over its own binder contract, outside this repository (§11.2, which reverses an earlier AIDL binding). The one runtime in this repository,ridl-loopback, runs in process and speaks no frame, and neither it nor the transport enforces a timing rule or a contract clause on the provider side, so every statement below about timing enforcement and provider-side enforcement describes the specification and not a shipped behaviour. See what is built.
Frame Specification
The logical frame of the ridl runtime — what crosses the boundary between a
provider's runtime and a consumer's runtime, for each of the five interaction
kinds, stated once and independently of any transport. A binding carries
this frame over one transport; a runtime presents what crosses through the
ports of ridl-rt. This document is what both are written from.
Version: 0.2.0
Provenance. This document is the frame specification (driftsys/ridl#257). It writes down what ADR-0018 decision 7 decided — one frame, several bindings — with the vocabulary ADR-0021 fixed for
ridl-rt0.1, over the interaction semantics of the ridl language reference and the RPC bounds of ADR-0015. Its exit criterion is that a second implementation — a runtime, or a binding — can be written from it alone, without reading the code in this repository. Where this document and an ADR disagree, the ADR wins and this document is corrected.
As built. Two packages in this workspace speak this frame over WebSocket (§11.1): the crate
ridl-transport-ws, the serving side, and the TypeScript package@driftsys/ridl-transport-ws(packages/ridl-transport-ws/), the consuming side (the crate's design record, the package's design record). The crate serves the observe and call ports of a provider runtime. With a member table of its catalog, which the embedder supplies, it accepts anattachnaming a listed earlier hash (§6.1); without one it applies the exact-match rule. With a member table it keeps each settled response until its bound has passed (§7); without one, or for a member with no bound, it keeps the last 64. With a member table that gives a member a payload limit, it also answers arequestwhose payload is longer than that limitcorrupt(§11.1). The runtime here,ridl-loopback, runs in process: it carries bytes from a provider handle to a consumer handle over one in-memory store, opens no socket, and has no wire format (its design record). Neither the crate norridl-loopbackenforces a timing rule or a contract clause on the provider side, so every sentence below about those describes the specification, not behaviour a shipped runtime performs. The first binding is the WebSocket transport (driftsys/ridl#265); the second is a runtime's own binder contract on Android, outside this repository (§11.2, which reverses an earlier AIDL binding generated per interface).The catalog descriptor carries the list of compatible earlier catalog hashes that §6.1 names.
ridl-transport-wsapplies the list when its embedder gives it a member table that holds it, and meets conformance item 1 of §12 then; without a table it applies only the exact-match rule.ridl-loopbackreads no descriptor.
Table of Contents
- Scope and Position in the Family
- Vocabulary
- The Session
- The Frame
- What Crosses per Interaction Kind
- The Control Plane
- Sequence Numbers, Loss and Duplicates
- Timing
- Invalid Payloads
- What a Binding Decides
- The Bindings
- Conformance
- Not in This Version
1. Scope and Position in the Family
A ridl contract describes interactions between a provider and a consumer (ridl §3). When the two run in different processes, on different machines, or in different languages, what one runtime hands to the other has to be agreed on. This document agrees on it at the logical level: which facts cross, in which direction, when, and what each side does with them. It fixes the fields, their domains and their meaning. It does not fix a byte layout, a message format, a socket, or a session-establishment handshake in transport terms; a binding does that (§10), and there is one binding per transport.
Three layers are involved, and each is defined once:
| Layer | Defined by | What it fixes |
|---|---|---|
| the contract | the .ridl source, lowered to the IR | the interfaces, their numbers, the interactions, their ordinals, kinds, payload types, timing and clauses (§11) |
| the logical frame | this document | what crosses a boundary per interaction kind, and what each runtime does with it |
| a binding | one document per transport, written from this one (§10, §11) | how the frame's fields are carried over that transport, and whether the binding carries any of the three payload encodings or fixes one |
The payload's encoding is not this document's either. A payload crosses as bytes
in one of the three core encodings — FlatBuffers, proto3, podbuf — and which
encoding a session uses is named at attach (§3). A binding either carries any
of the three, the attach tag naming the session's, or fixes one; the
ADR-0020
decision 2 matrix — proto3 serialized into a stream, FlatBuffers mapped or
passed within a node — gives the default a transport offers when it carries any,
and the one encoding when it fixes one. §11.1 carries any; §11.2 fixes
FlatBuffers. A projection record fixes what the bytes of each encoding are
(ADR-0017,
ADR-0019); this
document treats the payload as opaque and says only when it is present and what
its receiver does with it.
The frame is the thing a third party implements. A runtime in another
language implements the receiving and sending sides of §5 and §6 and presents
them through ports shaped like ridl-rt's; a binding for another transport
carries §4's fields. Neither reads ridl-loopback or the Rust backend to do so,
and §10 makes that a rule rather than a hope.
2. Vocabulary
Every name below is ridl-rt's, and it is defined once, in the module named.
This document reuses the names; it does not redefine them, and a reader who
needs a type's exact shape reads
the ridl-rt design record, which carries every
definition, or the crate's own source under crates/ridl-rt/src/.
| Name | Defined in | Meaning here |
|---|---|---|
CatalogRef | ridl_rt::contract | one unit's interfaces: the unit name and a 32-byte SHA-256 CatalogHash. Two references are equal only when both the name and the hash are equal |
InterfaceNo | ridl_rt::contract | an interface's number in its catalog (ridl §11, interfaces.lock), a u32, never 0 |
Ordinal | ridl_rt::contract | an interaction's position in its interface body, counted from 1 (ridl §11), a u32, never 0 |
Kind | ridl_rt::contract | the five interaction kinds, with the values Signal = 1, Event = 2, Command = 3, Query = 4, Fixed = 5 |
Timestamp | ridl_rt::sample | i64 microseconds since the PTP epoch, 1970-01-01 00:00:00 TAI (ridl §3.1) |
Duration | ridl_rt::sample | i64 microseconds |
Envelope | ridl_rt::sample | the sender's stamp: Timestamp and seq: u64 (ridl §3.1). Stamped once, at the sender, and changed by nobody after that |
Provenance | ridl_rt::sample | where a signal's value comes from: Init, Live, or Invalid(Cause) (ridl §4.4, §4.5) |
Cause | ridl_rt::sample | why a channel is invalid: Declared by the provider, or Detected(Detection) by the consumer's binding |
Detection | ridl_rt::sample | what a consumer's binding found: InvalidValue(Violation) (a typl constraint, ridl §10.2) or Corrupt (not a well-formed encoding, ridl §10.3) |
Freshness | ridl_rt::sample | Fresh, Stale { by } or Unbounded, measured by the consumer's runtime (§8) |
Correlation | ridl_rt::port | a u64 identifying one sent call to its caller |
Contract | ridl_rt::error | the four contract-error categories of ridl §10.2: InvalidValue(Violation), PreconditionFailed, ContractBroken, UnknownInteraction |
Transport | ridl_rt::error | the detected infrastructure failures of ridl §10.3: Timeout, Undelivered, Down, Corrupt, Busy |
Violation | ridl_rt::payload | the Rule that failed and, for a violation the generated code detected, the name of the typl type whose constraint failed; a contract(InvalidValue) outcome decoded from a frame carries the rule alone (§9.6), whereas a consumer's own Detected(InvalidValue(..)) on received bytes and a violation delivered in process keep the name |
Encoding | ridl_rt::encoding | the closed set of payload encodings: FlatBuffers, Proto3, Podbuf, each named as its cargo feature is |
Two words this document adds, because ridl-rt has no need of them:
- A session is one attachment of a consuming runtime to a providing runtime over one binding, for one catalog (§3).
- A frame is one unit that crosses a session, in either direction (§4).
Two ridl-rt names deliberately do not appear on the frame. ClaimId is
the provider runtime's local name for a call it has presented to application
code, and Correlation is the caller runtime's local name for a call it has
sent; neither crosses (§5.3, §5.4). Family — which of the five boundaries of
ridl §3.2 an interaction sits on — is not on the frame in this version (§13).
TraceContext, the optional trace context that a call or an event carries
across a port (ridl_rt::trace), is not on the frame either, so a frame
transport delivers None.
3. The Session
A session is asymmetric. One side consumes and the other provides. The consuming side opens the session, subscribes, reads and calls; the providing side publishes, raises, answers and settles. Two components that each provide an interface to the other open two sessions, one in each direction. Which side provides what is the wiring's (rsdl), not the frame's.
A session is bound to one catalog. It is opened by an attach naming a
CatalogRef (§6.1), and every InterfaceNo and Ordinal on every frame of the
session is scoped by that catalog. This is
ADR-0021 decision 3 one
layer down: a port is bound to one catalog, and the session under it is too. A
boundary carrying several catalogs carries several sessions.
A session fixes the payload encoding. Every payload that crosses the session
is in one encoding, named at attach (§6.1) and never changed. A binding either
carries any of the three encodings, in which case the consuming program names
one at construction and the transport offers a default taken from the
ADR-0020
decision 2 matrix, or fixes one (§1, §10 item 3); §11.1 carries any and §11.2
fixes FlatBuffers. attach carries the tag in both cases, so that a mismatch is
a refusal rather than a stream of corrupt payloads. The encoding tag values
are:
| Tag | Encoding | Encoding::NAME |
|---|---|---|
| 1 | FlatBuffers | flatbuffers |
| 2 | proto3 | proto3 |
| 3 | podbuf | podbuf |
These are the values the ridl-rt design record lists as "the frame
specification's wire tag values" against a constant Encoding::FORMAT that
ridl-rt 0.1 does not carry. A binding carries the tag as a value in this
table; a runtime maps it to the encoding marker type. Adding a fourth encoding
is an ADR, never a fourth row written here alone
(ADR-0021 decision 7).
A session has one sequence counter per publishing channel, one per caller and
one per provider's responses (§7), all of which start over when the session is
opened. State a consuming runtime holds about a session — the current sample of
each subscribed signal, the sequence numbers it has seen, the calls in flight —
is the session's, and a session that ends takes it with it: after the session
ends, every port of the consuming runtime that reaches through it reports
Detached, and every call in flight reports Transport::Down.
4. The Frame
A frame has a header and, on some forms, a payload. The header's field set is closed: a binding carries exactly these fields, adds none, and omits none that the form requires. Header fields have no default: a field the form requires is present on every frame of that form.
| Field | Domain | Present on |
|---|---|---|
form | one of the ten forms below | every frame |
interface | InterfaceNo, u32, never 0 | every frame but attach and attached |
ordinal | Ordinal, u32, never 0 | every frame but attach and attached |
kind | Kind, values 1 to 5 | every frame but attach and attached |
envelope | Envelope: stamp: i64, seq: u64 | publish, occurrence, request, response |
provenance | Init, Live or Invalid(Declared) | publish |
correlation | u64: the seq of the request answered | response |
outcome | §5.3 and §5.4; §9.6 for what contract(InvalidValue) carries | response, attached, answer |
payload | bytes in the session's encoding | as §5 states per form and per kind |
The ten forms, by plane and direction:
| Form | Plane | Direction | Meaning |
|---|---|---|---|
attach | control | consumer → provider | opens the session for one catalog and one encoding (§6.1) |
attached | control | provider → consumer | accepts or refuses the attach (§6.1) |
subscribe | control | consumer → provider | starts delivery of one signal or one event (§6.2) |
answer | control | provider → consumer | accepts or refuses a subscribe; refuses a read (§6.2, §6.3) |
unsubscribe | control | consumer → provider | stops delivery of one signal or one event (§6.2) |
read | control | consumer → provider | asks for the current state of one signal, or the value of one fixed (§6.3) |
publish | data | provider → consumer | one publication of a signal's state, or the value of a fixed (§5.1, §5.5) |
occurrence | data | provider → consumer | one occurrence of an event (§5.2) |
request | data | consumer → provider | one call of a command or a query (§5.3, §5.4) |
response | data | provider → consumer | the acknowledgment of a command, or the reply of a query (§5.3, §5.4) |
The codes. A binding that carries a field as a numeric code uses these
values. form: attach 1, attached 2, subscribe 3, answer 4,
unsubscribe 5, read 6, publish 7, occurrence 8, request 9, response
10 — the order of the table above. provenance: Init 1, Live 2,
Invalid(Declared) 3. outcome: accepted 1, reply 2,
contract(InvalidValue) 3, contract(PreconditionFailed) 4,
contract(ContractBroken) 5, contract(UnknownInteraction) 6, corrupt 7,
busy 8, refused 9. A refused reason: unknown_catalog 1,
catalog_mismatch 2, encoding_unsupported 3, frame_version_unsupported 4,
range 5. The Rule of an InvalidValue: §9.6. A new value in any of these
tables is a new frame version.
Why kind is on the frame at all. Both sides hold the catalog, so either
could look the kind up from the ordinal. It crosses because a relay, a recorder
or an observer that holds no catalog can route and classify a frame by it, and
because a frame whose kind disagrees with the receiver's catalog is a
detectable disagreement between peers — Contract::UnknownInteraction (ridl
§10.2, §11) — where an ordinal alone would be silently misread. The receiver
checks it: a frame whose kind is not the receiver's catalog's kind for that
(interface, ordinal) is treated exactly as a frame naming an unknown ordinal
(§6.4).
The header is canonical. Because the field set is closed and has no optional or defaulted field, the header of a given frame has one encoding under a given binding. That is the part of typl §17.10's canonical-encoding rule this document owns; the payload's part belongs to each projection record.
Widths. InterfaceNo and Ordinal are u32 on the frame, as they are in
ridl-rt and in the IR. This document places no bound on a header's size, so
the u16 fallback
ADR-0021 decision 2
records for this specification is not triggered. A binding whose transport
carries one of these in a native field narrower than 32 bits — an AIDL
transaction code is one — checks the catalog's numbers against that field's
range when the session is attached, and refuses the attach when a number does
not fit (§6.1); it does not truncate.
5. What Crosses per Interaction Kind
For each kind: which frames it uses, what each field means on them, what the
sender puts in and what the receiver does with it. The five subsections are the
normative core of this document. The facts come from the ridl reference sections
cited, and from ridl-rt's port contracts where the reference leaves the
runtime's side unstated.
5.1 Signal
A signal is continuous state: the latest sample is the truth (ridl §4). It
crosses as publish frames, provider to consumer, after a subscribe or in
answer to a read.
| Field | On a publish of a signal |
|---|---|
kind | Signal |
envelope | the provider's stamp and the channel's seq at this publication; under Init, seq 0 and the stamp of the channel's creation |
provenance | Init before the first publication; Live for a publication; Invalid(Declared) for an invalidation |
payload | present under Live, the value; absent under Init and Invalid(Declared) |
What the provider sends. A SignalWriter::commit publishes everything the
provider staged — a set, an invalidate or a touch per channel — under one
stamp per interface, taken from the provider runtime's clock. Each staged
channel becomes one publish frame:
- a
setis apublishwithLiveand the value; - an
invalidateis apublishwithInvalid(Declared)and no payload (ridl §4.5: the invalidity is delivered, the value is not); - a
touchis apublishwithLiveand the current value again. It carries the value, although nothing changed, so that a consumer which missed the earlier publication, or which subscribed between the two, holds the value after this frame. A form that re-affirms without a payload was rejected for that reason.
Every publish of a channel increments that channel's seq (§7), an
invalidation and a re-affirmation included: each is a publication.
What the consumer does. The consuming runtime holds, per subscribed signal,
one current sample: the last publish frame it accepted for that channel,
plus the last good value. On receiving a publish for a subscribed signal it:
- discards the frame when it already holds a sample for that channel and the
frame's
seqis not greater than that sample's (a duplicate, or a reordering; §7); anunsubscribedrops the held sample, so a latersubscribestarts again from the first frame it receives; - replaces the sample's envelope and provenance with the frame's;
- under
Live, hands the payload bytes to the generated binding, which runsPayload::verify. On success the sample's value is the decoded value and its provenance staysLive. On failure the value stays the last good value and the provenance becomesInvalid(Detected(_))(§9.1); - under
Init, sets the value to the init value, which the generated binding supplies from the catalog — the init value is a fact both sides hold, so it never crosses; - under
Invalid(Declared), keeps the last good value — or the init value, when there is none — and sets the provenance toInvalid(Declared).
The envelope a consumer reads on a sample is therefore the envelope of the frame that put the channel in its current state, including a frame whose payload the consumer rejected. That is the reading ridl §4.5 implies, and it is this document's answer to ADR-0018 open item 4 for the frame; the ridl finalization pass (driftsys/ridl#319) confirms it in the reference or corrects both.
SignalReader::read returns the current sample with its Freshness, which the
consuming runtime computes from the sample's stamp and its own clock (§8).
Nothing crosses for a read of a subscribed signal: the sample is local.
ScannableSignals and CoherentSignals are local views over the same samples
and cross nothing either.
A commit is a set of frames with one stamp. The frames of one
SignalWriter::commit carry the same stamp. A binding may carry them as one
unit and may deliver them as one unit; when it does, the consuming runtime may
present CoherentSignals over them. A binding that delivers each frame
separately does not present CoherentSignals, which ridl-rt permits a runtime
to omit. The frame requires no grouping: production coherence is the provider's
(one commit, one stamp) and delivery coherence is the binding's
(ADR-0015 decision
10).
Subscribing delivers the current state immediately. The first publish a
consumer receives after the answer is the channel's current state — Init
with no payload before any publication, or the last publication (ridl §4.4).
That first frame is an ordinary publish; nothing marks it, because the
consumer treats a solicited and an unsolicited publish identically.
5.2 Event
An event is a discrete occurrence: every occurrence matters and occurrences are
queued, not coalesced (ridl §5). It crosses as occurrence frames, provider to
consumer, after a subscribe.
| Field | On an occurrence |
|---|---|
kind | Event |
envelope | the provider's stamp when it raised the occurrence, and the channel's seq |
provenance | absent — an occurrence is not state and has no provenance (ridl §4.4, last paragraph) |
payload | always present: the occurrence's value as the provider encoded it |
What the provider sends. EventSink::raise sends one occurrence to every
consumer subscribed to that event at the moment it is raised, with the channel's
next seq (§7). There is no staging and no commit. An occurrence raised while a
consumer is not subscribed is not sent to it, and is never sent later: there is
no cache and no replay (ridl §5.1).
What the consumer does. The consuming runtime queues occurrences per session
in the order received and presents them through EventSource::next. It discards
an occurrence older than the event's time to live, measured from the frame's
stamp against its own clock (§8), inside next. It never verifies the
payload: next returns the bytes, and the generated binding runs
Payload::verify and delivers the occurrence either as the decoded value or as
Err(Detection) with its envelope — an occurrence whose payload fails its
check is delivered with that marker, not withheld
(ADR-0021 decision 6;
§9.2). A gap in seq between two accepted occurrences of one channel is a loss
(ridl §3.1); the runtime records it and continues.
5.3 Command
A command is a fire-and-forget action request in the contract, with a delivery
acknowledgment beneath it in the runtime (ridl §6). It crosses as one request,
consumer to provider, and one response, provider to consumer.
| Field | On the request | On the response |
|---|---|---|
kind | Command | Command |
envelope | the caller's stamp when it sent the call, and the caller's seq (unique per caller, §7) | the provider's stamp when it settled, and the provider's seq for its responses on this session |
correlation | absent — the request's own seq is what the response echoes | the request's seq |
outcome | absent | accepted, contract(Contract), corrupt, or busy |
payload | always present: the argument value | absent — an acknowledgment carries no functional payload (ridl §6.1) |
What the caller sends. Caller::command encodes the argument, evaluates the
command's require clauses before sending — a failing precondition costs no
round trip and is reported locally as SendError::Contract(PreconditionFailed)
— and sends one request. The Correlation the port returns is the caller
runtime's local name for the outcome it will read back through Caller::ack; a
runtime may use the request's seq as its value, and nothing on the frame
depends on that choice.
What the provider does. A providing runtime that cannot admit the call — no
slot, no budget, or a call faster than the member's min (§8) — does not
present it and answers with a response whose outcome is busy. Otherwise, on
a request naming a command, the providing runtime presents it once to
application code as a claim (Handler::next_claim, with a ClaimId that is the
provider's local name and never crosses), and the generated dispatch:
- runs
Payload::verifyover the argument bytes; a structure failure settlescorrupt, a typl violation settlescontract(InvalidValue(violation)); - evaluates the
requireclauses; a false clause settlescontract(PreconditionFailed); - settles
accepted, and only then calls the provider's method.
The response is sent when the claim settles. A command's acknowledgment is a
delivery acknowledgment — received and accepted for execution — and never an
execution outcome (ridl §6.1): the provider's method runs after the response is
sent, and what it does is observed as state, never reported on this frame. A
command is rejected whole or accepted whole; it is never partially executed
(ridl §6.2).
What the caller does with the response. Caller::ack reports Ok(()) for
accepted, Err(CallError::Contract(c)) for contract(c),
Err(CallError::Transport(Corrupt)) for corrupt, and
Err(CallError::Transport(Busy)) for busy. A response whose correlation
names no call in flight is discarded. No response within the command's
response bound is Transport::Undelivered (§8), detected by the caller's
runtime; the response bound covers acceptance, not execution
(ADR-0015 decision 3).
5.4 Query
A query is request/response with a mandatory reply (ridl §7). It crosses as one
request and one response, exactly as a command does, with two differences:
the response carries the reply, and the settlement happens after the provider's
method returns.
| Field | On the request | On the response |
|---|---|---|
kind | Query | Query |
envelope | as for a command | as for a command |
correlation | absent | the request's seq |
outcome | absent | reply, contract(Contract), corrupt, or busy |
payload | always present: the argument value | present under reply, the declared return type; absent under contract, corrupt and busy |
What the caller sends is as for a command; the outcome is read back through
Caller::reply, never through Caller::ack, which answers None for a query's
correlation always.
What the provider does. Admission is the command's: a query the providing
runtime cannot admit is answered busy. Steps 1 and 2 are the command's. Then
the generated dispatch calls the provider's method, evaluates the query's
ensure clauses over the arguments and the reply, and settles
contract(ContractBroken) when a clause is false — an ensure failure is a
provider defect (ridl §10.2) and the reply it would have carried does not cross
— or reply with the encoded reply otherwise. A query settles after the
provider's method returns, because its settlement carries the reply.
The error arm is inside the payload. A fallible query's return type is an
inline T | E (ridl §10.1), and the reply payload is that union: a Stratum 1
failure is a reply whose payload holds the error arm. It is never an outcome
value, because it is data the contract declares, and this frame's outcome
carries only what the contract does not declare — Stratum 2 and the two Stratum
3 failures a provider can report (§9.6).
What the caller does with the response. Caller::reply returns the reply
bytes for reply, Err(CallError::Contract(c)) for contract(c),
Err(CallError::Transport(Corrupt)) for corrupt and
Err(CallError::Transport(Busy)) for busy. The generated binding verifies the
reply bytes (§9.3). No response within the query's response bound is
Transport::Timeout (§8), detected by the caller's runtime.
5.5 Fixed
A fixed is provisioned outside the contract and immutable for the lifetime of
the software instance (ridl §8). It is not published and not called; it crosses
at most once per read, provider to consumer, as a publish frame in answer
to a read (§6.3).
| Field | On a publish of a fixed |
|---|---|
kind | Fixed |
envelope | the provider's stamp when it answered, and seq 0 — a fixed has no sequence: it never changes |
provenance | Live |
payload | always present: the provisioned value |
A consuming runtime may cache the value for the session's lifetime and answer
every later FixedReader::read_fixed from the cache, because the contract
promises the value does not change while the software instance runs; a new
session reads it again, because the provider may be a new instance. There is no
subscribe for a fixed: a subscribe naming one is refused
UnknownInteraction (§6.2). There is no provider-side port: the value is
provisioned into the providing runtime, not published by application code.
5.6 The field table, by kind
The same facts in one table. "—" is absent; the parenthesised form is the frame the column describes.
| Kind | Frames | envelope | provenance | correlation | outcome | payload |
|---|---|---|---|---|---|---|
signal | publish | provider stamp, channel seq | Init / Live / Invalid(Declared) | — | — | under Live only |
event | occurrence | provider stamp, channel seq | — | — | — | always |
command | request / response | caller stamp, caller seq / provider's | — | — / request seq | — / accepted, contract, corrupt, busy | always / — |
query | request / response | caller stamp, caller seq / provider's | — | — / request seq | — / reply, contract, corrupt, busy | always / under reply only |
fixed | publish (on read) | provider stamp, seq 0 | Live | — | — | always |
6. The Control Plane
The control plane is uniform across every binding (ADR-0018 decision 7): the same five operations over a Unix socket, Binder, a WebSocket and the wasm host boundary. Only the data plane varies between a path that maps a buffer and one that frames it.
6.1 attach and attached
attach opens the session. It carries:
| Field | Domain |
|---|---|
frame_version | u32: the version of this document's frame; 1 here |
catalog | CatalogRef: the unit name and the 32-byte hash |
encoding | the encoding tag (§3) |
attached answers with an outcome:
accepted: the provider serves that catalog name; the hash is the provider's own catalog hash or one of the earlier hashes its catalog descriptor lists as compatible (Catalog.compatible, ADR-0014 decision 15 as amended on 2026-10-09); the encoding is one the provider serves; and every interface number and ordinal of the catalog the provider serves fits the binding's native fields (§4). The session is open.refused(reason), withreasonone ofunknown_catalog(the name is not served),catalog_mismatch(the name is served, and the hash is neither the provider's own nor one its descriptor lists),encoding_unsupported,frame_version_unsupported, orrange(a number does not fit a native field). The session is not open; nothing else is exchanged.
The compatible catalogs list. The toolchain writes the list at the
provider's build: ridl baseline records, per unit, the chain of published
catalog hashes whose successive changes ridl diff judged compatible, and
ridl build emits that chain when the change from the published baseline to the
tree it builds is compatible too. A breaking change restarts the chain when it
is published: ridl baseline records the new hash alone. A tree that is
breaking relative to the published baseline is built with an empty list, and the
recorded chain is left as it is until the next publication. The one exception is
a catalog that did not change: when the catalog hash equals the newest hash of
the published chain, the chain is carried at publication and at the build
whatever the verdict, because every hash it lists is still compatible with that
catalog. ADR-0014 decision 15, as
amended on 2026-10-09 and 2026-10-10, states the rule. The direction is fixed:
an older consumer attaches to a newer provider. A newer consumer
attaching to an older provider is refused catalog_mismatch, because no tool
judges that direction and the provider is the authority on what it serves. The
descriptor field, the chain file and the emit are built (driftsys/ridl#787).
With an empty list the rule above is the exact-match rule.
The catalog check is the peers' agreement on the contract, taken once. On a
session whose attach named the provider's own hash, the two runtimes hold the
same interfaces, the same ordinals and the same kinds, so a frame that names an
unknown one is one side's defect (§6.4). On a session whose attach named an
earlier hash, the agreement is this: every interaction the consumer's catalog
holds, the provider's catalog holds under the same interface number, ordinal and
kind, or has retired since; the provider's catalog may hold interactions the
consumer's does not; the consumer never names an interaction its own catalog
does not hold; and the provider never sends a frame for an interaction the
consumer did not name on the session. The last clause costs the provider nothing
to keep: every data-plane frame it sends answers a subscribe, a read or a
request of the session (§5, §6.2, §6.3), each of which names an
(interface, ordinal) the consumer chose from its own catalog, so an
interaction appended after the consumer's hash — an event, say — is never
subscribed by that consumer and its occurrences are never sent to it. A
consuming runtime that subscribes at attach subscribes the signals of its own
catalog. What an older consumer can meet is an interaction a later catalog
retired, which the provider answers as §6.4 states.
The hash is computed, and the face checks it. Since driftsys/ridl#378, a
package generated by the Rust backend carries a computed catalog hash
(ADR-0014 decision 15) in place of the
CatalogHash([0u8; 32]) placeholder it carried before, so the comparison above
tells two catalogs apart. Since driftsys/ridl#381, the generated face's
Bind::new and serve compare the port's CatalogRef with the interface's
CATALOG, name and hash, once, and panic on a mismatch
(ADR-0023 decision 8;
the interaction-face record, "The catalog
check"). That check is local to one program, and it compares the two references
for equality, list or no list; this section's attach is the agreement between
two peers.
A consuming runtime whose attach was refused reports the refusal through its
own API — how is the runtime's — and every port of it reports Detached.
6.2 subscribe, answer and unsubscribe
subscribe names one (interface, ordinal) whose kind is Signal or
Event. The granularity is the interaction (ridl §5.1): one subscription per
signal or event, never per interface and never per group. An answer carrying
the same identity replies with accepted or contract(UnknownInteraction) — a
subscription to an unknown interaction fails at bind time (ridl §10.2). A
subscribe naming a fixed, a command or a query is refused the same way.
answer is the one control-plane reply form: it carries interface, ordinal,
kind and an outcome, and no envelope, no correlation and no payload.
For a signal, accepted is followed by the channel's current state as a
publish (§5.1). For an event, nothing follows until the provider raises an
occurrence, and only occurrences raised after the answer are delivered. A
second subscribe to an interaction already subscribed on the session is
accepted and changes nothing.
unsubscribe names one (interface, ordinal) and has no answer. Frames of that
interaction already in flight may still arrive; the consuming runtime discards
them.
The port pair EventSource::subscribe / unsubscribe sends these frames; a
runtime's SignalReader needs the signal subscribed too, which the generated
binding or the runtime arranges — how is the runtime's, and a runtime may
subscribe every signal of an interface at attach.
6.3 read
read names one (interface, ordinal) whose kind is Signal or Fixed, and
is answered with one publish (§5.1, §5.5) carrying the current state or the
provisioned value. A read of a signal does not subscribe it: nothing follows
the one publish. A read of any other kind is refused with an answer
carrying the read's identity and contract(UnknownInteraction) — a publish
cannot carry an outcome — which the consuming runtime maps to
ReadError::Contract(UnknownInteraction). A fulfilled read has no answer:
the publish is its answer.
Because two reads of one fixed return one value, and two reads of one signal
are ordered by seq (§7), a read needs no correlation of its own: its answer
is identified by (interface, ordinal) and applied by the rules of §5.1.
6.4 An unknown interaction
A frame naming an (interface, ordinal) the receiver's catalog does not hold,
or naming it with a kind the catalog does not give it, is a disagreement
between peers, Contract::UnknownInteraction:
- a
requestis answered with aresponsecarryingcontract(UnknownInteraction)and the request'sseqascorrelation; - a
subscribeis answered with ananswercarryingcontract(UnknownInteraction); - a
readis answered as §6.3 states; - a
publish, anoccurrence, aresponseor anunsubscribeis discarded and recorded; nothing is sent back.
An answer or a response that refuses a frame carries the interface,
ordinal and kind of that frame as received. So a response carrying
contract(UnknownInteraction) may carry a kind other than Command or
Query, and a receiver accepts it.
An interface the catalog retires, and a reserved ordinal, are unknown for this
purpose: the descriptors hold no row for either. On a session attached under an
earlier hash (§6.1), a subscribe, a read or a request naming an
interaction that the provider's catalog retired after that hash is answered the
same way, and that answer is the designed one, not a defect: the consuming
runtime reports it as the contract error of ridl §10.2. A frame the consumer
cannot place is the provider's defect on either kind of session. Whether a
runtime can tell an unknown ordinal from one it has no value for yet depends on
its holding the member table. ridl-transport-ws reports UnknownInteraction
when its embedder gives it one (its design record, D-12), and not otherwise.
ridl-loopback holds none: giving the loopback the catalog descriptor is not
yet assigned to an issue. Until it holds one, it cannot report
UnknownInteraction at all.
7. Sequence Numbers, Loss and Duplicates
Envelope.seq is assigned by the sender and never rewritten (ridl §3.1). Its
scope depends on the kind:
- A signal channel and an event channel each have one counter, per provider
instance, per session, starting at 1 for the first publication or occurrence;
seq0 is the envelope of a signal channel with no publication. A gap between two accepted occurrences of one event channel is a loss (ridl §3.1); a gap on a signal channel is not — intermediate samples may be missed (ridl §4). - A caller has one counter, per session, over every call it sends on every
channel, starting at 1. So a call's
seqis unique per caller, not per channel (ADR-0021 decision 5), and it is what aresponseechoes ascorrelation. - A provider's responses have one counter per session, starting at 1. It orders the responses; nothing depends on it beyond that.
Ordering on a signal. A consuming runtime accepts a publish for a channel
only when its seq is greater than the one it holds for that channel (§5.1). So
a reordered or duplicated publish is discarded, and the newest publication
wins whatever order the frames arrive in.
Duplicate suppression on a call. A binding may retransmit a request — the
same frame, the same envelope — while no response has arrived and the response
bound has not passed. The providing runtime keys each call it has presented on
the caller's identity plus the request's seq, where the caller's identity
is the session's (the binding says what it derives it from; §10). A request
under a key already presented is not presented again: the runtime re-sends
the response it gave, when the claim has settled, and sends nothing when it
has not. Two callers are never merged, even under one seq. A provider keeps a
settled call's response for a call with a response bound until that bound has
passed, measured from the request's stamp; for a call with no response bound,
the binding fixes the retention and states it.
The retransmission budget is bounded by the response bound
(ADR-0018
decision 18): a caller's runtime retrying with backoff exhausts its attempts
within the declared max, and reaching it is the Stratum 3 detection (§8).
No shipped runtime retransmits. ridl-loopback delivers each call once because
nothing in a process is lost. ridl-transport-ws suppresses a duplicate
request and keeps each settled response until the request's bound has passed,
measured from the request's stamp or from the provider's clock at receipt,
whichever is earlier, when its member table gives the member a bound. For a
member the table gives no bound, or when the embedder gives it no table, it
keeps the last 64 settled responses of the session instead. That fallback is a
deviation of this crate, stated here and in its design record, and not a rule of
the binding (driftsys/ridl#986).
8. Timing
Nothing about timing crosses the boundary. A member's min and max are
facts of the catalog (ridl §9), held by both sides in their descriptors; what
crosses is the envelope's stamp, and every timing rule is evaluated on it. The
stamp is in the platform's synchronized time base (ridl §3.1), so a receiver's
clock and a sender's stamp are comparable. Loss of synchronization is a system
failure outside this document (ridl §10.4).
| Bound | On a signal | On an event | On a command or a query |
|---|---|---|---|
max, staleness bound | the consuming runtime computes Freshness on every read: Fresh while now − stamp ≤ max, else Stale { by: now − stamp − max }; Unbounded with no max | the consuming runtime discards an occurrence with now − stamp > max inside EventSource::next | the calling runtime reports Transport::Undelivered (command) or Transport::Timeout (query) through ack or reply once now − request stamp > max with no response; the providing runtime presents Claim.remaining = max − (now − request stamp), None with no max |
min, rate floor | the providing runtime coalesces a faster set into the next publication | the providing runtime refuses a faster raise with RaiseError::Busy, or delays it | the providing runtime may refuse a faster call at admission, and answers with busy |
Under @Xms, the strict period, the providing runtime publishes the signal
every X whether or not it changed, which is a touch when nothing changed
(§5.1).
None of this is built. ridl-loopback holds no member table, so it reports
every sample Unbounded, every claim remaining: None, discards no occurrence,
and enforces no rate floor (its design record,
"What it cannot report"). The one timing fact a shipped runtime does produce is
the stamp, from a clock a test advances by hand.
9. Invalid Payloads
An invalid payload is one whose bytes fail Payload::verify: either the bytes
are not a well-formed encoding — VerifyError::Structure(Malformed), a
Stratum 3 serialization failure (ridl §10.3) — or they are well formed and hold
a value that breaks a typl constraint — VerifyError::Contract(Violation),
INVALID_VALUE, Stratum 2 (ridl §10.2). The two are never collapsed into one,
because they sit in different strata. A runtime never verifies: a port carries
bytes and names no payload type, so verification is the generated binding's on
both sides, and a runtime that verified would need the generated types below the
port, which the layering forbids
(ADR-0020
decision 6).
A sender does not verify its encoded bytes. Ref::encode calls
Payload::encode without running Payload::verify on the result
(ADR-0021 decision 7).
Sender-side constraint checks belong to the encoder; the
FlatBuffers codec record
describes those checks. An invalid value that is published is a provider defect
that surfaces at every consumer, through the rules below, and through the
provider's own observability, never through a frame sent back on pub/sub (ridl
§4.5).
9.1 A signal publication
The channel transitions to the invalid state (ridl §4.5). The consuming runtime
keeps the last good value — or the init value, when there is none — sets the
sample's provenance to Invalid(Detected(InvalidValue(violation))) or
Invalid(Detected(Corrupt)), and takes the envelope of the rejected frame
(§5.1). The malformed value is never delivered; its invalidity always is, on
every read until a later publish with Live passes its check. Nothing is sent
back. The runtime records the transition.
Nothing withholds a subsequent good publication: a publish that passes its
check after one that failed returns the channel to Live.
9.2 An event occurrence
The occurrence is delivered, marked invalid: Occurrence.payload is
Err(Detection) and the envelope is the frame's
(ADR-0021 decision 6).
It is not withheld, because nothing fails silently (ridl §10.3, §10.4), and it
is not delivered as a value, because the value is not one the contract admits.
Nothing is sent back.
9.3 A call's arguments and a query's reply
The providing runtime's generated dispatch verifies the argument bytes
before any application code runs, and settles corrupt for a structure failure
or contract(InvalidValue(violation)) for a typl violation (§5.3). The
provider's method is not called. The response crosses, and the caller reads it
back as Transport::Corrupt or Contract::InvalidValue(_).
The calling runtime's generated binding verifies a reply payload and
reports a structure failure as CallError::Transport(Corrupt) and a typl
violation as CallError::Contract(InvalidValue(violation)) through the query's
reply method. Nothing is sent back to the provider: a provider that replies with
an invalid value is a provider defect, reported at the caller and through the
provider's observability.
9.4 A fixed
The consuming runtime's generated binding verifies the value on the read that fetched it. A value that fails its check is not cached and the read reports the detection to the application; a provisioned constant that fails its own type's constraints is a provisioning defect, and the runtime records it.
9.5 A frame the binding cannot read
A frame whose header the binding cannot decode — an unknown form, a kind
outside 1 to 5, an interface or an ordinal of 0, a field the form requires
absent — is discarded and recorded, never partly applied. A request whose
header is readable but whose payload is absent where the form requires it is
answered corrupt. Whether a stream whose framing has been lost is torn down,
and how it is re-synchronized, is the binding's (§10); a torn-down session ends
as §3 states.
9.6 Which failures cross
Of the five Transport variants, exactly two cross a boundary, each as a
response outcome: Corrupt, because the provider detected it, and
Busy, because the provider refused the call at admission. Timeout,
Undelivered and Down are the absence of a frame, detected by the
caller's runtime against its own clock and its binding's session state, and
never sent by anyone. Of the four Contract categories, every one may cross as
a response or answer outcome. Detected(_) never crosses: it is the
consumer's own finding about bytes it received.
What a violation carries. contract(InvalidValue(violation)) crosses as the
violation's Rule alone; the name of the typl type whose constraint failed does
not cross. The Rule codes: Range 1, Step 2, Length 3, Pattern 4,
Variant 5, Unique 6; a new Rule variant is added only together with a new
frame version, because a receiver cannot construct a variant it does not know. A
runtime that decodes the violation from a frame reports it with no type name,
which ridl_rt::payload::Violation represents as type_name: None; a violation
the generated binding detects locally keeps its name, and an in-process
transport delivers a violation with its name. A type identifier was rejected
because the catalog descriptor carries no type table, and the name is
diagnostic, not contractual.
10. What a Binding Decides
A binding is written from this document alone. A binding's author reads this
document, the ridl-rt design record for the exact shape of each name in §2,
and the projection record of the encoding the path carries. They do not read
ridl-loopback, the Rust backend, or another binding to learn what crosses. A
fact a binding needs that this document does not state is a defect in this
document, fixed here and not in the binding; a behaviour a binding adds that
this document does not state is not part of the frame, and two bindings that
differ on it do not interoperate through a relay.
A binding's own document states, and only states, the following. Each item is a decision this document leaves to the transport on purpose, and a binding that leaves one unstated is incomplete.
- The transport and the session. How a consuming side reaches a providing
side; what opens and what ends the session under §3; what "the session ended"
is on that transport, so that
DetachedandTransport::Downhave a trigger. - The header encoding. The byte layout, field order and width of every
header field in §4, and how the
formis carried.InterfaceNoandOrdinalkeep their 32-bit range or the binding refuses atattach(§4). - The encoding tag. Whether the binding carries any of the three encodings,
the
attachtag naming the session's, or fixes one; when it fixes one, which, under the ADR-0020 decision 2 matrix, as a value of the table in §3; when it carries any, which default its transport offers. - Framing. Where one frame ends and the next begins on a stream transport; how a payload's length is carried; what the binding does when framing is lost (§9.5).
- The caller's identity for duplicate suppression (§7): what the providing side derives it from — a connection, a process, a Binder identity — and whether it survives a reconnect.
- Retention of a settled response for a call with no response bound (§7).
- Grouping. Whether the frames of one commit cross as one unit (§5.1), and
therefore whether a runtime over this binding presents
CoherentSignals. - What is mapped and what is framed. On a path that maps a buffer rather than sending a frame — a shared-memory store, a Binder parcel read in place — which fields of §4 are carried in the mapped region's own layout and which in a frame beside it. The control plane is framed on every binding.
- The rate-floor answer on a call (§8), when the binding enforces one.
A binding does not decide: the field set, the meaning of any field, what a receiver does with a frame, the per-kind presence rules of §5, the invalid payload rules of §9, or the control-plane operations and their answers.
11. The Bindings
Two bindings are named: one by the transport that carries it, and one by the
platform whose runtimes write their own. The WebSocket binding is specified in
§11.1 and its two transports are built: the Rust crate ridl-transport-ws and
the TypeScript package @driftsys/ridl-transport-ws at
packages/ridl-transport-ws/. A binder contract is not written in this
repository.
11.1 WebSocket
The WebSocket binding carries this frame between a consuming runtime that opens
a WebSocket connection and a providing runtime that accepts it.
ridl-transport-ws (driftsys/ridl#265,
ADR-0020
decision 6) is its Rust crate, and @driftsys/ridl-transport-ws
(packages/ridl-transport-ws/, decision 7) is the same binding for Deno and the
browser; it carries payload bytes without decoding them, so the two ends of one
connection may be a Rust runtime and a TypeScript one. This section is the
binding document: it states the nine items of §10 and adds nothing to the frame.
The binding is version 1 and carries frame version 1. The reasoning behind
each rule, with the alternatives considered, is in
the binding rationale technote.
1. The transport and the session. One WebSocket connection carries one
session. The client is the consumer and sends attach as its first message; the
server is the provider and sends attached as its first message. The URL is the
deployment's; the binding places nothing in the path, the query or a header;
ws:// and wss:// are both allowed, and what wss:// provides is the
security the deployment relies on (§13). The session ends when either side sends
a close frame, when the connection fails, or when the liveness check fails: the
provider sends WebSocket pings, and either end treats no message of any kind,
data or control, within its liveness timeout as the end of the session. The
provider pings because a browser client cannot. A consumer that needs two
catalogs opens two connections.
The subprotocol. The client offers ridl-ws-v1 in Sec-WebSocket-Protocol;
a client offering several versions lists the newest first, and the server
selects the newest it serves. When the server selects none, the client fails the
connection; a client that can choose a close code uses 1002. On a ridl-ws-v1
connection, attach.frame_version is 1 or the attach is refused
frame_version_unsupported.
2. The header encoding. Every integer is fixed-width little-endian; stamp
is a two's-complement i64. There is no padding and no alignment, and no field
carries the payload's length: the header is the prefix below, and on a frame
that carries a payload, the payload is every byte after it. Every code is the
value §4 and §9.6 give it (form, provenance, outcome, a refusal reason, a
Rule), kind is the Kind value of §2 and encoding the tag of §3; this
section restates none. Fields are in this order, and a form carries exactly the
fields §4 gives it:
| Form | Bytes | Header size |
|---|---|---|
attach | form u8, frame_version u32, encoding u8, hash 32 bytes, name_len u8 (1 to 255), name UTF-8 (name_len bytes) | 39 + name |
attached | form u8, outcome u8, detail u8 | 3 |
subscribe, unsubscribe, read | form u8, kind u8, interface u32, ordinal u32 | 10 |
answer | the common 10, then outcome u8, detail u8 | 12 |
publish | the common 10, then stamp i64, seq u64, provenance u8 | 27 |
occurrence, request | the common 10, then stamp i64, seq u64 | 26 |
response | the common 10, then stamp i64, seq u64, correlation u64, outcome u8, detail u8 | 36 |
detail is the Rule code (§9.6) with outcome 3, the refusal reason code
(§4) with outcome 9, and 0 with every other outcome. The name is at least
one byte and valid UTF-8. A consuming runtime whose unit name is longer than 255
bytes of UTF-8 cannot attach over this binding: it reports an error when the
session is opened and sends nothing, so no refusal reason exists for that case.
A value outside its table is a protocol violation (item 4). InterfaceNo and
Ordinal keep their 32-bit range, so §4's range check at attach always passes
on this binding.
Whether a frame carries a payload is decided by its header, never by whether
bytes follow the header: by its form, by the provenance of a publish and
by the outcome of a response. From §5: an occurrence and a request
always carry one; a publish carries one under Live (a signal's publication,
and every publish of a fixed) and none under Init or Invalid(Declared);
a response carries one under reply and none under any other outcome;
attach, attached, subscribe, answer, unsubscribe and read never
carry one. kind does not change the answer. A payload that is present may be
zero bytes long — a proto3 value whose fields all hold their defaults encodes to
no bytes — and is handed to the generated binding as it is.
The sizes. The envelope is 16 bytes. The largest data-plane header without
its envelope is 20 bytes (response), so a data-plane message is at most 36
bytes plus the payload bound. The largest header of any message is the attach
at 39 + 255 = 294 bytes; it carries no payload, so a receiver's message size
limit admits at least 294 bytes. Under the payload ceiling of 16,256 bytes
(ADR-0030 decision 2), the
largest data-plane message is 36 + 16,256 = 16,292 bytes. The Rust provider
ridl-transport-ws limits a message to 16,292 bytes by default and refuses a
configured limit above it (ADR-0030 decision 10). The codegen model carries the
data-plane numbers as the websocket row, binding version "1",
frame_header_max_bytes 20, envelope_bytes 16
(crates/ridl-ir/src/codegen/bindings.rs). The row is derived from this layout:
the layout is frozen through the vectors below, and the row is a derived value,
corrected in a patch release if it is wrong
(ADR-0029 decision 2).
3. The encoding tag. The binding fixes no encoding: a session uses the
encoding its attach names, and a provider that does not serve it refuses
encoding_unsupported. The default a program gets when it names none is the
transport's, proto3, and the construction can name another.
4. Framing. One frame is one WebSocket message with the binary opcode;
WebSocket fragmentation is below this binding. A text message is closed with
1003. A message the receiver cannot read — shorter than its header, a
control-plane message other than attach that is longer than its header, a code
outside its table, interface or ordinal 0, a message before attach, a
second attach, a name_len that does not match the message, a name that is
empty or not valid UTF-8, a first provider-to-consumer message that is not
attached — is a protocol violation: the receiver records the frame (§9.5) and
closes with 1002, and the session ends as §3 states. A publish or a response
whose header says it carries no payload and which has bytes after its header is
discarded and recorded (§9.5). A frame whose header says it carries a payload is
never answered or discarded for the payload's length: the payload, zero bytes
included, goes to Payload::verify, and a payload that fails to decode follows
§9 — a request's is answered corrupt (§5.3). So the request of §9.5 whose
payload is absent does not occur on this binding. One exception: a provider that
holds the member limits of its catalog answers a request whose payload is
longer than its member's limit corrupt without verifying it (ADR-0030 decision
15). A message above the receiver's configured size limit, which admits at least
294 bytes, is closed with 1009. Either side ends the session on purpose with
1000, the provider with 1001 when it shuts down; after attached(refused) the
provider closes with 1000, and the reason is in the frame. A provider may close
with 1008 when a session passes a resource limit it states, such as a cap on the
responses it keeps for §7. The binding defines no close code of its own.
5. The caller's identity for §7 is the connection. It does not survive a
reconnect: a reconnect is a new session, its counters start over, and the calls
of the old session were reported Transport::Down when it ended. The binding
never retransmits a request: the transport is reliable and ordered, so a
message is lost only when the connection fails, which ends the session. Every
call has an outcome, because every call has a response bound
(ADR-0015 decision 4):
the response, Undelivered or Timeout at the bound (§8), or Down at the
session's end (§3).
6. Retention. The provider keeps a settled response until the request's bound has passed, as §7 states; no call is without a bound, so the binding states no further retention.
7. Grouping. None. The frames of one commit cross as separate messages, and
a runtime over this binding does not present CoherentSignals.
8. Mapped and framed. Everything is framed; nothing is mapped.
9. The rate-floor answer is §8's: a call faster than the member's min may
be refused at admission and answered busy. The binding adds nothing.
Later versions. An addition to the frame — a form, a field, a code — is a new frame version carried by a new binding version, offered beside this one in the subprotocol list. Version 1's layout, codes and vectors are never edited. Streams (ridl §12, driftsys/ridl#336) arrive this way.
Test vectors. Each line is one complete message in hexadecimal, spaces for
reading only; xtask reads this table into the wire freeze dump by its Id
column, which is stable and never renamed. The stamp in every data-plane vector
is 0x0123456789AB microseconds (and 0x0123456789AC on the responses), chosen
to show the byte order; the hash in attach is the bytes 00 to 1f; the unit
name is veh.cabin. xtask also reads the payload a Vector cell names as
payload `<hex>` and fails a row whose bytes after the header differ from
it, or from no bytes when the cell names none; it decides from the header
whether a frame carries a payload, by the rule "Whether a frame carries a
payload" above.
| Id | Vector | Hex |
|---|---|---|
attach-1 | attach frame 1, FlatBuffers, veh.cabin | 01 01000000 01 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f 09 7665682e636162696e |
attached-accepted | attached accepted | 02 01 00 |
attached-mismatch | attached refused catalog_mismatch | 02 09 02 |
subscribe-signal | subscribe signal, interface 1, ordinal 2 | 03 01 01000000 02000000 |
answer-accepted | answer accepted, signal, interface 1, ordinal 2 | 04 01 01000000 02000000 01 00 |
publish-live | publish Live, seq 7, payload ff01 | 07 01 01000000 02000000 ab89674523010000 0700000000000000 02 ff01 |
request-command | request command, interface 1, ordinal 3, seq 1, payload 01 | 09 03 01000000 03000000 ab89674523010000 0100000000000000 01 |
response-accepted | response command accepted, correlation 1, provider seq 1 | 0a 03 01000000 03000000 ac89674523010000 0100000000000000 0100000000000000 01 00 |
response-violation | response query contract(InvalidValue(Range)), correlation 2 | 0a 04 01000000 04000000 ac89674523010000 0200000000000000 0200000000000000 03 01 |
11.2 Binder on Android
On Android, a runtime binds the ports over its own binder contract, which may be one generic, versioned AIDL interface serving every catalog. ridl specifies no Binder layout and no transaction code: the contract's methods, and whether it carries an ordinal as a transaction code or as a field, are the runtime's. Such a binding is written from this document like any other: it states the nine items of §10 for Binder, adds nothing to the frame, and follows §4 where it carries an interface number or an ordinal in a field narrower than 32 bits. Its path is "passed within a node", so its payload encoding is FlatBuffers (encoding tag 1). A binding is the runtime's and not a codegen backend's: the Kotlin backend's generated faces run over a Kotlin runtime that binds the ports this way.
Reversed decision. Until 2026-09-25 this section fixed an AIDL binding generated per interface by the Kotlin backend, with the ordinal as the transaction code, taken 2026-09-22. driftsys/ridl#516 reversed it.
11.3 Bindings this document does not name
The access service of
ADR-0018 decision
18 — Read, Subscribe, Call, Send, kind-blind and ordinal-keyed over gRPC
— is a binding of this frame in that record's own words; it is a published
schema a consumer opts into, not a story here. A shared-memory store and queue
are the engine's, outside this repository. Both, when written, take the form of
§10.
12. Conformance
A second implementation — a runtime, a binding, or both — conforms when every statement below holds and can be shown by a test that does not read this repository's code. The list is the observable consequences of §3 to §9, chosen so that each catches one way of being wrong.
- An
attachnaming a catalog the provider serves under a hash that is neither the provider's own nor one its descriptor lists as compatible is refusedcatalog_mismatch, and no other frame is exchanged; one naming a listed earlier hash is accepted (§6.1; the toolchain writes the list, andridl-transport-wswith a member table accepts a listed hash, per the As-built note). - A
subscribeto a signal is followed by exactly onepublishbefore any provider action:Init,seq0, no payload, before a first publication; the last publication otherwise (§5.1, §6.2). - A
subscribeto an event is followed by nothing until the provider raises one, and an occurrence raised before theanswernever arrives (§5.2). - A
setand aninvalidateof one channel, committed, each arrive as onepublishwhoseseqis one greater than the previous; the invalidation carries no payload, and the consumer's sample keeps the last good value underInvalid(Declared)with the invalidation's envelope (§5.1). - Two
publishframes of one channel delivered in the wrong order leave the consumer holding the one with the greaterseq(§7). - A
publishwhose payload breaks a typl constraint leaves the consumer holding the last good value underInvalid(Detected(InvalidValue(_)))with the rejected frame's envelope, and the next validpublishreturns the channel toLive(§9.1). - An
occurrencewhose payload is not a well-formed encoding is delivered asErr(Corrupt)with its envelope, not dropped (§9.2). - A command whose
requireclause is false at the caller sends norequest(§5.3). - A command
requestwhose argument breaks a typl constraint is answeredcontract(InvalidValue(_))and the provider's method is not called; one whoserequireclause is false at the provider is answeredcontract(PreconditionFailed)likewise; one that passes both is answeredacceptedbefore the provider's method runs (§5.3). - A query whose
ensureclause is false is answeredcontract(ContractBroken)with no payload; one that passes is answeredreplywith the reply payload after the provider's method returns (§5.4). - A
requestretransmitted with the same envelope after itsresponsewas sent is not presented to application code again and receives the sameresponse(§7). - Two callers whose first calls both carry
seq1 are presented as two claims (§7). - A
requestnaming an ordinal the catalog does not hold, or holding it under another kind, is answeredcontract(UnknownInteraction)(§6.4). - A
readof afixedis answered with onepublishofkindFixed,seq0 and the value; asubscribeto it is refused (§5.5, §6.2). - The frames of one commit carry one
stamp(§5.1). - No frame carries a timing bound, an init value, a
ClaimId, aCorrelationas the port hands it out, aDetected(_)provenance, or aTimeout,UndeliveredorDownoutcome (§8, §5.1, §2, §9.6).
13. Not in This Version
Each item is left out on purpose, with the record that takes it.
- Streams.
<T>on a call's parameter or return (ridl §12) has no frame in version 1:ridl-rt0.1 leaves streams out (driftsys/ridl#336). They arrive as frame version 2, negotiated as §11.1 "Later versions" states, so that a version 1 peer is unchanged. - The family. Which of ridl §3.2's five boundaries an interaction sits on is
not on the frame, because
ridl-rt0.1 carries noFamilyand the IR has no family field yet; it returns as aMemberfield, a breaking change under ADR-0021 decision 10, and the frame gains it then if a binding needs it. - The canonical encoding of a payload (typl §17.10): the header's part is §4; the bytes of each encoding are each projection record's.
- A wake hook. The frame is push: a runtime learns of a frame from its
binding. How it wakes an application that is waiting on a reply, an occurrence
or a claim is the runtime's, not the frame's:
port::Wakeable(ADR-0021 decision 13, which closed its open item 6). - Unknown fields on decode (ADR-0018 open item 3) is a payload question, the proto3 projection's.
- A relay or a bridge that forwards frames between sessions keeps every envelope it forwards and adds nothing to it; anything more is ADR-0018 decision 13's, outside this version.
- Security — who may attach, and whether a session is authenticated or encrypted — is the transport's and the deployment's, and a binding states what its transport provides.
Trace
- Roadmap:
docs/ROADMAP.md— the frame specification, the WebSocket binding, and "After step 2 — Kotlin, the first external plugin" states the Binder statement of §11.2 - Tracking issue: driftsys/ridl#257
- Binds: ADR-0018
decisions 1, 7 and 18;
ADR-0020
decisions 2, 6 and 7;
ADR-0021 decisions 2,
3, 5, 6, 7 and 14 (the last,
busyas aresponseoutcome, driftsys/ridl#510); ADR-0015 decisions 3, 4 (every call has a response bound, §11.1 item 5), 10 and 17 - Vocabulary: the
ridl-rtdesign record, andcrates/ridl-rt/src/—contract.rs,sample.rs,port.rs,error.rs,payload.rs,encoding.rs - Semantics: the ridl language reference — §3.1 the envelope, §4 signal, §5 event, §6 command, §7 query, §8 fixed, §9 timing, §10 the error strata, §11 identity, Appendix B the AIDL column
- The one runtime as built:
the
ridl-loopbackdesign record; the generated face over it: the interaction-face design record - The Binder statement: driftsys/ridl#516, which reverses the earlier AIDL binding (§11.2)
What this chapter is for. It is the contract between this toolchain and a program that reads its intermediate representation — a codegen plugin, a third-party backend, a tool that diffs artifacts. Nothing in this repository needs it: every in-tree backend links the IR types directly. Read it if you are writing a consumer outside this repository, or if you are changing the IR schema and need to know which changes a consumer can absorb.
IR Specification
The encodings of the intermediate representation, its canonical form, and the stability it promises — what a consumer outside this repository reads, what byte-identical means for it, how deep it nests, which schema changes it may expect without being rebuilt, and how a change it cannot absorb is versioned.
Version: 0.1.0 — Draft
Provenance. This document is the canonical-form policy of the IR (driftsys/ridl#321). It writes down the canonical-form policy of ADR-0014 decision 9, as that decision was amended on 2026-09-22: the canonical encoding is canonical protobuf JSON, and the binary and prototext encodings are derived. The reasoning trail, including the measurement that moved the decision, is the IR stability design note. Where this document and an ADR disagree, the ADR wins and this document is corrected.
Partial. The document inventory (family overview §2) gives the IR specification four subjects: serialization, the plugin protocol, the diff categories, and the canonical encoding. This version owns the first and the last, which is what driftsys/ridl#321 asked for. The plugin protocol is specified in the codegen plugin design; the diff categories are stated today by the typl language reference §7.4, the ridl language reference §11 and the rsdl language reference §14, and move here when a story moves them.
Table of Contents
- Scope
- The Encodings
- What Canonical Fixes
- The Nesting Bound
- The Derived Encodings
- The Compatibility Rule
- Versioning
- Reading the IR from Another Language
- Conformance
1. Scope
The schema of record is the protobuf in crates/ridl-ir/proto/, compiled with
prost (ADR-0004 §4). This document does not restate it; it states how that
schema is encoded on a surface a consumer reads, and what may change under a
consumer without breaking it.
It binds three schema packages:
| Package | Messages | Artifacts |
|---|---|---|
ridl.ir.v2 | Package (ir.proto) | <base>.ir.{json,txtpb,binpb}, every committed baseline |
ridl.ir.v2 | System (system.proto, ADR-0022) | <pkg.Name>.system.{json,txtpb,binpb} |
ridl.codegen.v1 | the lowered codegen model and its two envelopes | the request and response of a codegen plugin |
The word IR below means all three unless a sentence says otherwise.
ridl.codegen.v1 is defined in crates/ridl-ir/proto/ridl/codegen/v1/
(plugin.proto, model.proto and deployment.proto). The rules here were
written before that package existed, so that a plugin built outside this
repository could rely on them before the package was written.
2. The Encodings
Three encodings of the same schema are emittable (ADR-0014 decision 4). One is canonical; the other two are derived from it in the sense that they carry the same message and are produced by the same writer, and that where they disagree with the canonical encoding about what this toolchain can read back, the canonical encoding is the one that holds.
| Encoding | Flag value | Extension | Status | Use |
|---|---|---|---|---|
| canonical protobuf JSON | ir-json | .json | canonical | interchange, baselines, goldens, a plugin's request |
| protobuf binary | ir-binary | .binpb | derived | a compact form for a consumer that wants one (§5) |
| prototext | ir-text | .txtpb | derived | inspection (§5) |
Canonical protobuf JSON is the canonical encoding. It is the protobuf JSON mapping applied to the schema of record, as §3 fixes it. Two facts decided this, both measured on 2026-09-22 and recorded in the design note:
- It round-trips every IR this toolchain's front end admits. The binary encoding does not: its reader stops at 100 message levels below the root, which legal source reaches (§5).
- Every consumer named by any record of this repository — Rust, Kotlin,
TypeScript, and the Deno, Python and JVM plugin hosts ADR-0020 decision 10
lists — parses JSON with its standard library, so reading the IR needs no
protobuf toolchain. A typed reader exists where one is wanted: protobuf-java's
JsonFormat, protobuf-es and protobuf-python'sjson_formatall implement the same mapping.
The cost is size and speed: on the two veh-cluster corpus packages, binary is
6 to 11 times smaller and 5 to 9 times faster than JSON, and the largest of them
is 55 kB of JSON, 64 µs to write and 239 µs to read. No path in this toolchain
carries that cost measurably — a build writes each artifact once, and a plugin
process costs a millisecond to start before it reads anything.
3. What Canonical Fixes
A canonical artifact is byte-identical for equal IR across runs, hosts and platforms. That claim rests on six properties. Each is part of the contract, and a change to any of them is a breaking change under §6.
- The mapping. The protobuf JSON mapping over the schema of record: field
keys in
lowerCamelCaseas the mapping derives them from the.protofield names (json_nameis never set), enum values as their names, aoneofflattened to its member's key, 64-bit integers (int64,uint64) as decimal strings (ADR-0014 decision 8),bytesas base64, andstringas UTF-8 with only the escapes RFC 8259 requires. The schema declares nofloatand nodoublefield — every numeric value in the IR is an exact decimal string (ADR-0007 decision 9) — so no float formatting rule is needed and none is stated. - Presence. A non-
optionalfield holding its default is emitted; an unset proto3optionalfield, an unset message field and an unsetoneofproduce no key.nullnever appears (ADR-0014 decision 2). - Order. Fields appear in the order the
.protodeclares them within each message. A repeated field's elements appear in the order the writer holds them, which for every list in the IR is source order or an order the schema's own comments name per field. The schema declares nomap<>field; adding one is a breaking change (§6), because a map's key order would need a rule this list does not have. - Layout. Pretty-printed: two-space indentation, one field per line,
\nline endings on every host, no trailing newline, and no byte-order mark. - No host input. Nothing in the writer reads the locale, the clock, an environment variable, a hash seed or the platform's line ending.
- One form, not two. The bytes a plugin reads on its standard input are the bytes in the file: there is no compact variant for inter-process transport and no second dialect. A plugin's test fixture is therefore a file this toolchain wrote.
The reader side carries two different obligations, because the two readers are in different positions.
- This toolchain's own reader is strict. It refuses an unknown key, an
out-of-range numeric enum value,
nullfor a repeated field, a duplicate key and a float-form integer (ADR-0014 decisions 11 and 14). Its input is its own output or a committed baseline, and the strictness is what makes the conformance claim measurable. The one exception is the codegen request reader,ridl_ir::codegen::request_from_json, which ignores an unknown key so that a plugin keeps reading the requests of a laterridl(ADR-0020 decision 9). It rejects an unknown enum name, as the strict reader does, so a plugin built on an olderridl-irrejects a request that carries a newer enum value and must be rebuilt against theridl-irthat adds it. - A consumer's reader is lenient on unknown keys. A key it does not know is ignored, an absent key is the field's default, and a 64-bit value is parsed from its string form. This is proto3's rule for unknown fields, and it is what the additive changes of §6 rely on. A consumer may refuse a request whose schema package it does not know (§7); it must not refuse one that carries a key it does not know.
4. The Nesting Bound
The IR is a tree, and a type nests. A reader has to provision for the deepest tree this toolchain can write, so that number is stated rather than left to be discovered.
| Quantity | Value |
|---|---|
| type nesting levels the front end admits | 127 |
message levels below the Package root, deepest admitted IR | 386 |
| JSON levels of its canonical artifact | 516 |
| JSON levels this toolchain's own reader accepts | 1,000 |
The front end refuses type nesting at 128 levels with FORM-102
(MAX_TYPE_DEPTH, crates/ridl-syntax/src/parser.rs), so 127 is the deepest
that compiles. The other two numbers are the per-level cost of the schema
multiplied by that limit, for the most expensive shape the language has. The
cost per source level is two message levels for an array (FieldType,
ArrayType), two for a map (FieldType, MapType) and three for a tuple
(FieldType, TupleType, TupleField); in JSON a level costs two brackets for
an array or a map and four for a tuple. The tuple is therefore the shape that
binds, at 386 message levels and 516 JSON levels; the array shape at the same
source depth reaches 259 and 262. A contract expression is bounded by the same
limit — the front end builds an expression tree at most 128 nodes high, and each
binary operator, member access, group and prefix raises that height by one level
— but it reaches the IR as canonical text in Contract.source, so it adds no
message level.
A reader of the canonical encoding must accept at least 516 JSON levels. A reader built on a schema-typed protobuf JSON parser must in addition accept at least 386 message levels. Provisioning 1,000 is recommended, because that is this toolchain's own figure and it makes the two readers equal.
The obligation is real in more than one language. serde_json stops at 128 JSON
levels by default, protobuf-java at 100 message levels, Jackson at 1,000, and
JavaScript's JSON.parse has no limit of its own. A reader in the first two has
one configuration step to make before it can read what this toolchain legally
writes.
The three numbers move together, and only with an edit to MAX_TYPE_DEPTH or to
the schema's per-level cost. Either edit restates this section and moves the
test that pins it (crates/ridlc/tests/ir_canonical.rs).
5. The Derived Encodings
Binary. --emit ir-binary writes <base>.ir.binpb and
<pkg.Name>.system.binpb. Its writer is unrestricted, and its reader is not:
a binary artifact is readable by this toolchain up to 100 message levels below
the root, and one message level past that the decode fails with a recursion
error. The limit is prost's RECURSION_LIMIT, decremented once per nested
message on decode and not consulted on encode; protobuf-java and the C++ runtime
default to the same 100, so it is not one implementation's quirk.
That bound is below what the front end admits. A struct field nesting 48 arrays,
47 maps or 32 tuples — all legal source, which the checker accepts and every
backend emits — produces an artifact to_binary writes correctly and
from_binary refuses (driftsys/ridl#231). The emit does not refuse to write
such a package: the bytes are a correct encoding of it, and a consumer that
raises its own limit reads them. This asymmetry is why binary is derived and not
canonical.
ridl diff and ridl check --baseline read .ir.json only and refuse the
other two encodings by name (ADR-0014 decision 5). A committed baseline has to
be reviewable in a pull request.
Prototext. --emit ir-text writes <base>.ir.txtpb. It is for inspection.
It is not a recommended interchange form, because TypeScript — a named target —
has no usable text-format parser. This toolchain writes prototext and does not
read it on any command path.
6. The Compatibility Rule
Amended 2026-10-11 by ADR-0029. The codegen protocol messages of §6 and §7 are a frozen surface from 0.10.0, checked by
just freeze-check.
Within one schema package, a change is additive when a reader built against the schema before the change reads the writer's output after it and sees the meaning it saw before, and a reader built after the change reads output from before it and sees the field's default where the writer wrote nothing. An additive change is made in place. A new enum value is also made in place, with the limit its row states.
| Change | Rule |
|---|---|
| a new field, with a number never used in that message | additive, if its default carries the meaning the old writer implied by omitting it; a new field a reader cannot ignore is breaking |
a new oneof member | additive on the same condition; an old reader sees the oneof unset |
| a new enum value | made in place, but not additive for a reader that rejects an unknown enum name, as every ridl_ir reader does (§3): built on an older schema, that reader rejects an input that carries the new value and must be rebuilt against the schema that adds it. The IR's enums are closed and the checker never writes a value outside the schema |
| a new message, reached only through a new field | additive |
a comment, a doc string, a deprecated option | additive |
| retiring a field's meaning | additive only as [deprecated = true] with the field kept, still written and still read; the field itself leaves in the next major package (§7) |
Everything else is breaking, and is never made in place:
- removing a field, an enum value or a
oneofmember; - renaming a field. In the canonical encoding the key is the field's name
through the JSON mapping, so a rename changes the bytes even though the wire
number does not move. The
json_nameoption is not an escape from this, because it would make the schema and the bytes disagree; - renumbering a field or an enum value;
- changing a field's type, its cardinality (
repeatedor not) or its presence (optionalor not); - moving a field into or out of a
oneof; - changing the meaning of an existing value, of a default, or of the order of a repeated field;
- adding a
map<>field (§3 item 3); - changing any item of §3, or lowering any number of §4.
The rename entry is the one that differs from protobuf's own guidance, which reasons about the binary wire format where a rename is invisible. The canonical encoding is the contract here, and in it a rename is visible.
7. Versioning
A breaking change is a new schema package, never an edit to the old one.
A breaking change to ridl.ir.v2 is made by adding ridl.ir.v3 as
crates/ridl-ir/proto/ridl/ir/v3/{ir,system}.proto, compiled beside v2, with
this toolchain's writers and readers moved onto it. v2 is not edited for it,
keeps compiling, and is removed when its last consumer has moved — which is how
v1 was removed. The same rule governs ridl.codegen.v1, whose successor is
ridl.codegen.v2.
The artifact file names do not change with the package. <base>.ir.json stays
<base>.ir.json: the name drives snapshot detection and is cited across the
records, and the schema a reader is built against is what tells the versions
apart.
The IR artifacts carry no version field of their own. The proto3 JSON mapping
has no envelope, ridl diff reads a baseline with the toolchain that wrote it
or one of the same major version, and adding a field to Package would move
every golden for a fact the file name and the reader's schema already fix.
A codegen request is different, because the process that reads it is not this toolchain and may be older or newer than it. It carries the version as its first two fields, so that a reader meets them before anything it might not understand:
string schema = 1; // "ridl.codegen.v1" — the package this request is encoded from
string toolchain = 2; // the ridlc version that wrote it, for example "0.2.0"
A consumer refuses a schema it does not know, with a diagnostic naming both
values. It does not refuse on toolchain, which is informational: a consumer
that needs a fact only a later toolchain writes finds the field absent and
reports that, under the lenient-reader rule of §3.
8. Reading the IR from Another Language
For an implementer outside this repository, the whole obligation is:
- Parse the artifact, or the request on standard input, as JSON. Any parser will do that accepts at least 516 levels of nesting (1,000 recommended, §4), ignores keys it does not know, and reads 64-bit integers from their decimal string form (§3).
- Read the
schemafield first on a request, and refuse a package you do not know (§7). - Expect new keys over time and ignore them; expect no key to be removed or renamed within a schema package (§6).
- Use a file this toolchain wrote as a fixture. The bytes on the pipe and the bytes in the file are the same (§3 item 6).
9. Conformance
Three claims of this document are measured by the test suite rather than asserted here.
| Claim | Where |
|---|---|
| every IR the front end admits round-trips through the canonical encoding, and writing it twice gives the same bytes | crates/ridlc/tests/ir_canonical.rs, over every corpus package and every lowered system |
| the numbers of §4 | the same file, over the deepest package the front end admits, in all three shapes |
| the binary bound of §5 | crates/ridl-ir/src/lib.rs, at 100 message levels and at 101 |
The strictness of this toolchain's reader (§3) is pinned separately, one test
per narrowing, in crates/ridl-ir/src/lib.rs.
There is no cross-language conformance test. The strongest available evidence would be a second implementation reading an artifact this one wrote; it does not exist yet, and this document is written so that it can be built.