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

LanguageExpands toDescribesAudience
typltype languagedata — types, ranges, units, constantsdata architects
ridlreactive interface description languageinteractions at every boundary — system, person, world (signal / event / command / query / fixed)service, HMI, and sensor/actuator teams
rmdlreactive model description languagebehaviour — synchronous / functional computecontrol / algorithm engineers
rsdlreactive system description languagearchitecture — components, wiring, deploymentintegrators

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 diff at 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 of require / ensure clauses), 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 in docs/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

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.

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

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.

LanguageDescribesStatus in this repository
typldata — types, ranges, units, constantsbuilt
ridlsystem interactions — signal, event, command, query, fixedbuilt
rxdldomain spellings — present, notify, measure, detect, actuatespecified, not built
rmdlbehaviour — functions and reactive modelsspecified, not built
rsdlarchitecture — components, wiring, deploymentbuilt

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:

VariableEffect
RIDL_INSTALL_DIRDirectory to install into. Default ~/.local/bin (%LOCALAPPDATA%\Programs\ridl on Windows).
RIDL_INSTALL_BINARYridl (default) or ridlc.
RIDL_VERSIONThe 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 the veh.cluster package.
  • import veh.common.Speed — Speed is 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 a uint8 on transport; integer [0..65535] is a uint16. The generated language type is always int64. 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: false for booleans, 0 for a number whose range contains it and the range minimum otherwise, and the empty value when a string or bytes bound allows length 0. It cannot be derived for a string or bytes type carrying a match pattern or a non-zero minimum length, so those declare one — which is why ModelCode, Notes and Vin above do. Without a declared init such a type draws a TYPL-115 note. That note stays informational, always; what turns a missing init into an error is using the type as a signal payload, and the error is RIDL-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:

BoundRequired on a signal (state)Required on an event (occurrence)
min — rate floordebounce — coalesce a faster updatethrottle — do not raise occurrences faster
max — staleness boundrefresh ceiling — re-publish even if unchangedTTL — 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:

--emitOutputContents
rust<package>.rsidiomatic Rust source (the default)
ir-json<package>.ir.jsonthe lowered IR v2 as exact-decimal JSON
ir-text<package>.ir.txtpbthe lowered IR v2 as prototext
ir-binary<package>.ir.binpbthe lowered IR v2 as protobuf binary
typescript<package>.tsidiomatic TypeScript source, and <package>.codec.ts under codec = true for a package with a payload root that gets a codec
proto<package>.protothe proto3 schema — types and the ordinals
flatbuffers<package>.fbsthe FlatBuffers schema — types and the ordinals
podbuf<package>.hthe podbuf C header — the head structs and the accessors a C consumer reads a verified buffer through
codegen-model<package>.codegen.jsonthe lowered codegen model (ridl.codegen.v1) as canonical protobuf JSON
codegen-request<package>.codegen-request.jsonthe whole codegen request a build hands a backend, as canonical protobuf JSON
catalog<unit>.catalog.binfbthe 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

CommandWhat it does
ridl checktype-check a file, package, or workspace
ridl buildcompile to the artifacts above
ridl fmtrewrite .typl, .ridl and .rsdl files into one canonical form; --check reports without writing
ridl baselinepublish the current workspace as .ridl/baseline/<package>.ir.json snapshots and <unit>.catalogs files
ridl diffcompare two IR snapshots or source trees and classify the change
ridl lockallocate interface numbers in each unit's interfaces.lock; --rename and --retire record a renamed or removed interface
ridl testrun 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

ConstructConventionExample
type, struct, enum, enumset, unionCamelCaseVehicleSpeed, WarningFlags
InterfacesCamelCaseVehicleStatus
Interactions, fields, tuple fields, parameterscamelCasecurrentSpeed
Enum values, enumset bits, constantsSCREAMING_SNAKEPARK, MAX_SPEED
Packages and service nameslowercase.dotveh.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


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
}
  • requires names 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.
  • instances names the copies. A component that declares none has one instance, written Unit in the IR and in diagnostics.
  • external marks a component with no implementation in this workspace. It is still part of the closure, and its links are still derived.
  • ClimateControl has two instances, so it is a redundant provider set. Dashboard's and PhoneApp's requires Climate each 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 Rig does with ClimateControl. ClimateControl.front places one.
  • An external machine hosts external components only (RSDL-707). On Rig, PhoneApp is placed on an on-board machine: a stub standing in for the phone.
  • linux.cpuset is a backend key, carried uninterpreted on Dashboard's placement. No configured backend claims the namespace linux, so it draws RSDL-804, which the fence allows.
  • slots is a sizing key, written on a placement line like Dashboard's. It sets the table entries of every command or query channel that Dashboard consumes, in Car only. depth and budget are the other two, and a placement line and a deployment declaration 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 offers and requires lines, and their external flag.
  • The producers — for each service, the component that offers it and that component's instances. veh.climate.control is marked not yet realizable, because ClimateControl has more than one instance.
  • The grants — per component, the catalog regions its requirements reach. PhoneApp reaches veh.climate, and its grant is lowered with the external flag: it is derived here, and enforcing it is the external side's business. SeatHeating requires nothing, so its grant is empty.
  • The region map — one region per catalog the closure reaches, here veh.climate, holding Climate, Seats and veh.climate.diag with the number each takes from the unit's interfaces.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 as Climate for an interface of the unit's root package or cluster.Speed for one of a subpackage. An inline shape keeps its global name, so veh.climate.diag appears 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 catalog writes 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.Unit is same machine, ClimateControl.rear → SeatHeating.Unit is different machine, and every link from PhoneApp is off-board. In Bench every link is same machine, because everything sits on Rig.
  • 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: the external flag and not the machine defines the boundary, so Bench keeps them although they cross nothing.
  • The installation of each distribution — in Car, Comfort on ZoneFront and ZoneRear, Display on Head.

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 optional requirement 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.toml with a [package] table, not a [workspace] table, whose name equals the [imports] key. Anything else is MANI-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 is MANI-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 build generates code for it beside your own packages, and ridl fmt never changes it. No catalog, baseline snapshot or ridl lock entry 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:

  1. vendor/<key>/ at the manifest root, when that directory exists and ridl.lock pins a tree for the URL.
  2. The cache, ~/.ridl/cache, for the hash that ridl.lock pins.
  3. 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:

CommandsRead from vendor/, then the cacheFetch on a missWrite ridl.lock
ridl check, ridl buildyesyesyes
ridl check --frozen, ridl build --frozenyesnono
ridl baselineyesyesyes
ridl vendorcache only, never vendor/yesyes
ridl vendor --checkvendor/ onlynono
ridl test, ridl lock, ridl diff, ridl lsp, ridl mcpyesnono

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>"
  • sha256 pins the bytes of the downloaded archive. A fetch whose content hashes to another value is MANI-102, and the import is dropped.
  • tree pins 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 is MANI-107. A tree that holds a symbolic link or another file that is not a regular file is also MANI-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 members entry that is vendor or a path under it is MANI-017, an error, and the entry is not loaded.
  • In a standalone package, a .typl, .ridl or .rsdl file under vendor/ that is not inside a directory named by an [imports] key is MANI-018, a warning with the lint name vendor-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 vendor deeper in a package is an ordinary source package. A remote unit's own vendor/ 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 check and ridl vendor sections 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_unchecked skips the check and is for a value already known to satisfy them. get returns 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 is encode, verify, decode and a MAX_SIZE bound; with no list the module holds the types only. Each payload type also has a <T as Payload<FlatBuffers>>::Buffer type and a zeroed BUFFER value, sized by that type's own MAX_SIZE, and the same in Proto3. Every MAX_SIZE is finite: a declared type whose encoding has no finite bound, such as a string with no length bound, is refused in either encoding, and ridl build fails 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 implements ridl_rt::contract::Interface and carries the interface's CATALOG, NUMBER and NAME. Each interface also implements ridl_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:

ItemPresent when the interface carriesUsed by
Clientany interactionthe consumer: reads, subscriptions, calls
Publishera signal or an eventthe producer: publishes values, raises events
Provider (trait)a command or a querythe provider: the application implements it
servea command or a querythe provider: settles the calls waiting on a port
Event (enum)an eventthe consumer: one variant per event
blocking (module)an event, a command or a querya 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:

FeatureEnables
stdThe 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-patternThe 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 featureDefaultEnables
flatbuffersoffThe flatbuffers module the generated codec calls.
stdoffThe 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.
proto3offThe proto3 module the generated proto3 codec calls.
podbufoffThe 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. A require clause that is false is reported here, as SendError::Contract(PreconditionFailed), because the client evaluates the require clauses before it sends anything.
  • ClientError::Call — the call was sent, and its outcome is a failure. CallError::Contract is a contract error the provider side reported: InvalidValue (the payload breaks its typl constraints), PreconditionFailed, ContractBroken (an ensure clause is false) or UnknownInteraction (the peers disagree on an interface number or an ordinal). CallError::Transport is an infrastructure failure: Timeout, Undelivered, Down, Corrupt or Busy.
  • 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:

PartRustTypeScript
doc text/// lines; each link an intra-doc linkthe comment's text; each link {@link Name}
rulesa # Rules section, one bullet per rule@remarks, then a Rules: list
typl tagsnone@unit, @range, @bounds
@seea # See also section, one bullet eachone @see tag each
@sinceone Available since <version>. line eachone @since tag each
@deprecatedthe #[deprecated] attributeone @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:

RuleRustTypeScript
rangechecked by new, TryFrom and verifynot checked
stepchecked by new, TryFrom and verifynot checked
unitnot checkednot checked
lengthnew, TryFrom, verify; encode for anonymous fieldsnot checked
patternchecked by new, TryFrom and verify, under a featurenot checked
collection boundchecked by encode and verifynot checked
timingnot checkednot checked
response boundmax checked by the future of a Client callnot checked
errorsnot checkednot checked
requirechecked by the call methods of Client and by servenot checked
ensurechecked by servenot checked

In the Rust column:

  • new and TryFrom are the constructors of a named scalar. verify is the FlatBuffers codec's check of a received payload. It also checks a field whose type is written inline, which has no constructor. encode also 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 by verify.
  • The pattern is checked only when the crate is built with the validate-pattern feature (see Cargo features).
  • A call's future, in the async and in the blocking Client, resolves to a failure when the response bound's max passes (see Failures). Its min is not checked.
  • A query that declares an error type gets no face, so no generated item carries the errors rule.
  • A Client call method evaluates the require clauses before it sends. serve evaluates them before it calls the Provider, and evaluates the ensure clauses on the reply before it sends the reply. The Client does not evaluate ensure.
  • verify and the face exist only when the manifest names flatbuffers or proto3 under [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

TraitRoleUsed by
Attachednames the catalog the port servesthe face, when it binds; a supertrait of every interaction port
Encodedstates the encoding of the bytes the port carriesthe face: every port a face is bound to; a conformance fixture need not
Clockgives the time envelopes are stamped withthe runtime; a Client with calls, to measure a call's bound
SignalReaderreads a signal's current valuethe consumer: Client
SignalWriterstages signal changes and publishes them with committhe producer: Publisher
EventSourcesubscribes to events and takes the next occurrencethe consumer: Client
EventSinkraises an occurrencethe producer: Publisher
Callersends a command or a query and reads its outcomethe consumer: Client
Handlerserves members, takes each call, and settles itthe provider: serve
FixedReaderreads a provisioned fixed valuethe application, directly; the face has no method over it
ScannableSignalswalks the signals that changed since a mark (extension)a consumer that scans a store; no generated face calls it
CoherentSignalsreads several signals from one publication (extension)a consumer that needs one coherent set; no generated face calls it
Wakeablewakes 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:

  1. How a consumer reaches a provider, what opens and ends the session, and what an ended session is on that transport, so that Detached and Transport::Down have a cause.
  2. 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.
  3. Which payload encoding the path carries, as an encoding tag.
  4. 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.
  5. What the provider derives a caller's identity from for duplicate suppression, and whether that identity survives a reconnect.
  6. How long a settled response is kept for a call that has no response bound.
  7. Whether the frames of one commit cross as one unit, and so whether a runtime over the binding presents CoherentSignals.
  8. 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.
  9. 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 — the Factory trait, the suite! 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; the hash, which identifies this version of the unit (see below); toolchain, the version of ridl that wrote the file; the schema version; the interfaces; the retired interfaces, each with its name and number, so a reader can refuse a peer that still uses one; and compatible, 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 (Cabin for an interface of the root source package, cluster.Speed for one of the source package cluster below it); its number, which is one space per unit; and provisional, which is true while the number is not yet recorded in the unit's interfaces.lock, the one lock file beside the unit's ridl.toml. Cabin above is provisional because examples/cabin has no lock file; a number becomes permanent when ridl lock records it. reserved_ordinals lists the ordinals that reserved tombstones hold. The ridl reference, section 11, is the rule for the names and the numbers.
  • Each member: its name, its ordinal (its position in the interface body), its kind, its payloads, its timing when the member declares one, and its limit, the payload limit in bytes that a [limits] entry of the unit's ridl.toml gives it, absent when no entry applies.
  • Each payload: its role (value for a signal or a fixed, occurrence for an event, request for a command or a query, response for a query's reply), its type_name, and one max_sizes row per encoding. A row is Bounded, with bytes the maximum encoded size of the payload, envelope and framing excluded; or Unbounded, with bytes 0 and a cause that says why the compiler cannot bound it in that encoding. For example, a FlatBuffers row is Unbounded when 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 inline T | E reply have no row in any encoding. A podbuf row is present for a payload with a podbuf root, sized by ridl_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's interfaces.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.

  1. Record the numbers. ridl lock gives every interface that has no entry the next free number and writes interfaces.lock. Until it runs, an interface has a provisional number, which is no identity: ridl baseline refuses to publish a provisional number (RIDL-411).
  2. Publish a baseline. ridl baseline writes one <package>.ir.json snapshot per source package, and one <unit>.catalogs file per unit that has an interface, under .ridl/baseline/ at the workspace root. Commit the directory: it is the record of what was released.
  3. Change the source, then compare. ridl diff compares the published baseline with the working tree and prints a verdict. ridl check also reads .ridl/baseline/ when it exists, and warns (RIDL-407) on a change that moves an interaction's position.
  4. Publish again when the release is ready. ridl baseline replaces the snapshots and extends the <unit>.catalogs file.

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, and ridl diff reports interface_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 word retired, and ridl diff reports interface_retired. Without the retirement, the build fails with RIDL-409 and ridl baseline publishes nothing.
  • To remove an interaction, replace it with a reserved line that holds its position. A bare deletion is breaking, and ridl baseline refuses 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 changeVerdictThe catalog hashThe list in the descriptor and in the history fileAt attach, by §6.1
Noneidenticalunchangedunchangedaccepted: the hash is the provider's own
A doc comment, a label or deprecated changescompatibleunchangedunchangedaccepted: the hash is the provider's own
A declaration is added that no interface reachescompatibleunchangedunchangedaccepted: the hash is the provider's own
A compatible change to an interface, or to a declaration that an interface reachescompatiblechangesthe earlier hashes stay, and the previous one joinsaccepted: the earlier hash is in the list
A breaking change to an interface, or to a declaration that an interface reachesbreakingchangesthe descriptor's list is empty; the history file restarts with the new hash alonerefused with catalog_mismatch: the earlier hash is not listed
A breaking change to a declaration of the unit that no interface reachesbreakingunchangedunchanged: the hash did not changeaccepted: 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 a reserved line 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 by ridl 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 follows ridl diff --encoding podbuf: breaking with it, compatible without it, under the heading breaking under podbuf either 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 (u8 to u16), a value above 65535 or a flag at bit 16 (u16 to u32), or a value above 4294967295 or a flag at bit 32 (u32 to u64). 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 breaking decl_added and draws no width line, so the integer turns signed only when an enum with no value gains values (-1 gives u8 to i8). The width change is reported once per declaration as podbuf_width_change with 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 podbuf 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). Also breaking under podbuf: an enum value or an enum-set flag appended. 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. It is reported once per declaration as podbuf_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 the podbuf_width_change line. A field removed from a struct, its slot held by a tombstone, is decl_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.json snapshot;
  • the baseline holds no package of the unit;
  • the unit has no <unit>.catalogs file;
  • 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

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, and ridl fmt does not rewrite one form into the other. A project that wants only /// turns on the doc-comment-style lint (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:

CarrierMust have a doc
type, const, struct, enum, enumset, unionyes, unless internal
interface, serviceyes, unless internal
struct field, enum value, enumset bit, union armyes, unless its parent is internal
signal, event, command, query, fixedyes, unless its parent is internal
a parameter of a command or a queryno
a reserved entryno
rsdl system, component, distribution, deployment, machineyes
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.

A name in square brackets is a link to a declaration or a member. Three forms are links:

FormExampleText 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 Name is 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), then ridl.std.
  • A qualified pkg.Name reaches 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 an import of 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 draws broken-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 internal in another package cannot be linked to, because documentation of the other package could not show it. A link to an internal declaration 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:

TagValueChecked
@seeone name, with an optional member, as a link takesresolved like a link
@sincea version: MAJOR.MINOR or MAJOR.MINOR.PATCHits form only
@deprecateda reason in double quotesa missing reason draws deprecated-without-reason
@labelscomma-separated SCREAMING_SNAKE labelspassed 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.

LintCodeDefaultRaised when
broken-doc-linkTYPL-401warna link or @see target does not resolve, or is internal in another package
detached-doc-commentTYPL-404warna blank line separates a doc comment from its carrier
deprecated-without-reasonTYPL-405warn@deprecated has no reason
missing-docsTYPL-406warna carrier that must have a doc has none
misplaced-doc-commentTYPL-407warna doc comment is in a position that is not a carrier
unknown-doc-tagTYPL-408warna tag other than @see, @since, @deprecated and @labels
malformed-doc-tagTYPL-409warn@see or @since has a missing or malformed value
doc-comment-styleTYPL-410allowa 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"
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 @since versions, 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 a signal or event, the response bound of a command or query, the error type of a fallible query, and each require and ensure clause. 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 @see targets in .typl, .ridl and .rsdl files. Renaming a type updates every link and @see that names it, in every member of the workspace.
  • Quick fixes: missing-docs inserts a /// line, and doc-comment-style rewrites 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:

  1. The IR specification §7 and §8: read schema first, parse leniently, and use a file ridlc wrote as a fixture.
  2. plugin.proto: the request and the response.
  3. model.proto: what the model carries, message by message.
  4. deployment.proto: the deployment section, if your plugin lays out memory for a deployment.
  5. crates/ridlc-gen-model/src/main.rs: a complete plugin.
  6. The ridl build section of the CLI reference: how the plugin is found and what ridl does 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 kotlin looks for ridlc-gen-kotlin on PATH, in PATH order. On Windows, ridlc-gen-kotlin.exe is also tried.
  • --plugin kotlin=/opt/gen/bin/kt-gen runs the executable at that path and skips the lookup. Messages still name the plugin ridlc-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:

CaseExit
a --plugin value that does not parse, such as =/x or a/b2, before anything is compiled
no PATH directory holds the executable1, and the build writes no file
the executable cannot be started, exits non-zero, or is still running at the timeout1
standard output is not a CodegenResponse1
a file path in the response breaks the path rule: it is absolute, or holds .. or \, for example1, and no file of that response is written
the response carries an error-severity diagnostic1, and no file of that response is written
--deployment NAME names no deployment the system declares2, 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 oneof member can appear in ridl.codegen.v1 at any release. A plugin whose request reader is built on an older ridl-ir still rejects a request that carries a new enum value, so that plugin must be rebuilt against the ridl-ir that 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 ridl still reads. ridl_ir::codegen::request_from_json does this at every nesting level of the request, from ridl-ir 0.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 earlier ridl-ir rejects a request that carries a key that release does not know, and must be rebuilt once.
  • A plugin refuses an unknown schema. schema is 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 on toolchain, which only says which version of ridl wrote 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": ""
}
  • model is 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 that ridl build --emit codegen-model writes to <base>.codegen.json, one indentation level deeper, so that file is a fixture for a plugin's tests.
  • options is a list of {key, value} pairs sorted by key. It holds one encodings option, 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.
  • artifactBase is the name ridl gives 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.
  • generatedMarker is the line that marks a generated file as generated, without a comment token. The host composes it. See the marker below.
  • header is 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": "".
  • deployment is 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 of doc, in source order. Each is a DocLink: text is the link text as written, offset and len are the link's byte position in doc, and target is the item it names. Replace the len bytes at each offset with the plugin's link syntax, from the last link to the first, so that each earlier offset stays valid.
  • see — the @see targets, in source order, as DocLinks whose offset and len are 0.
  • since — the @since versions, in source order.
  • rules — the rules of the item. A Rule has a text, the line the editor's hover shows for it, and a kind, a oneof of the structured fields: range, step, unit, length, pattern, collection_bound, timing, response_bound, errors or contract (a require or an ensure clause). rules is filled on a Declaration, an Interaction, a Field and a Param, 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>:

SourceMeaning
DERIVEDcomputed by the toolchain: an event's depth is ceil(max / min) over its resolved timing
DECLAREDwritten in the rsdl source, on the placement line of the consuming instance or on the deployment
DEFAULTno value was declared and the toolchain applied its default: 16 slots for a command or a query
UNDERIVABLEno 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

  • podbuf on a consumer link. Every payload has a podbuf size state and the model carries the podbuf layout (see "The podbuf layout" above), but no consumer link carries ENCODING_PODBUF: no rsdl fact selects it.
  • Members whose depth or size cannot be derived. An event's depth is absent with the source UNDERIVABLE when its timing gives no bound. The build warns with RSDL-806 when a consumer link consumes such an event with no declared depth (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 inline T | E reply have no size in any encoding, so the reservation of such a member is unsized. 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.rs is a complete plugin in one file of under 80 lines, its module comment included. It reads the request, refuses an unknown schema, writes the model back as one file, and writes the response.
  • crates/ridlc/tests/layout.rs holds a test plugin that reads the deployment section. It computes the byte layout of each region of the examples/cabin deployment 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 same Backend trait the in-tree backends implement.
  • driftsys/ridlc-gen-kotlin is a plugin outside this repository, written in Kotlin. It runs through ridl build --plugin kotlin and 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 kotlin runs ridlc-gen-kotlin, found by walking PATH in order and taking the first directory that holds a file of that name (on Windows, ridlc-gen-kotlin.exe is also tried). No directory holding it is an error naming ridlc-gen-kotlin, and — like a compile error — a build that writes nothing.
  • --plugin kotlin=/opt/gen/bin/kt-gen runs the executable at that path and skips the lookup. ridl still reports it as ridlc-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=NEW rewrites the entry OLD to hold the key NEW in place, keeping its number, when NEW is a declaration without an entry — the same interface under a new name. Printed as renamed Old New N.
  • --retire NAME marks the entry retired, keeping its line and its number, when nothing declares NAME any more. Printed as retired 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_check checks workspace files or a source text.
  • ridl_explain explains a diagnostic code or diff category.
  • ridl_resolve resolves a declaration and its location.
  • ridl_describe_type describes a non-interface declaration.
  • ridl_list_interactions lists an interface's interactions.
  • ridl_references lists declarations and interactions using a declaration.
  • ridl_dependencies lists package dependencies and dependents.
  • ridl_metrics reports package coupling and interface cohesion groups.
  • ridl_diff compares 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:

  1. It takes the union of the [imports] of the root manifest and of every member.
  2. It materializes each unit as ridl check does, from the cache or by a fetch, and rewrites ridl.lock. It reads the cache and never vendor/, so a URL that changed is fetched and its directory replaced.
  3. 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 under vendor/ whose name is no [imports] key is removed, whether or not ridl vendor wrote it, so that, after a run, no directory other than a remote unit's remains among the non-hidden entries of vendor/; a file, a symbolic link and a hidden entry (a name that starts with .) are left alone. Nothing in vendor/ 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.

ExitMeaning
0vendor/ was written, or there was nothing to change
1a 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
2a 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 in vendor/;
  • MANI-110, a directory in vendor/ that no [imports] key names;
  • MANI-107, a vendored tree that does not match its tree hash;
  • MANI-103 or MANI-108, a unit that ridl.lock does not pin or pins with no tree, 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.

ExitMeaning
0vendor/ matches
1drift (MANI-107, MANI-109 or MANI-110), a unit that ridl.lock does not verify (MANI-103 or MANI-108), or another diagnostic error
2a 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

Command012
ridl check / ridlc checkclean (warnings included)a diagnostic is an errorthe 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 buildclean, every requested artifact writtena diagnostic is an error, nothing written — except for an RSDL-7xx error, which leaves only its deployment out of the lowered systemthe workspace cannot be found, or (for ridlc build) a missing --out-dir
ridl baselineclean, snapshot(s) publisheda 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 untouchedthe 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 testevery range self-corpus and sampled require passeda self-corpus failure, or a clause raised an evaluation errorthe workspace fails to compile, cannot be found, or --samples 0
ridl fmtnothing under --check would change, or the rewrite succeededa file under --check would change, or has a parse errorthe 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 diffthe change is compatible, or the two sides are identicalthe change is breakinga side fails to compile, an input is missing, or neither --explain nor both inputs were given
ridl lockthe file is written, or there is nothing to changea 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 errorthe 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 mergethe three sides merge clean, and the result is written to OURSentries disagree: OURS is written with conflict markers around only the disagreeing entries, and is malformed (RIDL-410) until resolvedan 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 newthe files are writtena 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 describethe descriptor was printeda 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 vendorvendor/ was written, or there is nothing to change; with --check, vendor/ matches the imports and ridl.locka 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:

  • allow removes the diagnostic from the report.
  • info reports the diagnostic as an info.
  • warn reports the diagnostic as a warning.
  • deny reports the diagnostic as an error. In ridl check, ridl build, ridlc check and ridlc build, an error makes the command exit 1, so a lint at deny fails 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:

  1. the default level from the table below;
  2. the [lints] table of the workspace root manifest, when the package is a workspace member;
  3. 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 at deny;
  • ridlc check and ridlc 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, warn or deny, which includes a level with the wrong case ("Deny");
  • lints is not a table.

MANI-010 is itself a lint, so the table can set its level.

The lints

LintCodeDefaultSummary
unused-importTYPL-007warnunused import
unneeded-import-aliasTYPL-008warnimport alias without an actual collision
unbounded-integerTYPL-101warninteger without a range constraint
unbounded-floatTYPL-102warnfloat without both a range and a step
unbounded-lengthTYPL-103warnstring/bytes without explicit bounds
no-init-valueTYPL-115infotype has no derivable init value and no declared = value
bound-above-transferTYPL-116warnstring/bytes bound above the advised transfer limit
duplicate-reservedTYPL-211warnduplicate reserved entry
inconsistent-unitTYPL-222infoone field name used with different units
duplicate-shapeTYPL-224infoduplicate declaration shape
broken-doc-linkTYPL-401warndoc link or @see target that does not resolve
detached-doc-commentTYPL-404warnblank line between a doc comment and its carrier
deprecated-without-reasonTYPL-405warn@deprecated doc tag without a reason string
missing-docsTYPL-406warnitem without a doc comment
misplaced-doc-commentTYPL-407warndoc comment in a position that is not a carrier
unknown-doc-tagTYPL-408warndoc tag other than @see, @since, @deprecated and @labels
malformed-doc-tagTYPL-409warn@see or @since with a missing or malformed value
doc-comment-styleTYPL-410allowdoc comment written as /** */
missing-timingRIDL-100warnsignal or event without a timing annotation
degenerate-timing-rangeRIDL-108warndegenerate timing range @[X..X]
missing-response-boundRIDL-112warncommand or query takes the default response bound
payload-above-ceilingRIDL-150warnpayload above the payload ceiling or with no finite bound
podbuf-padding-orderRIDL-156infostruct field order wastes podbuf padding
error-typed-parameterRIDL-304warnerror-typed or result-union parameter on a command or query
ensure-without-resultRIDL-305warnensure clause that never references result
contract-error-name-in-enumRIDL-307warncontract-error category name declared in an error enum
named-result-union-in-queryRIDL-308warnnamed result union in query return position
query-named-like-mutationRIDL-404warnquery named like a mutation
shared-error-typeRIDL-405infoone error type shared across unrelated failure domains
redeclared-envelope-metadataRIDL-406infopayload struct re-declares envelope metadata
ordinal-changedRIDL-407warninteraction, struct field, or union arm ordinal changed against the published baseline
low-cohesion-interfaceRIDL-414infointerface members form disconnected type-sharing groups
lock-in-subdirectoryRIDL-416warninterfaces.lock in a subdirectory of a unit is not read
unsupported-source-fileRIDL-417warn.rxdl or .rmdl file is not compiled: its profile is not supported
redundant-provider-setRSDL-409warna requires resolves to a redundant provider set
unclaimed-backend-keyRSDL-804warna backend key whose namespace no configured backend claims
depth-below-boundRSDL-805warna declared depth is below ceil(max / min) for an event a covered link consumes
depth-underivableRSDL-806warnan event whose contract bound is not derivable is consumed by a link with no declared depth
link-encoding-not-emittedRSDL-807warna link of the selected deployment carries an encoding the emitted backend's encodings list does not name
unknown-manifest-keyMANI-005warnunknown manifest key
unknown-lintMANI-010warn[lints] entry names no lint, or its value is not a level
vendor-source-not-loadedMANI-018warna 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 of vendor/ (<manifest root>/vendor/<key>/), and the formats of interfaces.lock and .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 json and --format sarif output.
  • The generated Rust crate. The domain types and the interaction face (Client, Provider, Publisher, Event, serve and Bind).
  • The runtime crates. The public API of ridl-rt, ridl-rt-conformance, ridl-loopback, ridl-descriptor and ridl-transport-ws, and the names of their cargo features, podbuf among them.
  • The codegen plugin protocol. The messages ridl.ir.v2 and ridl.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 attach handshake, the inputs of the catalog hash, the emitted .proto and .fbs mapping, 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) and ridl-descriptor, with edition 2021 for the first four and edition 2024 for ridl-descriptor. The generated crate compiles as edition 2021 with Rust 1.88 with every cargo feature on and with default features off, and ridl build writes edition = "2021" and rust-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 allow or info level, 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 at warn, which does not fail ridl check either, 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-model and ridlc-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

  1. Scope and Position in the Family
  2. Lexical Conventions
  3. Packages, Imports, and Visibility
  4. Primitives
  5. Type Definitions
  6. Constants
  7. Structs
  8. Enums
  9. Enum Sets
  10. Unions
  11. Tuples
  12. Collections
  13. Comments
  14. Doc Comments
  15. Conventions
  16. Diagnostics
  17. Open Questions

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 .typlRejected in .typl (belongs to)
package, import, as, internalinterface, signal, event, command, query, fixed (ridl)
type, constinteraction declarations (ridl, and the rxdl spellings)
struct, enum, enumset, unionmodel, node, behaviour operators (rmdl)
tuples, collections, ? optionalityinstances, wiring, deployment (rsdl)
doc comments, @labelstiming 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:

FormPatternUsed for
CamelCasestarts uppercase, no separatorstypes, structs, enums, enumsets, unions
camelCasestarts lowercase, no separatorsfields, tuple fields
SCREAMING_SNAKEuppercase, underscore separatorsenum 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 the regex crate gives no meaning, such as \e, \Q or \k. An identity escape of a letter the crate does give a meaning, such as \a, \A, \z or \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 or a{
  • a pattern whose compiled form exceeds the regex crate's size limit. \w and \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 patternECMA-262, no u flag (the checker)The Rust regex crate
\d, \wASCII digits, ASCII word charactersUnicode digits, Unicode word characters
\p{L}the text p{L}a Unicode letter
\A, \zthe letters A and zthe start and the end of the text
\athe letter athe bell character, U+0007
\<the character <a start-of-word boundary
[a&&b]one of a, &, bthe 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).

PrimitiveMeaningConstraint
booleanlogical true/falsenone
integerwhole number — width inferred from rangerecommended — profile may require
floatreal number — width inferred from range and steprecommended — profile may require
stringsequence of Unicode scalar values — default [0..256] if unspecifiedrecommended — profile may require
bytesopaque binary buffer — default [0..256] if unspecifiedrecommended — 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:

RangeWire 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 rangeint64

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:

ConditionWire type
range + step declared, N ≤ 2²⁴, both bounds exactly representable in binary32float32
otherwise — no step, no range, N > 2²⁴, or bounds not representablefloat64

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 string must be UTF-8 and FlatBuffers string is UTF-8, so a UTF-16 wire form means emitting bytes and [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

SyntaxMeaning
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

SyntaxMeaning
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:

TypeDerived init
booleanfalse
numeric / unit type0 (or 0.0) if admitted by both range and step, else the lower bound when declared
string / bytesempty if the bounds admit length 0; not derivable otherwise
type with match patternnot derivable (a pattern-valid value cannot be synthesised) — declare a = value
enumthe value 0 if declared, else the lowest declared value
enumsetempty set
structeach field's init, recursively; optional fields absent
unionfirst arm's init
tupleeach field's init
arraymin-bound count of element inits (empty when min = 0)
mapempty 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-diff is the enforcement point. Ordinals are fully derivable from source — no sidecar state, no lockfile-assigned numbers. The CI plane (ridl-diff against 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 step that 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. So ridl-diff reports 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 diff resolves types against the new snapshot and the built-in ridl.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 = 0 for 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 diff compares enum values and enumset bits by the number each declares. A member whose number changed is reported as member_reordered with its old and new number, breaking. A body whose difference is only order — its values or bits reordered with no number changed, or an enum's reserved list reordered with no entry changed — is reported as enum_reordered, compatible: nothing moves on the wire. The catalog hash covers only the declarations an interface reaches; when an interface reaches the enum or enumset, the order of its values or bits, and of an enum's reserved list, 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.0 is 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 bitWire type
0..7uint8
8..15uint16
16..31uint32
32..63uint64

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 oneof in proto3, union in FlatBuffers, sealed class in Kotlin, Rust enum

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
}
  • error is valid on enum, struct, and union only (TYPL-212); an error 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::Error impl, 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

ConstructSyntaxBound
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.

TagValueValid on
@seeone qualified name, with an optional member; a linkall
@sincea version, MAJOR.MINOR or MAJOR.MINOR.PATCHall
@labelscomma-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

CarrierA doc is required (TYPL-406)
type, const, struct, enum, enumset, union, interface, servicewhen not internal
struct field, enum value, enumset bit, union arm, interactionwhen the declaration is not internal
parameter of a command or querynever
reserved entrynever
rsdl system, component, distribution, deployment, machinealways
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).

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

ConstructConventionExample
type, struct, enum, enumset, unionCamelCaseVehicleSpeed, WarningFlags
Fields, tuple fieldscamelCasecurrentSpeed
Enum values, enumset bits, constantsSCREAMING_SNAKEPARK, MAX_SPEED
Packageslowercase.dotveh.common

15.2 Separators

Newline and comma interchangeable in all block constructs; trailing comma permitted; no semicolons.

15.3 Type Usage

  • string and bytes must not be used directly as field types — always define a named type (the ridl profile grants streams an element-type exception; there is no exception inside typl)
  • All variable-size types declare explicit bounds
  • union arms 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)

CodeRuleSeverity
TYPL-001more than one package declaration in a fileerror
TYPL-002package name does not mirror the directory patherror
TYPL-003wildcard, relative, or re-exporting importerror
TYPL-004circular package importserror
TYPL-005a public declaration exposes an internal typeerror
TYPL-006conflicting imports without an aliaserror
TYPL-007unused importwarning
TYPL-008import alias without an actual collisionwarning
TYPL-009duplicate definition of the same name in a packageerror
TYPL-010package name is reserved for a package the compiler provideserror
TYPL-011type reference names no visible declaration (§3.2, §3.3)error

16.2 Scalars and Constants (TYPL-1xx)

CodeRuleSeverity
TYPL-101integer without a range constraintwarning; error if active profile requires
TYPL-102float without both a range and a stepwarning; error if active profile requires
TYPL-103string/bytes without explicit bounds — default [0..256] appliedwarning; error if active profile requires
TYPL-104range min > maxerror
TYPL-105step type mismatch, non-positive, or larger than the rangeerror
TYPL-106invalid regex syntax in match or a regex const — a valid pattern the Rust regex crate refuses is TYPL-220error
TYPL-107regex contradicts declared character boundwarning
TYPL-108const value violates its declared type constraintserror
TYPL-109init = value incompatible with the type or field constraintserror
TYPL-110unknown or malformed UCUM unit expressionerror
TYPL-111integer range bound outside the int64 domain ([-2⁶³..2⁶³−1])error
TYPL-112a concrete wire-width name (uint8 … float64) written in source — width is inferred, not written (§5.6)error
TYPL-115type has no derivable init value and no declared = value (§5.8)info — escalated by consumers that require an init
TYPL-116string/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)

CodeRuleSeverity
TYPL-201array without explicit boundserror
TYPL-202map without explicit boundserror
TYPL-203enum values not unique or not explicitly assignederror
TYPL-204union arm with a primitive typeerror
TYPL-205same tuple shape used in multiple placeswarning
TYPL-206recursive composite reference, direct or transitiveerror
TYPL-207enumset bit positions not uniqueerror
TYPL-208string/bytes used directly as a field typeerror
TYPL-209map key is not a named string type or a primitiveerror
TYPL-210field, arm, or enum value re-declared under a reserved entry (a name or a value)error
TYPL-211duplicate 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 builtwarning
TYPL-212error modifier on a declaration other than enum, struct, unionerror
TYPL-213union mixes error and non-error arms without the result-union shape (exactly one success + one error arm)error
TYPL-214error union contains a non-error-typed armerror
TYPL-215field name declared twice in one struct or one tupleerror
TYPL-216enum value name declared twice in one enumerror
TYPL-217union arm name declared twice in one unionerror
TYPL-218enumset bit name declared twice in one enumseterror
TYPL-219enumset bit position is not a number with an integer valueerror
TYPL-220regex pattern the Rust regex crate cannot compile (§2.7) — numbered in this range, but not a compositeerror
TYPL-222one field name used with different unitsinfo
TYPL-224duplicate declaration shapeinfo

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:

CodeRuleSeverity
TYPL-301stream 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-302timing annotation or duration literal in a typl contexterror
TYPL-303require/ensure attribute in a typl contexterror
TYPL-304interaction declaration in a typl context — one of the ridl interaction words at declaration start; an rmdl or rsdl word there is FORM-102error

16.5 Documentation (TYPL-4xx)

CodeRuleSeverity
TYPL-401doc link or @see target that does not resolve — the name is unknown, is internal in another package, or names no member of the declarationwarning
TYPL-402@labels identifier not recognised by active profileinfo
TYPL-403@labels combination invalid per active profileerror
TYPL-404blank line between a doc comment and its carrier — a declaration or a memberwarning
TYPL-405@deprecated doc tag without a reason stringwarning
TYPL-406item 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 missingwarning
TYPL-407doc 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 bodywarning
TYPL-408doc tag other than @see, @since, @deprecated and @labelswarning
TYPL-409@see or @since with a missing or malformed valuewarning
TYPL-410doc comment written as /** */ — allow by defaultwarning

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

#QuestionDisposition
17.1String-backed enumsdeferred to v0.2
17.2Exclusive range boundsresolved — not supported and not planned; the rule and its reason are §5.5
17.3uniqueItems for arraysdeferred to v0.2, as an array constraint; the set container is rejected
17.4Recursion policyresolved — the hard error stands; the rule and its reason are §7.3
17.5Unit conversion semanticsdeferred to the rmdl era
17.6Scientific notation in float literalsdeferred to v0.2
17.7Value-rule expressionsdeferred to the family expr core
17.8Standards-fixed invalid sentinel valuesdeferred to v0.2; the rest of the original question is resolved
17.9Byte orderresolved for typl — byte order is not a type property, §4.6; the deployment surface is rsdl's, at rsdl v0.2
17.10Canonical (deterministic) encodingresolved — not a type-layer property, §12.2; the frame specification §4 and each projection record own the rest
17.11Explicit wire-width floorsplit: the layout consequence is resolved in §5.6; the wire clause is deferred to v0.2
17.12Integer-backed unit typesdeferred to v0.2
17.13What a string is made of, and how it is encodedresolved — the rule is §4 and §4.4
17.14Value-aware enum and enum-set reorder comparisonresolved — the rule is §8 and §9
17.15A reorder in the same edit as an addition or a removaldeferred 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

StandardUsed for
Unicode 15.0source encoding
RFC 3629UTF-8
RFC 8259string escapes
ECMA-262regex literal syntax, restricted to the patterns the Rust regex crate also compiles (§2.7)
CommonMarkdoc comment markdown
IEEE 754-2019floating point
ISO/IEC 9899:2018 (C17)integer/float literal conventions
UCUMphysical unit expressions
ISO 8601 / RFC 3339time types
RFC 3986, RFC 4122Uri, Uuid
SemVer 2.0.0Version
ISO 3779, ISO 3166-1, ISO 639-1Vin, CountryCode, LanguageCode
JSON Schema 2020-12coverage 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).

CanonicalRustKotlinC++TypeScript
any integeri64Longint64_tbigint/number*
any floatf64Doubledoublenumber
enumseti64Longint64_tbigint

*TypeScript uses number when the range fits in 2^53, bigint otherwise.

Transport layer — narrowest that safely fits:

Canonicalproto3FlatBuffersSOME/IPCAN/DBCAIDL
uint8uint32*uint8UINT88 bitsint
uint16uint32*uint16UINT1616 bitsint
uint32uint32uint32UINT3232 bitsint
int32sint32int32SINT3232 bits signedint
int64sint64int64SINT6464 bits signedlong
float32floatfloat32FLOAT32 bitsfloat
float64doublefloat64FLOAT6464 bitsdouble

*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 typlfamily typl (this spec)Disposition
$Name : kind shape bindings with sigilnamed declarations in packages (type, const, struct, …)sigil dropped — names live in the module system, not in prose
kind vocabulary: signal, event, command, streamnot typl — ridl's interaction keywords over the interact coremoved up a layer
kind statenot typl — rmdl territorymoved up a layer
kind configridl 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 constconstkept
kind value (default)type / struct fieldkept, made explicit
kind documentstructfolded in
kind namespace + $. relative refs + published tierpackage / import (ADR-0002 ns core)superseded — packages give declared-once, absolute citation, and ownership corpus-wide
int[0..300], float[0..1.0] postfix rangesinteger [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 shapesstring [3..6], bytes [32]kept
pattern /re/ shapematch /re/ inside the string constraintmerged into the constraint; named regex constants added
'low' | 'mid' | 'high' literal-union enumsenum (integer-backed)not carried over — string enums are planned for v0.2 (§17.1)
{ field: shape } recordsstructkept; structs are named and closed
element[](min..max) arrays[T; min..max]kept; bounds made mandatory
shape? optionalfield : T?kept
typedef type Name = shapetype Name : backing [constraint] (scalars) / named compositessplit by composite kind
entry-local scope tiernot applicable — no prose entries; packages are the only scopesuperseded
TYPL-001…012 diagnostic catalogueTYPL-0xx…4xx coded catalogue (§16)practice adopted, codes renumbered by area
four Markdown surfaces (fence, bullet, inline, table)not adopted — typl lives in .typl filesMarkSpec 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 keywordtypl equivalentStatus
type: boolean/integer/number/stringboolean, 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)
multipleOfstep (quantization anchored at range min)✓
minLength / maxLengthstring [min..max]✓
patternmatch /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 / contentMediaTypebytes + 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 + requiredstruct fields; optional via ? (required is the default)✓ inverted default, stricter
additionalProperties: falseimplicit — structs are closed✓ by construction
additionalProperties: <schema> / patternPropertiesbounded map [K:V; min..max] with match-constrained key type≈
propertyNamesmap key = named string type with match✓
minProperties / maxPropertiesmap 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 / unevaluatedPropertiesirrelevant — structs closed, arrays homogeneous by construction✓ by construction
$ref / $defsnamed types + qualified imports✓ stronger: versioned, package-scoped, cycle-checked
$id / $anchor / $dynamicRefpackage + name is the identity≈ superseded by module system
recursive schemas—✗ deliberate (§7.3): unbounded wire size
title / descriptiondoc comments✓
defaultbare = 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 / writeOnlynot a type property — direction lives on interactions (ridl fixed, signal direction)≈ relocated by design
vocabularies / meta-schemas / $schemaone 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

LanguageWhat typl takes / how it differs
ASN.1 (X.680) with constraintsthe 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
XSDfacets (minInclusive, pattern, length) map to typl constraints; typl rejects XSD's inheritance/extension model in favour of composition
Protocol Buffersthe codegen contract baseline; typl is deliberately richer (ranges, units, bounds) precisely where proto is silent, and its emitter targets proto3 (Appendix D)
FlatBuffers / Avro / Thriftfixed-width scalars and required bounds validate typl's transport-layer mapping; Avro's named-types + namespace model parallels the package system
Franca IDL / AUTOSAR ARXMLautomotive precedent: ARXML CompuMethods and data constraints are the target of typl's unit types and ranges; Franca's typeCollection ≈ a pure typl package
DBCsignal 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
CUEvalues-as-types and lattice unification are powerful but undecidable for width inference; typl keeps constraints first-class but static
Zod / valibotruntime-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

TermDefinition
RIDL (capitals)the platform and family name; typl is this language, the family's vocabulary layer and root of the lattice
familythe 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
profilethe restriction of the family grammar accepted by a file extension; .typl accepts type declarations only; .rxdl is the total profile accepting every layer
profile puritythe ridl.toml policy restricting a package to one profile's declarations — enforced per package, not by the grammar
corea reusable semantic unit beneath the surface languages: ns (namespacing), typl-core (types), expr (predicates), time (timing), interact (interactions)
vocabulary layertypl's role: the types, units, ranges, constants, and composites every other layer describes things with
packagethe unit of namespace, visibility, versioning, and codegen — a directory whose files share one package declaration (ADR-0002)
workspacea coordinated set of packages with one lockfile and one [workspace] manifest
manifest / lockfileridl.toml (declares imports, profile purity, defaults) / ridl.lock (pins remote imports by content hash)
primitiveone of the five built-in scalars: boolean, integer, float, string, bytes — backing material, restricted as direct field types
named typea type declaration: a constrained primitive or unit type carrying domain meaning; the normal building block for fields
backingthe primitive (or UCUM unit, itself float-backed) underneath a named type
unit typea named type whose backing is a UCUM unit expression (km/h, Cel) — carries dimensional identity into every layer
UCUMUnified Code for Units of Measure — the unit vocabulary standard (also used by OpenTelemetry, HL7 FHIR)
constraintthe [ ] clause on a type: range, step, length, match — closed-form by design (no general expressions, §17.7)
rangeclosed (inclusive) [min..max] bounds; the semantic truth from which wire width is inferred
stepquantization: valid values are min + n·step; drives float width inference and the scaled-integer wire form
quantized floata float with range + step — losslessly representable as a scaled integer (raw = (value − min)/step), the CAN/AUTOSAR wire form
matchthe constraint keyword binding a string type to a regex pattern (named const or inline literal)
width inferencethe compiler deriving the narrowest safe wire type from a declared range/step — computed once, passed to all backends
language layerthe generated in-code representation: always widest (int64/float64) so arithmetic cannot overflow storage
transport layerthe on-wire representation: narrowest width the range provably fits
wire typea concrete transport width (uint8 … float64); inferred from the range and never written in source — the names are not typl keywords (§5.6)
width flipthe 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
structa 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 valuethe 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 / enumsetclosed set of integer-backed values (single selection) / named bitfield (multiple simultaneous flags, width from highest bit)
uniondiscriminated (tagged) composite — exactly one named-type arm active; never untagged
error typean enum, struct, or union carrying the error modifier — failure vocabulary; ordinary data with one extra bit of meaning
result uniona two-arm union of one success arm + one error-typed arm — the family's Result<T, E> without generics; the entire functional-error mechanism
tupleanonymous composite of named fields used inline; () is the unit type
boundedthe invariant that every typl composite has a statically known maximum wire size — why collections require bounds and recursion is an error
ordinala field's/arm's implicit 1-based declaration-order identity — the source of proto field numbers and FlatBuffers ids (§7.4)
reservedtombstone keeping a retired field's ordinal (or enum wire value) occupied so it is never reused
append-onlythe evolution discipline: new fields at the end, deletions by tombstone, reorder = wire break caught by ridl-diff
one-definition rulea public name is declared exactly once, in exactly one package, importable everywhere — no re-exports, no wildcards
internalpackage-private visibility; the opt-out from public-by-default
diagnostic codea stable TYPL-nnn identifier for a compiler rule — never renumbered, never reused (practice inherited from markspec typl)
IRthe stable serialized intermediate representation — resolved names, checked types with ranges/units/widths intact — consumed by every backend and tool
ridlc / ridl / ridl-diffthe 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
SSOTsingle 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 by ridl-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 | E returns (§7, §10.1, Appendix A, Appendix C), generic min/max timing 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

  1. Scope and Position in the Family
  2. Lexical Additions
  3. The Interaction Model — families (§3.2), correspondence obligations (§3.3), availability (§3.4)
  4. Signal
  5. Event
  6. Command
  7. Query
  8. Fixed
  9. Timing Annotations
  10. Errors
  11. Interaction Identity and Evolution
  12. Streams
  13. Contracts — require / ensure
  14. Interfaces and Services
  15. Conventions
  16. Diagnostics
  17. Open Questions

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:

AdditionCoreSurface
interaction kindsinteractinterface, signal, event, command, query, fixed
timingtime@Xms, @[min..max], duration literals
contractsexprrequire, 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:

SuffixUnitExampleMicroseconds
usmicroseconds500us1
msmilliseconds10ms1 000
sseconds1s1 000 000
minminutes5min60 000 000
hhours1h3 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":

KeywordPatternSemanticInitiator
signalpub/subcontinuous state value — the latest sample mattersprovider publishes
eventpub/subdiscrete occurrence — every occurrence mattersprovider publishes
commandRPCfire-and-forget action — no reply channelconsumer calls
queryRPCrequest/response — reply mandatoryconsumer calls
fixedprovisionedimmutable for the software-instance lifetimeneither (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 fieldMeaning
timestampwhen the instance was published/raised/called — stamped by the sender, at the producing binding, before transmission
sequence numbermonotonic 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).

FamilyDirectionThe provider's promise
dispatchsystem → systemtransfer, nothing interpreted
presentationsystem → personI offer this to be perceived
intentperson → systemI capture what you meant
acquisitionworld → systemI report what is
controlsystem → worldI 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

Familycontinuous (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:

ShapeMeaningParameters
activateinvokenone
toggleflip a binary statenone
selectchoose one from a setexactly one — the key
adjustset a value within a rangeexactly one — a ranged/unit type
dismisscancel or close a transientnone

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.

FamilyReferenceRealisation
presentationthe provider's valuewhat the person perceives
intentwhat the person meantthe captured value
acquisitionthe physical quantitythe reported value
controlthe commanded valuethe resulting world state

The four obligations

ObligationPresentationAcquisitionControl
relationshipnever under-reads; quantisedtransfer function; calibrationauthority limits; slew rate
uncertaintydisplay resolutiontolerance per readingpositioning tolerance
latency of correspondenceperception delaysample instant ≠ publication instantactuation lag
failure to correspondshown but not perceivableplausible but falsecommanded 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 termHere
measurandwhat an acquisition interaction names
calibration — the relation between quantity values and indicationsthe relationship obligation
measurement uncertaintythe uncertainty obligation
indication — the value a measuring instrument providesa presentation-family value
detector — indicates presence when a threshold is exceededan acquisition occurrence
metrological traceability — an unbroken chain of calibrations, each contributing to the uncertaintythe 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.

SourceExampleCarried by
modeonly while the system is in ERRORduring — a state gate
datadisabled until a value validatesa predicate over declared state
in progressalready running, cannot be re-invokeda predicate over a declared progress value
policynot permitted in this conditiona predicate over declared policy state
provisioningthis variant does not have the interactionfixed — 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 = value or derived). A signal may override it with the same bare = value idiom, 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 @Xms is 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 named struct (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, use query
  • 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 query returning T | E — the language forces that honesty
  • require permitted (§13); ensure not (nothing to observe)
  • A range timing annotation is permitted, and an undeclared response bound draws a warning (§9.3): the max bound is a response bound on acceptance — the delivery acknowledgment below, never execution — and the min bound 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 → observe currentGear). 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, use command (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
  • require and ensure permitted (§13); ensure constrains result
  • Functional failure is expressed in the return type: the inline T | E form makes the query fallible — §10.1. There is no throws clause in ridl
  • A range timing annotation is permitted, and an undeclared response bound draws a warning (§9.3): the max bound is a response bound on the reply, and the min bound 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 fixed is free of the query machinery — bindings expose it as a plain accessor, populated at binding initialization
  • Naming decision on record: fixed, not final and not config — one word for one concept at every boundary (ADR-0011); fixed is family-neutral, because a provisioned constant carries no correspondence anywhere. final was the earlier spelling and misled: a Java or Kotlin reader takes it for a compile-time constant, which this is not. config connotes 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:

BoundOn a signal (state)On an event (occurrence)On a command / query (call)
min — rate floordebounce — a faster update is coalesced into the next samplethrottle — the provider must not raise occurrences fastercall throttle — the caller must not call faster
max — staleness boundrefresh ceiling — re-publish even if unchangedTTL — an occurrence older than max is discardedresponse 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-diff compares 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:

  • min is the call throttle — the minimum interval between calls. It constrains the caller, not the provider. That is not an inconsistency: min always 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.
  • max is the response bound — the provider must respond within it. What responding means is derived per kind. For a query it is the reply. For a command it 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 | E in 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 union before 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 inline T | E
  • command remains 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:

CategoryTriggerRPC surface (caller sees)Pub/sub surface
INVALID_VALUEpayload violates typl constraints (range, step, bounds, pattern) at a boundarystandard error, distinct from any result-union error arm — gRPC INVALID_ARGUMENT, SOME/IP E_MALFORMED_MESSAGEsignal: channel transitions to invalid state, propagated to subscribers (§4.5); event: occurrence delivered marked invalid (§5.1)
PRECONDITION_FAILEDa require clause evaluates falsestandard error — gRPC FAILED_PRECONDITION, SOME/IP E_NOT_OKcommand: negative ack to calling runtime + observability (§6.2)
CONTRACT_BROKENan ensure clause evaluates false — a provider bugstandard error to caller; incident-grade telemetry on providern/a
UNKNOWN_INTERACTIONordinal/version mismatch between peers (§11)standard error — SOME/IP E_UNKNOWN_METHODsubscription 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 | E return — 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-rt supervision hooks), informed by @labels assurance 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
  • reserved tombstones 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 baseline refuses 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 no reserved line at all; it is retired with a reserved line, but at an ordinal other than the one it held; the baseline already retired it and the source has dropped the reserved line; 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 by ridl 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 diff and 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 diff says so and exits 1, and ridl check warns 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.lock retires 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 diff reports 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 diff reports the removal and the form switch as 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, and ridl-diff decides 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-diff per 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

PositionDirection
return typeprovider produces, consumer consumes
parameterconsumer produces, provider consumes
bothbidirectional, 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
]
AttributeMeaningValid on
require <expr>precondition over parameters and the interface's own signalscommand, 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 (a require may read currentSpeed — 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/ensure written 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 as service.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 reserved tombstones only — type declarations live at package level (they are typl), never inside an interface (RIDL-107)
  • Flat: there is no interface inheritance. extends was 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 names camelCase
  • @labels classification 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 addressed service.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 { for struct 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.engaged an unambiguous system-wide address (the VSS/uProtocol model).
  • A service is always public: it takes no internal modifier, and every shape it lists must be public too. Publishing an internal interface at a global address is RIDL-143, reported against the offending list element — the address would name a contract no importer can implement. Drop internal from 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, keyed service: 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, and ridl diff reports an addition as service_interface_added and a removal as service_interface_removed, both compatible. A removed interface is retired in the lock — ridl lock <pkg> --retire Name — never in the list, and reserved in 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.status with 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, and ridl-diff classifies 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.cruise can compile down to static CAN signals or a discovered service, no rewrite. One constraint follows from transport physics: a command/query member (RPC) cannot be realized on a pure static bus — buses carry dataflow, not calls — so a service deployed statically realizes only its signal/event members; 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 offers a 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 its service: 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:

TransportHow the grouping is preserved
shared memoryone versioned block per group, one generation counter
DDSGROUP-scope PRESENTATION with coherent_access
SOME/IPone 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 max staleness 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 as get…/stream…
  • Reuse error types across queries whose failure domains genuinely coincide — not one error enum per query by reflex; compose several with an error union
  • Prefer @[min..max] change-driven signals over strict @Xms unless 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)

CodeRuleSeverity
RIDL-100signal or event without a timing annotation — default [100ms..1000ms] (or configured [defaults].timing) appliedwarning; error if active profile requires explicit timing
RIDL-101timing 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-101error
RIDL-102zero or negative timing durationerror
RIDL-103strict-periodic @Xms on a kind other than signal (§9.2, §9.3) — widened from event only by ADR-0015error
RIDL-104explicit return type on a commanderror
RIDL-105query returning ()error
RIDL-106timing 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-0015error
RIDL-107type declaration inside an interface or service body — raised at parse time, where the declaration is recognised and recoverederror
RIDL-108degenerate 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-108warning
RIDL-109signal payload has no derivable init and no = value override (§4.4)error
RIDL-110signal = value init override violates the payload constraints (kind, finite float backing domain, range, step, length, pattern, enum discriminant, or supported value spelling)error
RIDL-112command 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], appliedwarning; 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)

CodeRuleSeverity
RIDL-201stream <T> on a signal or event payloaderror
RIDL-202stream element type not a named type, string, or byteserror

16.3 Contracts and Errors (RIDL-3xx)

CodeRuleSeverity
RIDL-301require or ensure on signal, event, or fixederror
RIDL-302ensure on commanderror
RIDL-303fallible query return with no success path — a bare error type in return position, or an error-typed left arm of an inline T | Eerror
RIDL-304error-typed or result-union parameter on a command or query — failure flowing toward a providerwarning
RIDL-305ensure clause that never references resultwarning
RIDL-306require/ensure expression outside the guaranteed subset (expr-core specification §8)error
RIDL-307contract-error category name declared in an error enum (INVALID_VALUE, …)warning
RIDL-308named result union in query return position — the inline T | E spelling is canonical there (§10.1)warning

16.4 Evolution and Profile (RIDL-4xx)

CodeRuleSeverity
RIDL-401interaction re-declared under a reserved nameerror
RIDL-402duplicate interaction name in one interaction body — an interface or a service's inline shapeerror
RIDL-403behaviour, user-interaction, or architecture declaration in a ridl contexterror
RIDL-404query 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-405one error type shared across unrelated failure domains — it is the failure arm of queries in 3 or more interaction scopes (heuristic)info
RIDL-406payload 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 legitimateinfo
RIDL-407interaction, 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 warningwarning
RIDL-408interaction 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 losterror
RIDL-409live 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 shapeerror
RIDL-410interfaces.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 lockerror
RIDL-411provisional 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 publisherror
RIDL-412published 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-413parameter 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 sameerror
RIDL-414interface members form disconnected type-sharing groupsinfo
RIDL-416interfaces.lock in a subdirectory of a unit is not read — the unit's lock is the file beside its ridl.tomlwarning
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 insteadwarning
RIDL-140duplicate service name across the workspace — the service catalog is a flat global namespaceerror
RIDL-141service names a type that is not an interface — and has no inline shapeerror
RIDL-143service publishes an internal interface — a global published address must name a public shape (§14.5)error
RIDL-144duplicate member name across a service's interfaces — flat addressing (§14.5) cannot give service.member two referentserror
RIDL-145the 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 sloterror
RIDL-146retired 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 tombstoneretired
RIDL-147retired 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 collideretired
RIDL-148retired 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 errorretired
RIDL-149two 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-150payload 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-151payload 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-152a [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-153type 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 insteaderror
RIDL-154array 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 encodingserror
RIDL-155deprecated 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 encodingserror
RIDL-156struct 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-orderinfo

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)

CodeRuleSeverity
RIDL-501interaction 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 tableerror
RIDL-502operation shape's parameter rule violated (§3.2.1) — parameters on activate/toggle/dismiss, or not exactly one on select/adjusterror
RIDL-503intent operation with no shape — the shape set is closed and has no generic form (§3.2.1)error
RIDL-504non-dispatch interaction declaring no correspondence obligation (§3.3) — a family tag with nothing behind itwarning
RIDL-505availability predicate on an intent interaction referencing state the consumer cannot evaluate (§3.4)error
RIDL-506obligation attribute on a dispatch interaction — between software peers the datum is the truth, so there is nothing to correspond toerror
RIDL-507derives names an interaction in a family that cannot be its reference (§3.3 direction rule)error
RIDL-508a 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.

  1. Selective broadcasts. Resolved — subscription granularity is the interaction (§5.1). The question was whether Franca's broadcast 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.

  2. 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. Composing DiagBlock beside 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).

  3. 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.

  4. 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: a command for the goal whose bound covers acceptance alone, a progress signal so a late subscriber reads where the operation is now, a completion event for the outcome, and a query beside it where the result must be fetchable afterwards. Sugar is not taken: an action keyword 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.

  5. 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 policyridlverdict
    RELIABILITYthe 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 max staleness boundcovered
    LIVELINESS§9 freshness SLO, §10.3 detectioncovered
    TIME_BASED_FILTER§9 min rate floor, derived as debouncecovered
    LIFESPAN§9 max, derived as event TTL (§5.2)covered
    OWNERSHIP / OWNERSHIP_STRENGTH§4.2 — exactly one owning provider per flowcovered by construction
    DESTINATION_ORDER§3.1 envelope timestamp and per-channel sequence numbercovered
    RESOURCE_LIMITS (payload)typl bounded collections and length boundscovered, in the type system
    HISTORY (KEEP_LAST N)§4 signal is latest-value; §5.1 rules out event replayout of scope, deliberately
    DURABILITY (PERSISTENT)persist, reserved by ADR-0008 decision 3reserved
    LATENCY_BUDGETa delivery-delay hint permitting batching; the DDS specification is explicit it is not a commitmentno analogue needed, none wanted
    RPC reply timeout (DDS-RPC)@[..max] response bound on command/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.

  6. 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 2 INVALID_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.

  7. Reflection/discovery service. Resolved — no normative ridl.reflect package. 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), where ridl describe prints 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.

  8. 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-rt runtime spec; assurance policy via @labels profiles. 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.

  9. 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.

  10. The explicit one-time read. The acquisition request/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.

  11. 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.

  12. 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 with reserved b at ordinal N, replaced by a source that retires it with reserved b at ordinal M, reports InteractionReordered, not InteractionRemoved, and the RIDL-408 gate reads InteractionRemoved changes and the interaction-level ReservedNameRedeclared, never InteractionReordered. 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 diff reports it and exits 1, and ridl check warns with RIDL-407 at the desk. §11 now states the division of labour between the two.

  13. A whole package removed or renamed bypasses the publication gate (§11). Resolved — the interaction level does not refuse it, the interface level does. ridl-diff reports one DeclRemoved for 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's interfaces.lock retires 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 diff reports the removal as breaking.

  14. 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-diff reports one DeclRemoved for a service present only on the baseline side and one ServiceChanged for 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 its interfaces.lock number, 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 by ridl 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.

ridlSOME/IPproto3 / gRPCAIDLDDSMQTT / AsyncAPI
signalfield notifier (+ auto-derived getter from last-value cache)server-streaming RPC or pub/sub sidecarcallback / oneway listenertopic, TRANSIENT_LOCAL durability, DEADLINE = maxretained message on channel
coherent-set delivery (§14.5)per-field only — no grouping across notifiersper-message only — no grouping across streamsper-parcel only — one callback per signalGROUP-scope PRESENTATION with coherent_accessper-message only, per channel
eventevent (eventgroup)server-streaming RPCcallbacktopic, VOLATILE durabilitynon-retained publish
commandrequest w/ empty response (= ack, §6.1)unary RPC → Empty (= ack)oneway + runtime ack shimreliable-QoS request topic (DDS ack)publish QoS 1 (puback = ack)
queryrequest/response methodunary/streaming RPCmethodrequest/reply (RPC over DDS)request/reply channel pair
RPC response bound, max (§9.3)derived request/response timeout, supervised by the bindingserver-side deadline; per-call client override stays Stratum 3transaction-timeout supervision in the runtime shimDDS-RPC reply timeoutreply-channel timeout
RPC call throttle, min (§9.3)admission-side rate limit at the providerserver-side admission rate limitadmission check in the runtime shimrequester-side rate limitinbound publish rate limit
fixedfield with getter onlyunary getter RPC (cacheable)constant/property—retained provisioning channel
result-union error arm (§10.1)method return code tablegoogle.rpc.Status + typed detailServiceSpecificException codereply union armerror 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 fieldtopic name suffixchannel path segment
Stratum 2 (§10.2)E_MALFORMED_MESSAGE / E_NOT_OK / E_UNKNOWN_METHODINVALID_ARGUMENT / FAILED_PRECONDITION / UNIMPLEMENTEDIllegalArgumentException mappingreply statuserror 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:

ReferenceUsed for
AUTOSAR Classic R22-11signal/event split (isQueued), CalibrationParameter, signal groups
SOME/IP (AUTOSAR PRS)field/method/event mapping, return codes, IDs
Franca IDLattribute/method/broadcast precedent, error enums, rejected version block
OMG DDS 1.4 / DDS-RPCdurability, deadline, liveliness — QoS-in-contract comparison
gRPC / google.rpc.Statusstatus model, streaming RPC
Android AIDLoneway, transaction codes
Fuchsia FIDLmethod ordinals (hash model rejected), protocol evolution
WASM Component Model / WITstream<T>/future<T> (WASI 0.3), resource model
AsyncAPI 3.0channel/operation vocabulary for the MQTT binding
ROS 2msg/srv/action decomposition
Eclipse uProtocol / COVESA uServicescontemporary transport-neutral automotive service layer
Eiffel, SPARK AdaDesign by Contract

Appendix E — Prior Art Survey

What each interface language contributed to, or was rejected from, this design:

LanguageTaken / rejected
CORBA IDLthe 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 IDLclosest 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 Classicthe 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/IPfield triple (getter derivable from §4.4, setter as explicit command, notifier = signal), return codes → Stratum 2 mapping, method/event IDs → ordinal derivation
DDSthe 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 / protostreaming 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
AIDLoneway validates fire-and-forget as a first-class kind; transaction-code stability → ordinals
FIDLstrict/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 modelstream<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
AsyncAPInaming 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 2msg/srv ≈ signal/query; actions (goal+feedback+result) written as the composition idiom → §15
OPC-UAindustrial precedent for typed nodes with subscriptions; its reference model is heavier than a contract language needs — profiles/labels carry the assurance metadata instead
MQTTretained 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 uServicesthe 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 constructridl equivalentStatus
CORBA/Franca attribute (readable, subscribable state)signal + §4.4 last-value✓
attribute setterexplicit command (never implicit)≈ deliberate — mutation is always a visible verb
Franca broadcastevent✓
Franca selective broadcast (per-client)— (the recipient is a payload field, §5.1)✗ deliberate §5.1
Franca/AIDL fireAndForget / onewaycommand✓ 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 / bidiquery 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 livelinessstaleness via freshness bounds + observability≈
SOME/IP field get/set/notifysignal (get derived, notify native) + command (set)✓ decomposed
SOME/IP eventgroupsno 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 portsquery/command + signal/event✓
AIDL in/out/inout parametersparameters in, tuple returns out≈ deliberate — no out-params
FIDL events (server-initiated on protocol)event✓
FIDL strict/flexible evolutionordinals + 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 / streamquery return / <T>✓
AsyncAPI channel + operationinteraction + 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

TermDefinition
RIDL (capitals)the platform and family name; ridl (lowercase) is this language, the family's interaction layer and flagship member
familythe 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 familywho 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 obligationwhat 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 / realisationin a non-dispatch family, the causally upstream and downstream sides; the realisation must correspond to the reference within declared bounds (§3.3)
operation shapeactivate · toggle · select · adjust · dismiss — a closed set constraining an intent operation's parameters and carrying machine-readable gesture semantics (§3.2.1)
indicationmetrology's term (VIM) for the value a measuring instrument provides; a presentation-family value is one (§3.3)
acquisition spanthe interval over which a value acquired by sweeping corresponds to the world, as against an instant offset (§3.3)
profilethe restriction of the family grammar accepted by a file extension; .ridl accepts interactions + typl declarations; .rxdl is the total profile accepting every layer
corea reusable semantic unit beneath the surface languages: ns (namespacing), typl-core (types), expr (predicates), time (timing), interact (interaction primitives)
interactiona named, typed, directed exchange on a contract boundary; ridl defines five kinds
signalpub/sub interaction carrying a continuous state value — latest sample matters, intermediate samples may be missed
eventpub/sub interaction carrying a discrete occurrence — every occurrence matters, queued not coalesced
commandfire-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
queryrequest/response RPC — reply mandatory; an inline T | E return makes it fallible
fixeda value provisioned externally (build/factory/FOTA), immutable for the software-instance lifetime, safe to cache
providerthe 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
consumerany component bound to an interface it does not own: subscribes, calls
state vs occurrencethe load-bearing distinction behind signal/event: state exists while unchanged and may be cached; an occurrence happens once and is meaningful individually
timing annotationthe @ clause making publication timing part of the contract: @Xms strict periodic or @[min..max]
rate floor / staleness boundthe 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 ceilingthe signal derivation of rate floor / staleness bound (§9): coalesce updates faster than min / re-publish at least every max even unchanged
throttle / TTLthe event derivation of rate floor / staleness bound (§9): the provider must not raise faster than min / an occurrence older than max is discarded
default timingthe 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 SLOthe 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 joinera subscriber that binds after publication began; served by the last-value guarantee on signals, receives nothing retroactive on events
quarantinewithholding 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 typea typl enum/struct/union carrying the error modifier — failure vocabulary, ordinary data (typl §10.1)
fallible query / inline T | Ea 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 unionthe 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 / ensurepre-/postcondition contract clauses (expr core); violations are Stratum 2, and each assertion also runs as CI property test, online observer, and rmdl oracle check
streamthe <T> container: an unbounded element sequence, valid only in interaction position; direction determined by position (parameter = consumer produces, return = provider produces)
ordinalan interaction's implicit 1-based declaration-order identity within its interface; source of transport IDs (typl §7.4 model)
reservedtombstone keeping a retired interaction's ordinal slot occupied so wire identities are never reused
append-onlythe evolution discipline implied by ordinals: new interactions at the end, deletions by tombstone, reorder = wire break
interfacethe abstract contract shape — a reusable, identity-less group of interactions; a contract type, realized by services (interface : service :: type : instance)
servicea 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 catalogthe flat global namespace of all service declarations — the system-wide SSOT of contracts
posturehow 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)
bindinggenerated per-transport code realising a contract: validation, caching, error mapping, (de)serialization
enveloperuntime-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 timethe 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 valuethe 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 statethe 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
brokerthe asynchronous message plane between components; ridl contracts cross it, rmdl models never do (the sync/async wall)
IRthe stable serialized intermediate representation — resolved names, types, timings, ordinals — consumed by every backend and tool
ridl-diffthe 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
SSOTsingle 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 .rxdl file in a package directory with the warning RIDL-417 (unsupported-source-file) and does not compile it, and refuses a lone .rxdl file 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

  1. What rxdl Is
  2. The Unrestricted Profile
  3. Domains and Families
  4. The hmi Domain
  5. The env Domain
  6. Lowering
  7. Admission Tests
  8. What rxdl Is Not
  9. Package Tightening
  10. Diagnostics
  11. Open Questions

1. What rxdl Is

Two things, and nothing else:

  1. A file kind that restricts nothing (§2). Every other profile is a restriction — .typl accepts type declarations, .ridl accepts dispatch-family interactions, .rmdl accepts behaviour. .rxdl accepts all of it.
  2. Spelling tables for the non-dispatch families (§4, §5). present is 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

ExtensionAccepts
.typltype declarations
.ridldispatch-family interactions, over typl
.rmdlbehaviour declarations
.rsdlarchitecture declarations
.rxdlany 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 .ridl file 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.

DomainFamilies
hmipresentation · intent
envacquisition · 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.

Familycontinuousoccurrenceoperation (no return)operation (returns)
presentationpresentnotify——
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.

Familycontinuousoccurrenceoperation (no return)operation (returns)
acquisitionmeasuredetect—(open)
controlactuate—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.

Spellingkindfamilyshape
signalsignaldispatch—
eventeventdispatch—
commandcommanddispatch—
queryquerydispatch—
presentsignalpresentation—
notifyeventpresentation—
activate … dismisscommandintenteach
measuresignalacquisition—
detecteventacquisition—
actuatesignalcontrol—
triggercommandcontrol—

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. A present never renders back as a signal.
  • 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 .rxdl file 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. family and shape are ridl's IR fields. rxdl adds no node, no field, and no attribute key.
  • Not layout, styling, navigation, or localisation. A toggle may 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-diff classifies 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

  1. The intent occurrence keyword. The person-supplies-content spelling — typing, scanning, dictation. Event-shaped: debounce, TTL, no acknowledgment. supply is the working candidate; provide and tell remain live. enter was rejected for modality, submit for implying a commit and an acknowledgment an occurrence does not have.
  2. 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 measure is maintenance, not duration: measure obliges 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. read is 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.
  3. 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.
  4. 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.
  5. 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

SourceTaken / 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, QMLThe 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 practiceThe 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 .rmdl file in a package directory with the warning RIDL-417 (unsupported-source-file) and does not compile it, and refuses a lone .rmdl file 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 family expr core, and the model — the unified reactive construct (no separate node).


Table of Contents

  1. Scope and Position in the Family
  2. Lexical Additions
  3. The Function Layer
  4. Expressions
  5. The Model
  6. Execution — Steps, Not Ticks
  7. Models Are Contract-Blind
  8. Step Faults
  9. Observers
  10. Conventions
  11. Diagnostics
  12. Open Questions

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

LayerKeywordNatureOwns
functionsfunctionpure, total, timeless — mathematicsalgorithms: let y = a*x + b
modelsmodelreactive, stateful over stepsmemory (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/float permitted as in struct fields)
  • let binds immutable locals; no reassignment, no shadowing (RMDL-104)
  • Package-scoped, importable, internal-able — functions are vocabulary-adjacent citizens under the same ns rules

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, and dt are 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 plain T, sampled at step start (latest value, provenance-aware)
  • event name : T — an occurrence flow: inside the body it reads as T? — present with its payload in a step where the occurrence arrived, absent otherwise. A payloadless occurrence is event 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), when blocks (§5.6), let locals, 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 equationwhen block
selected bya value, present every stepan occurrence or edge
branches per stepexactly one, alwayszero or one
definitiontotal — every branch defines the same flow set (RMDL-213)partial — signals hold where unassigned
exhaustivenessrequired, else as catch-all (RMDL-108)not applicable (no branch matching is normal)
emitnot 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 flow x at the previous step. Total: defined even at the first step, because every memory has a seed
  • init x = e — the seed equation: what last x yields at the first step. e is 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 init and no binding is seeded per the derived rules of typl §5.8
  • Any other flow read through last requires an explicit init equation (RMDL-203 — a presence check, not a dominance analysis)
  • A when block's init branch 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:

ConditionTriggers when
initthe first step — initializes the signals this block defines
e?the occurrence e arrived this step
e? if ce arrived and c holds
(e1?, e2?)both occurrences arrived this step
boolean expression cc 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 when block keeps its previous value in any step where no branch assigns it — the latch generalized. The init branch supplies the first value (mandatory for every signal the block defines, RMDL-209); it is initialization, not default — once another branch has run, init is unreachable
  • Events don't hold: an event output is raised with emit name = expr inside a branch and is absent in every step where nothing emits it. emit is valid only inside when branches and only targets event-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:

  1. An input arrives — an occurrence or update on an input flow, or
  2. 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:

ExpressionTypeMeaning
nowTimestampthe 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
dtDurationelapsed logical time since this instance's previous activation — definitionally now - last now, seed making it 0 at the first reaction
time(f)Timestampthe 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 − logical per 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/await never appear in the language. Concurrency, task spawning, and completion are ridl-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 parallel keyword 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 sourceProjected from
input arrivalthe occurrence's sender-stamped envelope time (logical)
output deadlinethe bound service's @[..max] refresh ceiling / @Xms period, anchored to the last publication (supplied by the binding component, §7)
coalescing floorthe contract's min bound — the earliest next instant a flow may cause
timerfuture 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:

ConcernWhere it lives now
which contract member each flow maps tothe 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 valuethe 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/ensurethe 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:

  1. The faulting step aborts atomically — no partial state update, no partial outputs; the model's memory (last state) remains that of the last completed step
  2. 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
  3. 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)
  4. 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/ensure clauses (§9.2), evaluated every step — a violated ensure is a CONTRACT_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/ensure clauses 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, over result; 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), and e? implies … — the eventful conditional (GReusot's when 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 init equation adjacent to the equation that reads last — the seed and the recurrence review as a pair. Channel-bound flows are implicitly seeded (§5.3), but an explicit init on 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)

CodeRuleSeverity
RMDL-101recursion, direct or mutualerror
RMDL-102last or init inside a functionerror
RMDL-103now or dt inside a functionerror
RMDL-104let reassignment or shadowingerror
RMDL-105division/modulo whose divisor is not provably nonzero and not guardederror
RMDL-106cross-unit arithmetic without an explicit conversionerror
RMDL-107if without elseerror
RMDL-108non-exhaustive case (no else, arms incomplete)error
RMDL-109function literal outside a combinator argument positionerror
RMDL-110flow declared with the name now or dt inside a modelerror

11.2 Models (RMDL-2xx)

CodeRuleSeverity
RMDL-201flow defined by more than one equationerror
RMDL-202declared output with no defining equationerror
RMDL-203flow read through last with neither a channel seed nor an init equationerror
RMDL-204instantaneous dependency cycle (cycle not broken by last)error
RMDL-205conditional or dynamic instantiationerror
RMDL-206unused input or abstract inputwarning
RMDL-207when branch unreachable — subsumed by an earlier, less restrictive branchwarning
RMDL-208emit outside a when branch, or targeting a non-event flowerror
RMDL-209signal defined by a when block without an init branch valueerror
RMDL-210init expression references anything beyond constants and first-step inputserror
RMDL-211duplicate init equation for the same flowerror
RMDL-212emit inside a case equation — a mode is not an occurrenceerror
RMDL-213case equation branches define differing flow setserror

11.3 Signatures (RMDL-3xx)

CodeRuleSeverity
RMDL-301declared output flow with no defining equationerror
RMDL-302realizes (or any contract reference) in a model — models are contract-blind (§7)error
RMDL-303input flow never readwarning

(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)

CodeRuleSeverity
RMDL-401model dataflow across an async boundary (the sync/async wall)error
RMDL-402behaviour declaration in a non-.rmdl/.rxdl contexterror
RMDL-501model reading no last (should be a function)warning

12. Open Questions

  1. Multi-activation (successor of multi-rate clocks). when found 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/current stay reserved for that clock calculus if it is ever needed.
  2. 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.
  3. State-machine sugar. automaton/state/transition reserved; compiles to enum + latch equations; lands once the equation core is proven in real models.
  4. 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.
  5. Saturation vs fault at typed boundaries. §8 makes range violation a step fault; control practice sometimes wants declared saturation instead (clamp semantics 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.
  6. Standard behaviour library — semantics re-derived against the signal/event/timeline model. Two Rust-implemented stdlib packages: ridl.std.math (pure functions: clamp, lerp, abs/min/max, interpolation tables, dt-correct filter/integrator kernels) and ridl.std.flow (adapter models). GReact's vocabulary is re-based with audience-correct names (its scan/throttle collide 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:

AdapterKind signatureSemantics (seed behaviour included)
Changes(s)signal T → event Temits 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 Tzero-order hold; init seeds the output channel explicitly (GReact persist, which left the pre-first-occurrence value implicit)
Deadband(s, Δ)signal T → signal Tforwards 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 Urunning 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 Tforwards 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:

AdapterKind signatureSemantics
Clock(d: Duration) : event ()the primitive instant source: an occurrence at every multiple of d from instantiationsee rationale below
Sample(s, d)signal T → event Temits 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 Tat 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.

AspectRust (native)WASM component
modela struct (state) + fn step(&mut self, inputs: &Inputs, ctx: StepCtx) -> Outputssame, behind the component model; WIT signature generated from the contract
functionfn — pure, #[no_panic]-style disciplinecore function
last state (+ seeds)struct fields, initialized at constructionlinear memory, initialized at instantiation
step schedulingridl-rt scheduler: input arrival + deadline queue from contract boundshost runtime drives step() — wasmtime on ECU/edge, browser at the person boundary
step faultsResult-shaped step return → invalid-state propagation via bindingstrap-free: fault is a returned status, never a WASM trap
determinismIEEE 754 strict, no fast-math, fixed evaluation orderidentical — the reference-oracle guarantee (§6.4)
observerscompiled 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

SourceTaken / 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
Lustrethe 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
SCADEthe 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 / Signalthe imperative and relational siblings; Signal's polychrony is the intellectual ancestor of the multi-activation question (§12.1)
Lingua Francathe 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
Simulinkthe 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 synchronouscontinuous-time ODEs beside discrete flows — out of scope, but dt keeps the door open for host-side integration schemes
Kotlin / OCamlthe function surface: expression-bodied definitions, let bindings, ?:; OCaml's discipline that everything is an expression
SPARK Ada / MISRAthe totality doctrine: no recursion, no unbounded loops, decidable WCET as a language property rather than a coding rule
TLA+/model checkersrejected 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).

Constructrmdl equivalentStatus
Lustre node / SCADE operatormodel✓ unified
pre, ->last + init (seed model)≈ adopted from GRust — total, no nil
node instantiation, hierarchic compositionmodel instantiation, synchronous✓
Lustre clocks when/merge/currentmulti-activation✗ deferred §12.1, keywords reserved
SCADE state machinesenum + when mode logic; sugar deferred≈ §12.3
GRust when/emit event equationswhen/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 executiondegenerate case of scheduled steps (periodic output demand)✓
event-driven execution (LF reactions)input-triggered steps✓ native
LF deadlinescontract @[..max] → scheduler constraint✓ from the contract
elapsed time / timersnow, dt✓
Simulink continuous blocks (ODE solvers)—✗ out of scope (discrete only)
Simulink saturation blocksclamp + §12.5 boundary policy≈ open
lookup tables / interpolationbounded arrays + functions✓
iterators over buses/vectorsbounded 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/observersinherited contract observers; local invariants pending expr≈ §9
formal verification hooksdeterministic 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:

TermDefinition
functiona pure, total, timeless computation — no state, no recursion, no unbounded iteration; the expr core's function layer
totalitythe function-layer contract: always terminates, WCET decidable — bought by banning recursion and unbounded loops
modelthe 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
equationthe single defining expression of a flow; unordered in source, scheduled by data dependency
flowa typed value evolving over steps — an input, output, or local of a model; every flow has a kind: signal or event
signal flowa state-kind flow: reads as T, sampled at step start, holds between assignments
event flowan occurrence-kind flow: reads as T? — present with payload when the occurrence arrived this step, absent otherwise; raised by emit, never held
when equationthe 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 equationthe 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
emitraises 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)
lastthe 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)
stepone atomic synchronous reaction — inputs snapshotted, equations evaluated, outputs published; scheduled by the runtime on inputs and constraints, never by polling
activationthe runtime's decision to run a step — input-triggered or deadline-triggered, coalesced per contract min bounds
ambient time valuesnow 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 timethe 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
timelinethe 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 tracethe 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)
islanda 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 faulta runtime fault (division by zero, boundary range violation) aborting the step atomically — state preserved, output flows go invalid, everything recorded
observera boolean flow evaluated each step with no effect on outputs — the four-way assertion machinery anchored in the model
causalitythe acyclicity requirement on same-step dependencies; last is the only legal self-reference
synchronous hypothesisthe 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 retainedWhy
time-based debounce/throttle of a channelthe contract's min bound — annotation, not operator
TTL / stalenessthe contract's max bound
time()language: now, time(f) (§6.3)
Map over eventsinline 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 / buffersunbounded or alignment-fragile; no demonstrated automotive need
Derivativea 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 .rsdl files against this reference, lowers the system to the IR beside the package IR (§13), and ridl diff compares 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

  1. Scope and Position in the Family
  2. Files, Packages, Imports and Keywords
  3. The Five Declarations
  4. Member Lines and Body Lines
  5. Attributes
  6. The Implicit Component
  7. Instances and Redundancy
  8. Resolution
  9. Placement
  10. Crossing Kinds
  11. Grants
  12. Reserved and Deferred
  13. What the Lowering Produces
  14. ridl diff at the System
  15. Conventions
  16. Diagnostics
  17. 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:

TreeShapeWhat it is for
distributiondistribution → componentsversion, sign, install
runtimemachine → component instanceexecute
contractservice → interface → member (ridl)publish and address

The nouns, each answering one question:

NounAnswersLayer
distributionwhat is versioned, signed and shippedrsdl
machinewhat software is installed on; the unit of placementrsdl
componentone execution context; offers services, requires interfacesrsdl
servicewhat is addressed on the wireridl
interfacewhat is contractedridl
memberone typed interactionridl
cataloga unit's interfaces, their numbers, one hashridl

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 .rsdl file begins with one package declaration (typl §3.1). A package is a directory (ADR-0002) and may hold .typl, .ridl and .rsdl files together. A package may hold several .rsdl files: 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 requires line 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 internal modifier in this release: the closure is workspace-wide, so every declaration it names has to be visible from the system's package. internal before 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):

SpellingRoleExample
CamelCasecomponent, system, distribution, deployment, machineAdasHpc
camelCaseinstance — a member of its componentCruise.primary
lowercase.dottedpackage, serviceveh.adas.cruise
SCREAMING_SNAKElabel, tier valueASIL_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.

DeclarationClauseBody holdsrsdl-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 servicestier, labels, deprecated
deploymentfor Systemmachine declarationsdepth, slots, budget, labels, deprecated
machine—placement lines: components, instances, lone servicesexternal, 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 deployment is for the system; ridl diff compares at it (§14).
  • A workspace with no system lowers nothing and draws no diagnostic; a second system is 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 }
  • offers takes one service reference. A name that is not a service is RSDL-310.
  • requires takes one interface reference. An inline-shape service's dotted name is the name of its one interface (ridl §14.5, group identity), so requires veh.diag.access is legal. A service with a list of shapes after requires is 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, B is a parse error at B (FORM-102); write requires A, requires B.
  • The same service on two offers lines, or the same interface on two requires lines, 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.
  • instances gives the component named copies (§7); external marks 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.
  • tier is optional, PLATFORM or APPLICATION (§5). A PLATFORM distribution holding a component whose requires resolves (§8) to a component in an APPLICATION distribution is RSDL-901; a distribution without tier, on either side, is exempt.
  • These rules are stated for components. A later asset declaration 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 }
}
  • for names the workspace's system, bare or qualified. A name that resolves to no declared system is RSDL-704.
  • Deployment names are unique in the workspace: two deployments with one name is RSDL-708. Several deployments for one 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, slots and budget (§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.
  • external marks 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 service lists its interfaces (ridl §14.5). It is the body item of system, distribution and machine. 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's reserved Name and the file-level import. It is the body item of component.

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:

FormNamesWhere legal
Namea component in scope; in for, the systemevery body; for
Name.instone declared instance of a componentmachine
pkg.Namea component (or the system) by qualified nameas Name
pkg.Name.instone instance, qualifiedmachine
pkg.servicea service by its global dotted nameoffers, 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.

KeyFormLegal onConsumerDiff
instances= (a, b, …) camelCase namescomponentthe instance set, §7reported under the heading of its site, §14
externalflagcomponent, machineplacement and the surface set, §9, §13reported under the heading of its site, §14
tier= PLATFORM or = APPLICATIONdistributionRSDL-901, §3.3reported under the heading of its site, §14
depth= N, integer occurrencesdeployment, placement linethe ring depth of an event channel, §13not reported: sizing, §14
slots= N, integer table entriesdeployment, placement linethe slot count of a command or query channel, §13not reported: sizing, §14
budget= N, integer bytesdeployment, placement linethe byte budget of a command or query channel, §13not reported: sizing, §14
labels= (LABEL, …)the five declarationsassurance profiles (typl §14.3)reported under the heading of its site, §14
deprecated= "reason"the five declarationsthe family lint; rsdl adds no rulereported under the heading of its site, §14
backend.keyflag or assignmentevery declaration and linethat backend, uninterpretedreported under the heading of its site, §14
  • instances must be a parenthesised list of one or more camelCase names: () and instances = solo are RSDL-305. external is a flag and takes no value: external = true is RSDL-313. tier takes 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 — instances on a line, deprecated on a line, labels on a line, tier on a component; a key twice in one block is FORM-108. The registered predicates require and ensure have 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, slots and budget are 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 as slots = 50ms, is FORM-101 and blocks the whole build. depth is 1 to 4294967295 and replaces the depth the toolchain derives from the event's timing; slots is 1 to 65536 and defaults to 16; budget is 1 to 18446744073709551615 and has no default, so only slots bounds the calls in flight. depth sizes event channels and slots and budget size 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 the deployment declaration applies to every link of the deployment. The precedence per link is the placement line, then the deployment declaration; a key declared at neither site takes the default slots, the derived depth, and no budget. RSDL-805 warns when a declared depth is below the contract bound ceil(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 declared depth.

  • A backend key is backend.key — someip.serviceId, linux.cpuset, rust.crate — a camelCase namespace, a dot, a camelCase key (typl Appendix E's camelCase_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 example encodings = ["flatbuffers"], and it is not an attribute and is not part of this namespace.

  • labels on 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 component with the external flag.
  • 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 instances has exactly one instance, named Unit in the IR and in diagnostics (Cruise.Unit) and written in source by the bare component name: Cruise in a machine body places Cruise.Unit. Declaring instances replaces the unit instance; it does not add to it. An implicit component (§6) has its unit instance only.
  • Unit is never written. Cruise.Unit in a body and Unit in an instances list are both RSDL-307. R7 keeps a declared instance camelCase, so the CamelCase Unit cannot collide with one.
  • Instance names are unique within a component; a duplicate is RSDL-306. Cruise.primary and Lane.primary are 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 requires that 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:

  1. interface → owning service: the one closure service whose shape list contains the interface (an inline-shape service owns its one interface).
  2. service → offering component: the one closure component with an offers line naming the service.
  3. 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.primary places 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 Cruise and again by Cruise.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 external component on an external machine, 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 Production and once again, elsewhere, in Bench; 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:

CrossingWhen
same machineboth instances are on one machine
off-boardthe machines differ and at least one of them is external
different machinethe 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 machine or a block under deployment, 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 external component 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 external flag; 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 external flag and its labels; and every instance of every closure component, with its component, the component's external flag and the machine the instance is placed on (§9), including an instance that has no link.
  • The link set — for every requires line, 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.toml and 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 a system or a distribution, a placement line (where an instance's placement carries its keys), an offers line, or a requires line (the link's site). labels ride beside the map per declaration.
  • The sizing values — the declared depth, slots and budget of 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: A depends on B, a different distribution, when a component in A requires an interface whose owning service a component in B offers (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 @see targets and the @since versions of the system, each component and its offers and requires lines, each distribution, each system and distribution member line, each deployment and each machine (typl §14, ADR-0026). They are documentation only: no other fact depends on them, and ridl diff at 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 diff lists it under its own heading, placement changed, with no verdict: neither compatible nor breaking. A changed labels, deprecated or 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 changed depth, slots or budget, 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, and ridl diff reads neither Deployment.sizing nor Placement.sizing.
  • A change to the closure or to a component's lines — a component added to or removed from the system, an offers or requires line added or removed, instances changed, a component made external, a labels, deprecated, tier or 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 component when 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

CodeRuleSeveritySection
RSDL-305instances is not a parenthesised list of one or more camelCase names ((), instances = solo)error§5
RSDL-306duplicate instance name in one componenterror§7
RSDL-307Unit written in source — as a declared instance name, or as the instance segment of a referenceerror§7
RSDL-308a component requires an interface listed by a service it offerserror§3.2
RSDL-309the same service on two offers lines, or the same interface on two requires lines, of one componenterror§3.2
RSDL-310an offers line names something that is not a service, or nothingerror§3.2
RSDL-311a requires line names a service whose shape is a list of interfaces — the diagnostic lists themerror§3.2
RSDL-312a requires line names something that is neither an interface nor an inline-shape service, or nothingerror§3.2
RSDL-313external written with a value — it is a flagerror§5
RSDL-403a closure component requires an interface that no closure service lists (missing provider)error§8
RSDL-408an interface listed by two services of the closure — raised for every interface they listerror§8
RSDL-409a requires resolves to a redundant provider set — an offering component with more than one instancewarning (not yet realizable)§7
RSDL-502two closure components offer one serviceerror§8
RSDL-504a member line names a service by its name while a declared component offers it — names the offerererror§6
RSDL-601more than one system in the workspaceerror§3.1
RSDL-602a system member line names nothing that is a component or a serviceerror§3.1
RSDL-603a name listed twice in one system bodyerror§3.1
RSDL-604a declaration of another profile (a type, an interface, a service) in an .rsdl fileerror§2
RSDL-701an instance of a closure component with no placement in a deploymenterror§9
RSDL-702a placement line names a component, instance or service outside the closure, or a name that resolves to nothingerror§9
RSDL-704deployment … for Y where Y resolves to no declared systemerror§3.4
RSDL-705two machines with one name in one deploymenterror§3.5
RSDL-706an instance placed twice in one deployment — also Cruise together with Cruise.primaryerror§9
RSDL-707an external machine lists an implemented componenterror§9
RSDL-708two deployments with one name in the workspaceerror§3.4
RSDL-709a depth, slots or budget value that the parser reads is not an integer within its range, or the key is written bareerror§5
RSDL-804a backend key whose namespace no configured backend claimswarning§5
RSDL-805a declared depth is below ceil(max / min) for an event a covered link consumeswarning§5
RSDL-806an event whose contract bound is not derivable is consumed by a link with no declared depthwarning§5
RSDL-807a link of the selected deployment carries an encoding the emitted backend's encodings list does not namewarning§13
RSDL-901a PLATFORM distribution holds a component whose requires resolves into an APPLICATION distribution (tier inversion); a distribution without tier is exempterror§3.3
RSDL-903a distribution member line names something outside the closure, or nothingerror§3.3
RSDL-904an implemented closure component in no distribution, while the workspace declares at least oneerror§3.3
RSDL-905a component listed by two distributionserror§3.3
RSDL-906a name listed twice in one distribution bodyerror§3.3
RSDL-907an external component listed by a distributionerror§3.3
RSDL-908tier value other than PLATFORM or APPLICATIONerror§5

16.2 Reserved

CodeReserved forReopens with
RSDL-801derived transport cannot meet a link's contract timing (feasibility)posture, §12
RSDL-803a 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.

Codev0.1 rule, in briefReason retired
RSDL-301applied name is neither a model nor a componentapplication notation dropped; a component applies nothing
RSDL-302provided output flow with no defining equationno flows or equations in a component body
RSDL-303provided service not fully covered by equationsa component offers a service whole; coverage is the implementation's
RSDL-304applied model signature mismatches the wired signalsno wiring; a model is rmdl's and is not applied here
RSDL-401conditional or dynamic instantiationinstances are a static attribute (§7); nothing is instantiated
RSDL-402wire connects flows of differing typeno wires; a link is derived from requires (§8)
RSDL-404provided output defined more than onceno outputs defined in source
RSDL-405kind-crossing wire other than event to commandno wiring; event-to-command needs a model to emit
RSDL-407instantaneous cycle among models within one leafno models composed in a component
RSDL-501consumer source distinguishes static wire from discoveryno posture exists to distinguish (§12)
RSDL-503published service name collidesthe name is ridl's; RIDL-140 alone reports it
RSDL-703placement onto a target whose capability class cannot hosttargets and capability classes replaced by named machines (§3.5)
RSDL-802no transport-policy entry for a (locality × kind)transport is configuration, not a table in the grammar (D-5)
RSDL-902bundle includes an instance placed on a different targeta distribution spans machines; installation is derived (§13)
RSDL-950reserved resilience keyword usedredundant, 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.

  1. Composition versus deployment boundary — answered. system and component state what exists and how it relates; deployment and machine state where it runs. The time base that v0.1.0 declared in a deployment is retired.
  2. 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.
  3. Transport and posture policy — moved. Transport is the implementer's configuration (§10); posture is reserved (§12).
  4. 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.
  5. End-to-end timing composition — deferred with timing feasibility and RSDL-801 (§12).
  6. Distribution dependency and versioning — narrowed. Dependencies between distributions are derived (§3.3). Version ranges and coordinated updates of several distributions remain open.
  7. 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, supervise and degraded are retired.
  8. Global service catalog scoping — unchanged. Service names stay one flat global namespace (RIDL-140).
  9. 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).
  10. 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.
  11. 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.
  12. What a breaking deployment change is — open. ridl diff lists 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 component veh.diag.access (§6). Services in the closure: veh.adas.cruise (offered by Cruise), veh.adas.lane (Lane), veh.diag.access (its implicit component). Interfaces: CruiseControl, LaneAssist, and veh.diag.access as 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.Unit same machine; Cruise.backup → Lane.Unit different machine; Panel.Unit → Cruise.primary different machine; Panel.Unit → Cruise.backup same machine; Panel.Unit → Lane.Unit different machine; Backend.Unit → Cruise.primary, Backend.Unit → Cruise.backup and Backend.Unit → veh.diag.access.Unit off-board. The three Backend links are the surface set (§13): the external side consumes.
  • Links, Bench: every link is same machine; the Backend links stay in the surface set, because the flag and not the machine defines the boundary.
  • Warnings: RSDL-409 twice, for Panel's and Backend's requires CruiseControl — Cruise is a redundant provider set. RSDL-804 for linux.cpuset when no linux backend is configured; the key is carried on Panel's placement line in Cockpit either way.
  • Regions (§13): one, veh, because the three interfaces belong to one unit. Its interfaces are adas.CruiseControl and adas.LaneAssist, named relative to the unit, and the inline shape veh.diag.access, which keeps its full dotted name.
  • Grants (§11): Cruise reaches the veh region (for LaneAssist); Panel reaches veh; Backend reaches veh, lowered with the external flag. Lane requires nothing and holds no consumer grant; its write side is read from the producers fact.
  • Distributions (§3.3, §13): Hmi depends on Adas (Panel requires interfaces Cruise and Lane offer); Adas depends on nothing. No tier inversion. Backend, external, is in no distribution. In Production, Adas is installed on AdasHpc and Cockpit, Hmi on Cockpit; in Bench, both on DevBox.

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:

TermDefinition
attribute mapthe namespaced backend keys of one node — a declaration or a line — carried into the lowered facts uninterpreted (§5, §13)
backend keyan attribute key namespaced by a backend name, backend.key, that the compiler carries and the backend validates (§5)
closurethe components a system lists, including the implicit components of listed services; every completeness check quantifies over it (§3.1)
componentone execution context; offers services, requires interfaces, may be external, has instances (§3.2)
crossing kindwhere a link runs: same machine, different machine, or off-board (§10)
deploymentone placement of the system: a named set of machines, for the system (§3.4)
distributionwhat is versioned, signed and shipped together; its installation and dependencies are derived (§3.3)
externala flag: on a component, no implementation exists in this system; on a machine, it lies outside the system's boundary (§3.2, §3.5)
grantthe set of catalog regions a component's requirements reach, resolved through each interface's owning service (§11)
implicit componentthe single-service component a lone service stands for, named by the service's name (§6)
instanceone named copy of a component; instances share the component's offers and requires and are indistinguishable on the wire (§7)
linkone consumer instance reaching one producer instance through a requires, with its crossing kind (§8, §10)
machinewhat software is installed on; lists the instances it hosts (§3.5)
member linea bare reference in a system, distribution or machine body; a reference, not a declaration (§4)
offers / requiresa component's two body lines: it offers a service and requires an interface (§3.2, §4)
placementmembership of an instance in a machine body; every instance of the closure is in exactly one machine per deployment (§9)
posturehow a service is realized on the wire, static or discovered; reserved, not derived in this release (§12)
redundant provider setthe instances of an offering component that has more than one; derived, and reported as not yet realizable (§7)
surface setper deployment, the links with an external component at exactly one end: the system's external boundary (§13)
systemthe closure: the components a deployment has to account for, and nothing else (§3.1)
tieran optional attribute of a distribution, PLATFORM or APPLICATION, checked for inversion (§3.3)
unit instancethe 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: require and ensure clauses 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

  1. Scope and Position in the Family
  2. The Layer Model
  3. Grammar
  4. The Rejection List
  5. Typing Rules — the Guaranteed Subset
  6. The Reference Environment
  7. Evaluation Domains
  8. The RIDL-306 Boundary
  9. Worked Examples — the ridl Section 13 Contracts
  10. 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/ensure predicate attributes on command and query (ridl §13; carrier production attribute = "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 expr core")
  • typl — future invariant predicates 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:

LayerReleaseStatusContent
Guaranteed subsetV1Builtcomparison, boolean connectives, arithmetic, enum access, tuple-field access, duration comparison — the forms ridl §13 names as guaranteed-supported
Function layerV2Not builtlet 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 (.). So a || b && c parses as a || (b && c), !a && b as (!a) && b, and a + b * c < d as (a + (b * c)) < d.
  • Comparison does not chain: cmp_expr admits at most one comparison operator, so a < b < c is a parse error. Write a < b && b < c.
  • Member access admits member names only: after . the grammar accepts a camelCase_id (tuple field) or a SCREAMING_SNAKE_ID (enum member), never a CamelCase_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 on 0ms as 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_lit plus 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):

  • if is an expression and else is mandatory — totality (rmdl §4.2, RMDL-107). case dispatches on enums, unions, and optionals, and must be exhaustive, with else as the explicit catch-all (rmdl §4.3, RMDL-108).
  • match in expression position is the pattern-conformance predicate (value match "pattern" — boolean), the expression form of typl's match constraint (typl §17.7c). It is not value dispatch — that is case.
  • let binds immutable locals; no reassignment, no shadowing (rmdl §3.1, RMDL-104).
  • A function_def declares 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, count over typl bounded collections — is rmdl §4.5; anonymous function(…) = expr values 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 the some/none patterns 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/ensure are 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:

  1. Recursion, direct or mutual (RMDL-101 once the function layer lands). Total functions cannot be self-referential; per-call WCET stays decidable.
  2. Loops. No loop statement exists at any layer. Iteration exists only as bounded combinators over typl bounded collections (rmdl §3.2, §4.5).
  3. last inside functions — and with it init, now, and dt (RMDL-102/103). These are model-layer constructs; expr terms are timeless and stateless. A computation that needs elapsed time takes it as an ordinary parameter.
  4. 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).
  5. 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:

DomainInhabited by
booleanbool_lit, comparison results, boolean connectives
numerica value of one named numeric type (Speed), a bare integer/float value, or a bare numeric literal
durationduration_lit (500us, 10ms, 1s) and values of the standard Duration type
enuman enum-typed value; Enum.MEMBER access
tuplea 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

OperatorOperandsResult
== !=two operands unifying in one domain: boolean, numeric of one type, duration, or one enum typeboolean
< <= > >=two operands unifying in one ordered domain: numeric of one type, or durationboolean
&& ||boolean, booleanboolean
!booleanboolean
+ - * /numeric operands, nominally consistent (one named type; literals unify)the operand type
%as arithmetic, and integer-backed operands onlythe operand type
unary -numericthe operand type
Enum.MEMBERpostfix SCREAMING_SNAKE_ID member on an enum type name in scopethat enum type
value.fieldpostfix camelCase_id member on a tuple-typed value, naming one of its fieldsthe field's type

Rules the table implies, stated explicitly:

  • The root of a require/ensure expression 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_lit parses (it is a typl literal), but any operator over strings or bytes is outside the subset; the V2 match predicate 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:

  1. Parameters of the enclosing command/query (camelCase_id).
  2. result — the query's return value; ensure only. In a require, result does not exist.
  3. The enclosing interface's own signals (camelCase_id); require only. A require reads the provider's latest published value (ridl §4.4's last-value guarantee makes this well-defined). The ridl §13 table scopes ensure to result and parameters, so a signal read in an ensure is an error.
  4. Constants — package-local and imported SCREAMING_SNAKE_ID constants (ADR-0002 import rules).
  5. Enum types (CamelCase_id), for Enum.MEMBER access.

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.3 holds. 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 (int64 microseconds, 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.0 never 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 to function definitions 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:

FormExample
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 ensureensure 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): Speed
  • command setGear(position: GearPosition), inside an interface that declares signal currentSpeed : Speed
  • const MAX_SPEED : Speed (typl §5.7)
ContractWalkRoot
require min < maxmin, max — parameters, numeric Speed; ordering over one named typeboolean
require max <= MAX_SPEEDmax — parameter Speed; MAX_SPEED — constant Speed (env item 4); ordering over one named typeboolean
require window > 0mswindow — parameter Duration; 0ms — duration literal (legal at zero in expression position, §3.1); duration orderingboolean
ensure result >= 0.0result — Speed (ensure position, env item 2); 0.0 — literal, unifies with Speed; ordering. References result, so no RIDL-305boolean
require position != GearPosition.PARK || currentSpeed == 0.0position — parameter, enum GearPosition; GearPosition.PARK — enum access, same enum; equality. currentSpeed — own signal (require position, env item 3), Speed; 0.0 unifies; equality. || over booleanboolean

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:

  1. 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 of require/ ensure is that the compiler owns them.
  2. 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.
  3. 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-ws speaks 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-rt 0.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 an attach naming 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 a request whose payload is longer than that limit corrupt (§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 nor ridl-loopback enforces 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-ws applies 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-loopback reads no descriptor.


Table of Contents

  1. Scope and Position in the Family
  2. Vocabulary
  3. The Session
  4. The Frame
  5. What Crosses per Interaction Kind
  6. The Control Plane
  7. Sequence Numbers, Loss and Duplicates
  8. Timing
  9. Invalid Payloads
  10. What a Binding Decides
  11. The Bindings
  12. Conformance
  13. 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:

LayerDefined byWhat it fixes
the contractthe .ridl source, lowered to the IRthe interfaces, their numbers, the interactions, their ordinals, kinds, payload types, timing and clauses (§11)
the logical framethis documentwhat crosses a boundary per interaction kind, and what each runtime does with it
a bindingone 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/.

NameDefined inMeaning here
CatalogRefridl_rt::contractone 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
InterfaceNoridl_rt::contractan interface's number in its catalog (ridl §11, interfaces.lock), a u32, never 0
Ordinalridl_rt::contractan interaction's position in its interface body, counted from 1 (ridl §11), a u32, never 0
Kindridl_rt::contractthe five interaction kinds, with the values Signal = 1, Event = 2, Command = 3, Query = 4, Fixed = 5
Timestampridl_rt::samplei64 microseconds since the PTP epoch, 1970-01-01 00:00:00 TAI (ridl §3.1)
Durationridl_rt::samplei64 microseconds
Enveloperidl_rt::samplethe sender's stamp: Timestamp and seq: u64 (ridl §3.1). Stamped once, at the sender, and changed by nobody after that
Provenanceridl_rt::samplewhere a signal's value comes from: Init, Live, or Invalid(Cause) (ridl §4.4, §4.5)
Causeridl_rt::samplewhy a channel is invalid: Declared by the provider, or Detected(Detection) by the consumer's binding
Detectionridl_rt::samplewhat a consumer's binding found: InvalidValue(Violation) (a typl constraint, ridl §10.2) or Corrupt (not a well-formed encoding, ridl §10.3)
Freshnessridl_rt::sampleFresh, Stale { by } or Unbounded, measured by the consumer's runtime (§8)
Correlationridl_rt::porta u64 identifying one sent call to its caller
Contractridl_rt::errorthe four contract-error categories of ridl §10.2: InvalidValue(Violation), PreconditionFailed, ContractBroken, UnknownInteraction
Transportridl_rt::errorthe detected infrastructure failures of ridl §10.3: Timeout, Undelivered, Down, Corrupt, Busy
Violationridl_rt::payloadthe 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
Encodingridl_rt::encodingthe 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:

TagEncodingEncoding::NAME
1FlatBuffersflatbuffers
2proto3proto3
3podbufpodbuf

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.

FieldDomainPresent on
formone of the ten forms belowevery frame
interfaceInterfaceNo, u32, never 0every frame but attach and attached
ordinalOrdinal, u32, never 0every frame but attach and attached
kindKind, values 1 to 5every frame but attach and attached
envelopeEnvelope: stamp: i64, seq: u64publish, occurrence, request, response
provenanceInit, Live or Invalid(Declared)publish
correlationu64: the seq of the request answeredresponse
outcome§5.3 and §5.4; §9.6 for what contract(InvalidValue) carriesresponse, attached, answer
payloadbytes in the session's encodingas §5 states per form and per kind

The ten forms, by plane and direction:

FormPlaneDirectionMeaning
attachcontrolconsumer → provideropens the session for one catalog and one encoding (§6.1)
attachedcontrolprovider → consumeraccepts or refuses the attach (§6.1)
subscribecontrolconsumer → providerstarts delivery of one signal or one event (§6.2)
answercontrolprovider → consumeraccepts or refuses a subscribe; refuses a read (§6.2, §6.3)
unsubscribecontrolconsumer → providerstops delivery of one signal or one event (§6.2)
readcontrolconsumer → providerasks for the current state of one signal, or the value of one fixed (§6.3)
publishdataprovider → consumerone publication of a signal's state, or the value of a fixed (§5.1, §5.5)
occurrencedataprovider → consumerone occurrence of an event (§5.2)
requestdataconsumer → providerone call of a command or a query (§5.3, §5.4)
responsedataprovider → consumerthe 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.

FieldOn a publish of a signal
kindSignal
envelopethe provider's stamp and the channel's seq at this publication; under Init, seq 0 and the stamp of the channel's creation
provenanceInit before the first publication; Live for a publication; Invalid(Declared) for an invalidation
payloadpresent 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 set is a publish with Live and the value;
  • an invalidate is a publish with Invalid(Declared) and no payload (ridl §4.5: the invalidity is delivered, the value is not);
  • a touch is a publish with Live and 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:

  1. discards the frame when it already holds a sample for that channel and the frame's seq is not greater than that sample's (a duplicate, or a reordering; §7); an unsubscribe drops the held sample, so a later subscribe starts again from the first frame it receives;
  2. replaces the sample's envelope and provenance with the frame's;
  3. under Live, hands the payload bytes to the generated binding, which runs Payload::verify. On success the sample's value is the decoded value and its provenance stays Live. On failure the value stays the last good value and the provenance becomes Invalid(Detected(_)) (§9.1);
  4. 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;
  5. under Invalid(Declared), keeps the last good value — or the init value, when there is none — and sets the provenance to Invalid(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.

FieldOn an occurrence
kindEvent
envelopethe provider's stamp when it raised the occurrence, and the channel's seq
provenanceabsent — an occurrence is not state and has no provenance (ridl §4.4, last paragraph)
payloadalways 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.

FieldOn the requestOn the response
kindCommandCommand
envelopethe 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
correlationabsent — the request's own seq is what the response echoesthe request's seq
outcomeabsentaccepted, contract(Contract), corrupt, or busy
payloadalways present: the argument valueabsent — 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:

  1. runs Payload::verify over the argument bytes; a structure failure settles corrupt, a typl violation settles contract(InvalidValue(violation));
  2. evaluates the require clauses; a false clause settles contract(PreconditionFailed);
  3. 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.

FieldOn the requestOn the response
kindQueryQuery
envelopeas for a commandas for a command
correlationabsentthe request's seq
outcomeabsentreply, contract(Contract), corrupt, or busy
payloadalways present: the argument valuepresent 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).

FieldOn a publish of a fixed
kindFixed
envelopethe provider's stamp when it answered, and seq 0 — a fixed has no sequence: it never changes
provenanceLive
payloadalways 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.

KindFramesenvelopeprovenancecorrelationoutcomepayload
signalpublishprovider stamp, channel seqInit / Live / Invalid(Declared)——under Live only
eventoccurrenceprovider stamp, channel seq———always
commandrequest / responsecaller stamp, caller seq / provider's—— / request seq— / accepted, contract, corrupt, busyalways / —
queryrequest / responsecaller stamp, caller seq / provider's—— / request seq— / reply, contract, corrupt, busyalways / under reply only
fixedpublish (on read)provider stamp, seq 0Live——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:

FieldDomain
frame_versionu32: the version of this document's frame; 1 here
catalogCatalogRef: the unit name and the 32-byte hash
encodingthe 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), with reason one of unknown_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, or range (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 request is answered with a response carrying contract(UnknownInteraction) and the request's seq as correlation;
  • a subscribe is answered with an answer carrying contract(UnknownInteraction);
  • a read is answered as §6.3 states;
  • a publish, an occurrence, a response or an unsubscribe is 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; seq 0 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 seq is unique per caller, not per channel (ADR-0021 decision 5), and it is what a response echoes as correlation.
  • 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).

BoundOn a signalOn an eventOn a command or a query
max, staleness boundthe consuming runtime computes Freshness on every read: Fresh while now − stamp ≤ max, else Stale { by: now − stamp − max }; Unbounded with no maxthe consuming runtime discards an occurrence with now − stamp > max inside EventSource::nextthe 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 floorthe providing runtime coalesces a faster set into the next publicationthe providing runtime refuses a faster raise with RaiseError::Busy, or delays itthe 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.

  1. 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 Detached and Transport::Down have a trigger.
  2. The header encoding. The byte layout, field order and width of every header field in §4, and how the form is carried. InterfaceNo and Ordinal keep their 32-bit range or the binding refuses at attach (§4).
  3. The encoding tag. Whether the binding carries any of the three encodings, the attach tag 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.
  4. 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).
  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.
  6. Retention of a settled response for a call with no response bound (§7).
  7. Grouping. Whether the frames of one commit cross as one unit (§5.1), and therefore whether a runtime over this binding presents CoherentSignals.
  8. 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.
  9. 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:

FormBytesHeader size
attachform u8, frame_version u32, encoding u8, hash 32 bytes, name_len u8 (1 to 255), name UTF-8 (name_len bytes)39 + name
attachedform u8, outcome u8, detail u83
subscribe, unsubscribe, readform u8, kind u8, interface u32, ordinal u3210
answerthe common 10, then outcome u8, detail u812
publishthe common 10, then stamp i64, seq u64, provenance u827
occurrence, requestthe common 10, then stamp i64, seq u6426
responsethe common 10, then stamp i64, seq u64, correlation u64, outcome u8, detail u836

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.

IdVectorHex
attach-1attach frame 1, FlatBuffers, veh.cabin01 01000000 01 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f 09 7665682e636162696e
attached-acceptedattached accepted02 01 00
attached-mismatchattached refused catalog_mismatch02 09 02
subscribe-signalsubscribe signal, interface 1, ordinal 203 01 01000000 02000000
answer-acceptedanswer accepted, signal, interface 1, ordinal 204 01 01000000 02000000 01 00
publish-livepublish Live, seq 7, payload ff0107 01 01000000 02000000 ab89674523010000 0700000000000000 02 ff01
request-commandrequest command, interface 1, ordinal 3, seq 1, payload 0109 03 01000000 03000000 ab89674523010000 0100000000000000 01
response-acceptedresponse command accepted, correlation 1, provider seq 10a 03 01000000 03000000 ac89674523010000 0100000000000000 0100000000000000 01 00
response-violationresponse query contract(InvalidValue(Range)), correlation 20a 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.

  1. An attach naming a catalog the provider serves under a hash that is neither the provider's own nor one its descriptor lists as compatible is refused catalog_mismatch, and no other frame is exchanged; one naming a listed earlier hash is accepted (§6.1; the toolchain writes the list, and ridl-transport-ws with a member table accepts a listed hash, per the As-built note).
  2. A subscribe to a signal is followed by exactly one publish before any provider action: Init, seq 0, no payload, before a first publication; the last publication otherwise (§5.1, §6.2).
  3. A subscribe to an event is followed by nothing until the provider raises one, and an occurrence raised before the answer never arrives (§5.2).
  4. A set and an invalidate of one channel, committed, each arrive as one publish whose seq is one greater than the previous; the invalidation carries no payload, and the consumer's sample keeps the last good value under Invalid(Declared) with the invalidation's envelope (§5.1).
  5. Two publish frames of one channel delivered in the wrong order leave the consumer holding the one with the greater seq (§7).
  6. A publish whose payload breaks a typl constraint leaves the consumer holding the last good value under Invalid(Detected(InvalidValue(_))) with the rejected frame's envelope, and the next valid publish returns the channel to Live (§9.1).
  7. An occurrence whose payload is not a well-formed encoding is delivered as Err(Corrupt) with its envelope, not dropped (§9.2).
  8. A command whose require clause is false at the caller sends no request (§5.3).
  9. A command request whose argument breaks a typl constraint is answered contract(InvalidValue(_)) and the provider's method is not called; one whose require clause is false at the provider is answered contract(PreconditionFailed) likewise; one that passes both is answered accepted before the provider's method runs (§5.3).
  10. A query whose ensure clause is false is answered contract(ContractBroken) with no payload; one that passes is answered reply with the reply payload after the provider's method returns (§5.4).
  11. A request retransmitted with the same envelope after its response was sent is not presented to application code again and receives the same response (§7).
  12. Two callers whose first calls both carry seq 1 are presented as two claims (§7).
  13. A request naming an ordinal the catalog does not hold, or holding it under another kind, is answered contract(UnknownInteraction) (§6.4).
  14. A read of a fixed is answered with one publish of kind Fixed, seq 0 and the value; a subscribe to it is refused (§5.5, §6.2).
  15. The frames of one commit carry one stamp (§5.1).
  16. No frame carries a timing bound, an init value, a ClaimId, a Correlation as the port hands it out, a Detected(_) provenance, or a Timeout, Undelivered or Down outcome (§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-rt 0.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-rt 0.1 carries no Family and the IR has no family field yet; it returns as a Member field, 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, busy as a response outcome, driftsys/ridl#510); ADR-0015 decisions 3, 4 (every call has a response bound, §11.1 item 5), 10 and 17
  • Vocabulary: the ridl-rt design record, and crates/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-loopback design 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

  1. Scope
  2. The Encodings
  3. What Canonical Fixes
  4. The Nesting Bound
  5. The Derived Encodings
  6. The Compatibility Rule
  7. Versioning
  8. Reading the IR from Another Language
  9. 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:

PackageMessagesArtifacts
ridl.ir.v2Package (ir.proto)<base>.ir.{json,txtpb,binpb}, every committed baseline
ridl.ir.v2System (system.proto, ADR-0022)<pkg.Name>.system.{json,txtpb,binpb}
ridl.codegen.v1the lowered codegen model and its two envelopesthe 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.

EncodingFlag valueExtensionStatusUse
canonical protobuf JSONir-json.jsoncanonicalinterchange, baselines, goldens, a plugin's request
protobuf binaryir-binary.binpbderiveda compact form for a consumer that wants one (§5)
prototextir-text.txtpbderivedinspection (§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's json_format all 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.

  1. The mapping. The protobuf JSON mapping over the schema of record: field keys in lowerCamelCase as the mapping derives them from the .proto field names (json_name is never set), enum values as their names, a oneof flattened to its member's key, 64-bit integers (int64, uint64) as decimal strings (ADR-0014 decision 8), bytes as base64, and string as UTF-8 with only the escapes RFC 8259 requires. The schema declares no float and no double field — 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.
  2. Presence. A non-optional field holding its default is emitted; an unset proto3 optional field, an unset message field and an unset oneof produce no key. null never appears (ADR-0014 decision 2).
  3. Order. Fields appear in the order the .proto declares 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 no map<> field; adding one is a breaking change (§6), because a map's key order would need a rule this list does not have.
  4. Layout. Pretty-printed: two-space indentation, one field per line, \n line endings on every host, no trailing newline, and no byte-order mark.
  5. No host input. Nothing in the writer reads the locale, the clock, an environment variable, a hash seed or the platform's line ending.
  6. 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, null for 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 later ridl (ADR-0020 decision 9). It rejects an unknown enum name, as the strict reader does, so a plugin built on an older ridl-ir rejects a request that carries a newer enum value and must be rebuilt against the ridl-ir that 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.

QuantityValue
type nesting levels the front end admits127
message levels below the Package root, deepest admitted IR386
JSON levels of its canonical artifact516
JSON levels this toolchain's own reader accepts1,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.

ChangeRule
a new field, with a number never used in that messageadditive, 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 memberadditive on the same condition; an old reader sees the oneof unset
a new enum valuemade 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 fieldadditive
a comment, a doc string, a deprecated optionadditive
retiring a field's meaningadditive 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 oneof member;
  • 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_name option 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 (repeated or not) or its presence (optional or 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 schema field 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.

ClaimWhere
every IR the front end admits round-trips through the canonical encoding, and writing it twice gives the same bytescrates/ridlc/tests/ir_canonical.rs, over every corpus package and every lowered system
the numbers of §4the same file, over the deepest package the front end admits, in all three shapes
the binary bound of §5crates/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.