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.