What this chapter is for. It is the contract between this toolchain and a program that reads its intermediate representation — a codegen plugin, a third-party backend, a tool that diffs artifacts. Nothing in this repository needs it: every in-tree backend links the IR types directly. Read it if you are writing a consumer outside this repository, or if you are changing the IR schema and need to know which changes a consumer can absorb.

IR Specification

The encodings of the intermediate representation, its canonical form, and the stability it promises — what a consumer outside this repository reads, what byte-identical means for it, how deep it nests, which schema changes it may expect without being rebuilt, and how a change it cannot absorb is versioned.

Version: 0.1.0 — Draft

Provenance. This document is the canonical-form policy of the IR (driftsys/ridl#321). It writes down the canonical-form policy of ADR-0014 decision 9, as that decision was amended on 2026-09-22: the canonical encoding is canonical protobuf JSON, and the binary and prototext encodings are derived. The reasoning trail, including the measurement that moved the decision, is the IR stability design note. Where this document and an ADR disagree, the ADR wins and this document is corrected.

Partial. The document inventory (family overview §2) gives the IR specification four subjects: serialization, the plugin protocol, the diff categories, and the canonical encoding. This version owns the first and the last, which is what driftsys/ridl#321 asked for. The plugin protocol is specified in the codegen plugin design; the diff categories are stated today by the typl language reference §7.4, the ridl language reference §11 and the rsdl language reference §14, and move here when a story moves them.


Table of Contents

  1. Scope
  2. The Encodings
  3. What Canonical Fixes
  4. The Nesting Bound
  5. The Derived Encodings
  6. The Compatibility Rule
  7. Versioning
  8. Reading the IR from Another Language
  9. Conformance

1. Scope

The schema of record is the protobuf in crates/ridl-ir/proto/, compiled with prost (ADR-0004 §4). This document does not restate it; it states how that schema is encoded on a surface a consumer reads, and what may change under a consumer without breaking it.

It binds three schema packages:

PackageMessagesArtifacts
ridl.ir.v2Package (ir.proto)<base>.ir.{json,txtpb,binpb}, every committed baseline
ridl.ir.v2System (system.proto, ADR-0022)<pkg.Name>.system.{json,txtpb,binpb}
ridl.codegen.v1the lowered codegen model and its two envelopesthe request and response of a codegen plugin

The word IR below means all three unless a sentence says otherwise. ridl.codegen.v1 is defined in crates/ridl-ir/proto/ridl/codegen/v1/ (plugin.proto, model.proto and deployment.proto). The rules here were written before that package existed, so that a plugin built outside this repository could rely on them before the package was written.

2. The Encodings

Three encodings of the same schema are emittable (ADR-0014 decision 4). One is canonical; the other two are derived from it in the sense that they carry the same message and are produced by the same writer, and that where they disagree with the canonical encoding about what this toolchain can read back, the canonical encoding is the one that holds.

EncodingFlag valueExtensionStatusUse
canonical protobuf JSONir-json.jsoncanonicalinterchange, baselines, goldens, a plugin's request
protobuf binaryir-binary.binpbderiveda compact form for a consumer that wants one (§5)
prototextir-text.txtpbderivedinspection (§5)

Canonical protobuf JSON is the canonical encoding. It is the protobuf JSON mapping applied to the schema of record, as §3 fixes it. Two facts decided this, both measured on 2026-09-22 and recorded in the design note:

  • It round-trips every IR this toolchain's front end admits. The binary encoding does not: its reader stops at 100 message levels below the root, which legal source reaches (§5).
  • Every consumer named by any record of this repository — Rust, Kotlin, TypeScript, and the Deno, Python and JVM plugin hosts ADR-0020 decision 10 lists — parses JSON with its standard library, so reading the IR needs no protobuf toolchain. A typed reader exists where one is wanted: protobuf-java's JsonFormat, protobuf-es and protobuf-python's json_format all implement the same mapping.

The cost is size and speed: on the two veh-cluster corpus packages, binary is 6 to 11 times smaller and 5 to 9 times faster than JSON, and the largest of them is 55 kB of JSON, 64 µs to write and 239 µs to read. No path in this toolchain carries that cost measurably — a build writes each artifact once, and a plugin process costs a millisecond to start before it reads anything.

3. What Canonical Fixes

A canonical artifact is byte-identical for equal IR across runs, hosts and platforms. That claim rests on six properties. Each is part of the contract, and a change to any of them is a breaking change under §6.

  1. The mapping. The protobuf JSON mapping over the schema of record: field keys in lowerCamelCase as the mapping derives them from the .proto field names (json_name is never set), enum values as their names, a oneof flattened to its member's key, 64-bit integers (int64, uint64) as decimal strings (ADR-0014 decision 8), bytes as base64, and string as UTF-8 with only the escapes RFC 8259 requires. The schema declares no float and no double field — every numeric value in the IR is an exact decimal string (ADR-0007 decision 9) — so no float formatting rule is needed and none is stated.
  2. Presence. A non-optional field holding its default is emitted; an unset proto3 optional field, an unset message field and an unset oneof produce no key. null never appears (ADR-0014 decision 2).
  3. Order. Fields appear in the order the .proto declares them within each message. A repeated field's elements appear in the order the writer holds them, which for every list in the IR is source order or an order the schema's own comments name per field. The schema declares no map<> field; adding one is a breaking change (§6), because a map's key order would need a rule this list does not have.
  4. Layout. Pretty-printed: two-space indentation, one field per line, \n line endings on every host, no trailing newline, and no byte-order mark.
  5. No host input. Nothing in the writer reads the locale, the clock, an environment variable, a hash seed or the platform's line ending.
  6. One form, not two. The bytes a plugin reads on its standard input are the bytes in the file: there is no compact variant for inter-process transport and no second dialect. A plugin's test fixture is therefore a file this toolchain wrote.

The reader side carries two different obligations, because the two readers are in different positions.

  • This toolchain's own reader is strict. It refuses an unknown key, an out-of-range numeric enum value, null for a repeated field, a duplicate key and a float-form integer (ADR-0014 decisions 11 and 14). Its input is its own output or a committed baseline, and the strictness is what makes the conformance claim measurable. The one exception is the codegen request reader, ridl_ir::codegen::request_from_json, which ignores an unknown key so that a plugin keeps reading the requests of a later ridl (ADR-0020 decision 9). It rejects an unknown enum name, as the strict reader does, so a plugin built on an older ridl-ir rejects a request that carries a newer enum value and must be rebuilt against the ridl-ir that adds it.
  • A consumer's reader is lenient on unknown keys. A key it does not know is ignored, an absent key is the field's default, and a 64-bit value is parsed from its string form. This is proto3's rule for unknown fields, and it is what the additive changes of §6 rely on. A consumer may refuse a request whose schema package it does not know (§7); it must not refuse one that carries a key it does not know.

4. The Nesting Bound

The IR is a tree, and a type nests. A reader has to provision for the deepest tree this toolchain can write, so that number is stated rather than left to be discovered.

QuantityValue
type nesting levels the front end admits127
message levels below the Package root, deepest admitted IR386
JSON levels of its canonical artifact516
JSON levels this toolchain's own reader accepts1,000

The front end refuses type nesting at 128 levels with FORM-102 (MAX_TYPE_DEPTH, crates/ridl-syntax/src/parser.rs), so 127 is the deepest that compiles. The other two numbers are the per-level cost of the schema multiplied by that limit, for the most expensive shape the language has. The cost per source level is two message levels for an array (FieldType, ArrayType), two for a map (FieldType, MapType) and three for a tuple (FieldType, TupleType, TupleField); in JSON a level costs two brackets for an array or a map and four for a tuple. The tuple is therefore the shape that binds, at 386 message levels and 516 JSON levels; the array shape at the same source depth reaches 259 and 262. A contract expression is bounded by the same limit — the front end builds an expression tree at most 128 nodes high, and each binary operator, member access, group and prefix raises that height by one level — but it reaches the IR as canonical text in Contract.source, so it adds no message level.

A reader of the canonical encoding must accept at least 516 JSON levels. A reader built on a schema-typed protobuf JSON parser must in addition accept at least 386 message levels. Provisioning 1,000 is recommended, because that is this toolchain's own figure and it makes the two readers equal.

The obligation is real in more than one language. serde_json stops at 128 JSON levels by default, protobuf-java at 100 message levels, Jackson at 1,000, and JavaScript's JSON.parse has no limit of its own. A reader in the first two has one configuration step to make before it can read what this toolchain legally writes.

The three numbers move together, and only with an edit to MAX_TYPE_DEPTH or to the schema's per-level cost. Either edit restates this section and moves the test that pins it (crates/ridlc/tests/ir_canonical.rs).

5. The Derived Encodings

Binary. --emit ir-binary writes <base>.ir.binpb and <pkg.Name>.system.binpb. Its writer is unrestricted, and its reader is not: a binary artifact is readable by this toolchain up to 100 message levels below the root, and one message level past that the decode fails with a recursion error. The limit is prost's RECURSION_LIMIT, decremented once per nested message on decode and not consulted on encode; protobuf-java and the C++ runtime default to the same 100, so it is not one implementation's quirk.

That bound is below what the front end admits. A struct field nesting 48 arrays, 47 maps or 32 tuples — all legal source, which the checker accepts and every backend emits — produces an artifact to_binary writes correctly and from_binary refuses (driftsys/ridl#231). The emit does not refuse to write such a package: the bytes are a correct encoding of it, and a consumer that raises its own limit reads them. This asymmetry is why binary is derived and not canonical.

ridl diff and ridl check --baseline read .ir.json only and refuse the other two encodings by name (ADR-0014 decision 5). A committed baseline has to be reviewable in a pull request.

Prototext. --emit ir-text writes <base>.ir.txtpb. It is for inspection. It is not a recommended interchange form, because TypeScript — a named target — has no usable text-format parser. This toolchain writes prototext and does not read it on any command path.

6. The Compatibility Rule

Amended 2026-10-11 by ADR-0029. The codegen protocol messages of §6 and §7 are a frozen surface from 0.10.0, checked by just freeze-check.

Within one schema package, a change is additive when a reader built against the schema before the change reads the writer's output after it and sees the meaning it saw before, and a reader built after the change reads output from before it and sees the field's default where the writer wrote nothing. An additive change is made in place. A new enum value is also made in place, with the limit its row states.

ChangeRule
a new field, with a number never used in that messageadditive, if its default carries the meaning the old writer implied by omitting it; a new field a reader cannot ignore is breaking
a new oneof memberadditive on the same condition; an old reader sees the oneof unset
a new enum valuemade in place, but not additive for a reader that rejects an unknown enum name, as every ridl_ir reader does (§3): built on an older schema, that reader rejects an input that carries the new value and must be rebuilt against the schema that adds it. The IR's enums are closed and the checker never writes a value outside the schema
a new message, reached only through a new fieldadditive
a comment, a doc string, a deprecated optionadditive
retiring a field's meaningadditive only as [deprecated = true] with the field kept, still written and still read; the field itself leaves in the next major package (§7)

Everything else is breaking, and is never made in place:

  • removing a field, an enum value or a oneof member;
  • renaming a field. In the canonical encoding the key is the field's name through the JSON mapping, so a rename changes the bytes even though the wire number does not move. The json_name option is not an escape from this, because it would make the schema and the bytes disagree;
  • renumbering a field or an enum value;
  • changing a field's type, its cardinality (repeated or not) or its presence (optional or not);
  • moving a field into or out of a oneof;
  • changing the meaning of an existing value, of a default, or of the order of a repeated field;
  • adding a map<> field (§3 item 3);
  • changing any item of §3, or lowering any number of §4.

The rename entry is the one that differs from protobuf's own guidance, which reasons about the binary wire format where a rename is invisible. The canonical encoding is the contract here, and in it a rename is visible.

7. Versioning

A breaking change is a new schema package, never an edit to the old one.

A breaking change to ridl.ir.v2 is made by adding ridl.ir.v3 as crates/ridl-ir/proto/ridl/ir/v3/{ir,system}.proto, compiled beside v2, with this toolchain's writers and readers moved onto it. v2 is not edited for it, keeps compiling, and is removed when its last consumer has moved — which is how v1 was removed. The same rule governs ridl.codegen.v1, whose successor is ridl.codegen.v2.

The artifact file names do not change with the package. <base>.ir.json stays <base>.ir.json: the name drives snapshot detection and is cited across the records, and the schema a reader is built against is what tells the versions apart.

The IR artifacts carry no version field of their own. The proto3 JSON mapping has no envelope, ridl diff reads a baseline with the toolchain that wrote it or one of the same major version, and adding a field to Package would move every golden for a fact the file name and the reader's schema already fix.

A codegen request is different, because the process that reads it is not this toolchain and may be older or newer than it. It carries the version as its first two fields, so that a reader meets them before anything it might not understand:

string schema = 1;     // "ridl.codegen.v1" — the package this request is encoded from
string toolchain = 2;  // the ridlc version that wrote it, for example "0.2.0"

A consumer refuses a schema it does not know, with a diagnostic naming both values. It does not refuse on toolchain, which is informational: a consumer that needs a fact only a later toolchain writes finds the field absent and reports that, under the lenient-reader rule of §3.

8. Reading the IR from Another Language

For an implementer outside this repository, the whole obligation is:

  • Parse the artifact, or the request on standard input, as JSON. Any parser will do that accepts at least 516 levels of nesting (1,000 recommended, §4), ignores keys it does not know, and reads 64-bit integers from their decimal string form (§3).
  • Read the schema field first on a request, and refuse a package you do not know (§7).
  • Expect new keys over time and ignore them; expect no key to be removed or renamed within a schema package (§6).
  • Use a file this toolchain wrote as a fixture. The bytes on the pipe and the bytes in the file are the same (§3 item 6).

9. Conformance

Three claims of this document are measured by the test suite rather than asserted here.

ClaimWhere
every IR the front end admits round-trips through the canonical encoding, and writing it twice gives the same bytescrates/ridlc/tests/ir_canonical.rs, over every corpus package and every lowered system
the numbers of §4the same file, over the deepest package the front end admits, in all three shapes
the binary bound of §5crates/ridl-ir/src/lib.rs, at 100 message levels and at 101

The strictness of this toolchain's reader (§3) is pinned separately, one test per narrowing, in crates/ridl-ir/src/lib.rs.

There is no cross-language conformance test. The strongest available evidence would be a second implementation reading an artifact this one wrote; it does not exist yet, and this document is written so that it can be built.