Skip to content

Schema compatibility

Status: under discussion · tracked in core#119 (with core#122 for versions before 1.0 and author-set bumps) · nothing here is implemented

The question: which schema changes are breaking, and so which version bump a build takes. A bump is only worth something if it tells a reader whether an old peer can still talk to a new one, and whether code written against the old version still finds what it uses. Today it says neither reliably.

Where things stand

Versioning rules describes the bump a build takes. The comparison behind it (core/src/schema/ir/diff.rs) looks at a fraction of the schema:

  • Not compared at all:
  • annotations, including @framing and @validators;
  • type aliases and validator declarations;
  • constants, beyond their docstring;
  • an error's fields, message and ordinal;
  • argument names;
  • a field's optional flag.
  • Removing an optional field reports nothing.
  • Types are compared as written, so changing string to Handle (type Handle = string) is reported as breaking (core#110).
  • Schema files are paired by their position in the glob, not by namespace.

core#118 lists these.

The rules it does apply also disagree with the wire in places, because what a change breaks depends on how it travels:

  • MessagePack encodes a struct as a positional array. Generated Rust has no #[serde(default)], so adding even an optional field at the end breaks the other side, and so does reordering fields. JSON keys fields by name and tolerates both. Today adding an optional field is minor, whichever format the package uses.
  • datagram framing addresses a call by its function's declaration index. Inserting a function before others re-addresses them. jsonrpc addresses calls by name.
  • An error travels as its ordinal. Local errors are numbered in declaration order, so inserting one renumbers the rest.
  • Enum variants travel by name, so reordering them changes nothing.

Finally, the handshake compares a hash of the whole frozen schema, spans and docstrings included. Any edit, even a reformat, makes old and new peers refuse each other, so no bump can be relied on at runtime yet. That is fixed first: a handshake that checks compatibility instead of exact identity (comline@2, runtime#15) lands before the rules below are implemented, so that they mean something at runtime from the start. Its design is in Checking compatibility.

What "breaking" protects

A change is breaking if an old peer and a new peer can no longer exchange messages, or if a name that consumers refer to disappears.

The names are those of a type, member, alias, constant, validator, or a generated error variant. Generated code doesn't have to stay source-compatible in every respect. Adding a field to a Rust struct breaks a consumer's struct literal, and counting that would make almost every change major.

  • Minor: an addition that an old peer and old consumer code don't depend on.
  • Patch: a change that alters nothing on the wire and removes no name, such as documentation, advisory annotations or a respelled type.

Versioned formats and framings

The rules depend on the encoding, so the encoding must be named exactly, and the package must say which ones it speaks.

  • Every format and framing gets a version: json@1, msgpack@1, datagram@1, jsonrpc@1. A third-party one is namespaced (com.acme.cbor@1). A later encoding, such as a name-keyed msgpack@2 (runtime#16), gets a new version instead of silently changing what "msgpack" means.
  • A name must be specific. @framing = "jsonrpc" becomes a build error that suggests "jsonrpc@1", with an editor quick fix, and there is no deprecation period. The handshake carries the versioned names (runtime#14).
  • The package declares what it speaks in config.idp (core#117):
wire_formats = ["json@1", "msgpack@1"]
framings = ["datagram@1", "jsonrpc@1"]

The key names are open. The package's own choice of one format and one framing has shipped as config.idp's wire = { format, framing } (Package wire defaults); declaring several that peers may pick from is still this proposal's. - Defaults belong to the producer. A protocol's framing is resolved, most specific first, from: 1. its own @framing; 2. the package default in config.idp; 3. core's built-in default, datagram@2 (was datagram@1; see below).

The framing is part of the contract, so the consumer has no say: default_framing leaves comline.toml, and using it there becomes an error pointing to config.idp. Today two consumers of one package can generate clients that can't talk to each other, with nothing in the package showing it. Two safeguards keep this sound: - The resolved framing is frozen per protocol into the compiled schema, so the comparison and the generators read one value. A change at any of the three levels shows as a change to that protocol. - Core's default only changes in a new edition (specification_version, core#102), never between releases, because every package relying on it has it in its contract. Amended: before 1.0 the default changed once without an edition, from datagram@1 to datagram@2 (Package wire defaults). The rule holds from 1.0 on.

This part has shipped: wire.framing, default_framing refused in comline.toml, and the resolved framing frozen per protocol.

The package default must be one of the declared framings; with only one declared, that one is the default. - The package default isn't gated. Writing it in the package's own config.idp is deliberate enough. The annotations.framing.allowed gate stays on per-protocol @framing overrides, since mixing framings within one package is the less common case. - One framing per protocol. A protocol isn't served under several framings. A consumer behind a gateway (JSON-RPC, say) talks to the gateway's contract, and the gateway translates. A producer that wants two framings declares two protocols, which states it more plainly. Handshakes resolve the same way: core's default is comline@1. Formats don't need a default, since the call site picks among the declared ones. - The comparison applies the strictest rules among the declared encodings. With none declared, the package is taken to speak every known one. - Generated code offers only the declared formats (generation#67). In Rust, this could be a sealed trait implemented only for them.

Encoding profiles

A format doesn't declare which bumps its changes take. It declares facts about its encoding, and the comparison turns those facts into bumps with one set of rules.

The facts are what can be tested. "json@1 ignores an unknown field" is true or false, and a conformance vector shows which; "adding an optional field is minor under json@1" has no such test. If every format carried its own table of bumps, each could quietly decide policy differently, and the rules would be copied once per format.

What a profile states

A format and a framing are chosen separately, so each has its own profile:

// msgpack@1 (format)
{
  "struct_fields": "by_position",   // or "by_name"
  "unknown_field": "rejected",      // or "ignored"
  "missing_optional": "rejected",   // or "absent"
  "enum_variants": "by_name",       // or "by_index"
  "unknown_variant": "rejected",    // or "tolerated"
  "integer_width": "by_value"       // a wider integer decodes if its value fits
}

// datagram@1 (framing)
{
  "calls": "by_index",              // jsonrpc@1: "by_name"; comline.packet@1: "by_id"
  "errors": "by_ordinal",
  "one_way": "no_reply_frame"
}

The property set is small and fixed, so the comparison knows every value it can meet. A new property is a change to the comparison too, not something a format can add on its own.

How a rule reads them

Each rule that depends on the encoding is written once, against properties:

  • Add an optional field: major if struct_fields is by_position, unknown_field is rejected or missing_optional is rejected; minor otherwise.
  • Insert a function before others: major if calls is by_index; none otherwise (by_name, or by_id, where each function states its id).
  • Reorder enum variants: major if enum_variants is by_index; none otherwise.

Over the formats and framings a package declares, the strictest result wins. The reported reason names the property that decided it: "major under msgpack@1: fields are positional". That reason tells the author what to change, or which encoding to drop.

Some outcomes depend on values, not shape. A wider integer only fails on a value the old reader can't hold. These stay major, with the reason saying so, rather than introducing a "maybe" level.

Where profiles live

  1. The runtime's conformance corpus is the source. It holds one profile per versioned name beside features.json, for example runtime/conformance/profiles/msgpack@1.json. Every property is backed by a vector:
  2. "msgpack@1 rejects an unknown field" is a vector with "expect": "reject";
  3. "json@1 ignores one" is a vector that decodes.

A profile then can't claim something the runtime doesn't do, the same guarantee the support tables already have. 2. Core keeps a pinned copy for the encodings it knows, because core can't depend on the runtime. It's drift-checked against the corpus, the way comline-typescript vendors the vectors. 3. A profile is fixed for its version. If a property changes, that is a new version, and that defines what the @N in a name means. An encoding's parameters can set profile facts too: msgpack@2 keys struct fields by name or by field id, chosen with keys = "name" | "id", and each value sets the facts for that choice (runtime#16). 4. An encoding core doesn't know is compared under the strictest profile, with a warning saying so.

The config.idp declaration stays names only; the profiles come from the corpus.

The rules

Types are compared after resolving aliases, each version through its own. The bump a build takes is still the largest of its changes.

Where a row below names a format or framing, that's the rule applied to today's profiles; the rule itself is written against the property, as above.

Fields (structs, and error bodies)

Change Bump Why
add an optional field minor under json@1; major under msgpack@1 and packed@1 JSON readers ignore unknown fields and read a missing one as absent; msgpack@1 and packed@1 are positional
add a required field major an old writer never sends it
remove a field major a name disappears (and a required one stops arriving)
required → optional major a new writer may send null, which an old reader rejects
optional → required major an old writer may send null
reorder fields none under json@1; major under msgpack@1 and packed@1
change the type major including widening: an old reader fails on a value outside its range, and in TypeScript u32 → u64 changes number to bigint
swap the type for an equivalent alias, either way patch same bytes, same generated type; reported as "same type, respelled"
change the default patch defaults never reach the wire

Enums

Change Bump Why
add a variant minor with a caveat for a closed enum: an old reader can't decode the new variant, so a peer must not send it to one that predates it. comline@2's degrade will enforce that. Under an @open enum there's no caveat
mark an enum @open, or remove the mark major generated code gains or loses the Unknown case
remove or rename a variant major
reorder variants none variants travel by name

Unknown variants (decided 2026-10-20, runtime#17):

  • Strict by default: an unknown variant fails to decode. That gives up nothing, since comline@2 stops a newer peer from sending it.
  • @open enums, opt-in: generated code gets an extra Unknown(raw value) case that code must handle, as Protobuf's open enums and Rust's #[non_exhaustive] do. The schema declares it, so nothing is given up.
  • No silent fallback to a default: that would misread data unnoticed, giving up encoding conformance. It isn't offered.

Protocols and functions

Change Bump Why
add a function at the end minor
insert a function before others, or reorder major under datagram@1; none under jsonrpc@1 datagram@1 calls by index
remove a function major
rename, add, remove or reorder an argument major JSON keys arguments by name; MessagePack and Rust's positional signature by order
change the return type major
switch between one-way and request/response major one side waits for a reply the other never sends, or gets one it doesn't expect
add an error to ! minor an old client receives it as Remote
remove an error from ! major a generated error variant disappears
a protocol's resolved framing changes, at any level (@framing, the package default, or core's default in a new edition) major compared in canonical, versioned form
a protocol's resolved handshake changes, at any level major the first frame on the wire changes (handshakes)
a guarantee given up (@unverified, giving up a guarantee), or one more added to the set major peers lose a guarantee they relied on
a given-up guarantee restored minor
the reason for giving one up changed patch

Errors

Change Bump Why
append a new error minor
insert, remove or reorder local errors, so that an ordinal changes major the ordinal is what travels
change the fields as for fields the body is encoded like a struct
change the message patch rendered client-side, never sent

Constants, aliases and validators

Change Bump Why
add a constant, alias or validator minor
remove one major a name disappears
change a constant's type major
change a constant's value by what it reaches see below
change an alias's target major, at every use
a field's effective validators change, other than by removal major what's accepted narrows; with validate_on_decode, old data is rejected
validators only removed from a field minor what's accepted widens

A field's effective validators are those on its alias chain, innermost first, then its own, with every argument resolved against its default. If a validator in that set changes its body or its targets, the set has changed too. Telling a genuine tightening from a loosening in general would mean comparing conditions. So any change other than pure removal counts as narrowing.

  • Reported as its own category (decided 2026-10-20): a narrowed validator set appears as "validation tightened", not as a generic major change, so a reader can tell it from a change of shape.
  • A constant's value counts by what it reaches (decided 2026-10-20). Constants never travel, but they often encode limits both peers rely on, such as a maximum length:
  • where it feeds a validator's argument or a bound, the validator rules apply;
  • where it's a field's default, it's a patch, as default changes are;
  • where it reaches nothing on the wire, it's minor.

Annotations and documentation

Change Bump
@deprecated added minor
@deprecated removed, or its message changed patch
@timeout_ms, @idempotent, or an annotation Comline doesn't know patch
@settings, or a settings block none: these only govern the build
any docstring, on a declaration or a member patch

The package

Change Bump
a declared format or framing removed major
a declared format or framing added minor
a namespace (schema file) renamed major, reported as a rename
a protocol renamed major, reported as a rename

Schema files are matched by namespace, so adding, renaming or reordering files doesn't pair the wrong ones.

Renames

A rename is major because of names, not the wire. Every generated path goes through the namespace and the protocol name: crate::chat::Message, a TypeScript import path, and a dependent's use pkg::chat::Message. All of them stop resolving.

The wire doesn't carry either name today: - a call is addressed by its function's index within the protocol (datagram) or by its bare function name (JSON-RPC); - one connection serves exactly one protocol.

The namespace only reaches the wire indirectly, through comline@1's whole-schema hash. The compatibility-checking comline@2 (runtime#15) leaves the namespace out of its wire-shape hash, so a rename stays a code change and peers still connect.

The comparison detects a rename and reports it as one, not as a removal plus an addition. A namespace with the same content under a new name is reported as "namespace chat renamed to messaging"; the same goes for a protocol whose functions are unchanged.

A constraint for later. If several protocols ever share one connection, each protocol's identity has to travel on the wire. How it's identified then fixes the rename policy: - a qualified name (chat.Mail.send) would make every rename break live peers; - an identifier the schema states explicitly (an ordinal, or a declared stable id) keeps renames a names-only change.

Whoever designs sharing a connection should choose deliberately.

Versions before 1.0

While the version is 0.x, the minor plays the role of the major, following Cargo's convention:

Change 0.x 1.0 and later
breaking 0.3.2 → 0.4.0 major
new feature 0.3.2 → 0.3.3 minor
patch-level change 0.3.2 → 0.3.3 patch
  • The first build is 0.1.0, not 0.0.1 as today. Cargo treats every 0.0.z release as incompatible with the one before, so 0.0.1 would make every later change breaking for Rust consumers.
  • Reaching 1.0 is the author's decision, never a side effect of a build. A dedicated command (cli#82, say comline release 1.0) explains what it commits to before doing it:
  • from then on a breaking change is a major bump, and peers and consumers rely on that;
  • what the package exposes, in summary: its protocols, the formats and framings it speaks, and any guarantees it gives up;
  • anything worth settling first, such as deprecated declarations or guarantees given up. These are listed for information only, not as requirements.

It asks for confirmation, and needs an explicit flag in CI. The store records the decision, so history shows when and why the package became stable.

Builds and releases

Built (core#133, cli#90): builds carry X.Y.Z-dev.N, the release they lead to; comline release with --raise and 1.0. Not yet: --lower (how consumers accept a package-wide loss of version honesty is open) and assigning @ids at 1.0 (core#127).

Decided 2026-10-20. Today every build that changes something creates a commit with a new version, so five edit-and-build cycles make five patch versions, and a commit records neither the computed bump nor a reason. Instead, builds and releases are separate, as with Cargo (the version changes when you publish) or changesets in JS projects:

  • A build still records history, under a pre-release version such as 1.3.0-dev.4, derived from the bump computed since the last release.
  • comline release creates the version. It takes the highest bump computed since the last release, plus any raise or lower with its reason. The release record stores the computed bump, the applied one, the reason, and the changes behind it.
  • comline release 1.0 (cli#82) is one use of the same command.
  • Consumers and the handshake only see releases. A pre-release tag never equals a release, so two developers' local builds can't be mistaken for one.
  • The first release is 0.1.0, and 0.x follows Cargo's convention (above); today's 0.0.1 start and its major bump straight to 1.0.0 change with it.

Bumps set by the author

Some changes don't show in the schema: a function that now behaves differently, say. So an author must be able to set a bump. But a mistaken bump must not break consumers. The two directions carry different risks, so they're handled differently:

  • The computed bump is a floor. Raising and lowering happen at release.
  • Raising it is always allowed, with a mandatory reason, recorded in the store and shown in comline diff and history. At worst it costs churn: a consumer's version range may stop picking the release up. It never breaks anyone unexpectedly. Raising to major asks for confirmation interactively, and needs an explicit flag in CI.
  • Lowering it breaks consumers silently, so it isn't a bump setting at all. It's giving up version honesty: off by default, with a reason, and accepted by consumers. A naive author can't do it by accident. It's a decision about one release, so it's made there (comline release --lower minor --reason "…") and stored on the release record, not in config.idp.
  • A raise the computed bump already reaches is recorded as covered, and its reason is kept as a note.
  • The build always shows why the computed bump is what it is: the rule, and the encoding property behind it where one decided. An author sees the reason before relying on the number.

The one mistake left unguarded is bumping higher than necessary. It's the harmless kind, so it gets no more friction than the reason it already needs.

Forbidding a break

Decided 2026-10-20. Allowing a break needs nothing new: going below the computed bump is already @unverified. The missing direction is forbidding one, for a stable branch or a 1.x line in CI, as cargo semver-checks fails a build:

versioning.allow = "major" | "minor" | "patch"   # default "major"

A build whose computed bump goes above it fails and lists the changes responsible. comline build --deny major overrides it for one run (cli#80).

Dependencies (tentative)

Still being thought through; recorded here so it isn't lost.

Today any breaking change in a dependency makes this package major, even in a part it never uses. The proposed rule is that a dependency's change counts only as far as this package reaches into it:

  • A dependency's type counts if this package's own declarations use it: fields, arguments, returns, errors, aliases, validators, or re-exports. Those parts are compared with the same rules, and their result counts as this package's own change.
  • Whatever lies outside that reach doesn't move the version.

Points to weigh:

  • Generated lib output depends on the dependency's published bindings. A major release of the dependency, even in an unused part, still gives this package's consumers a new major transitively. That argues for recording it at least as a patch.
  • Adding or removing a dependency nothing uses: probably patch, rather than today's minor and major.
  • Cost: the comparison has to know what's reachable. The code-generation resolution layer and the alias-resolution helpers already walk most of it.

Open questions

  • Third-party profiles are settled in Extending the call system: with vectors, conformance is proven; without, it's given up.
  • Dependencies (tentative, above).

What it touches

  • core:
  • the comparison, with rules written against profile properties;
  • a pinned, drift-checked copy of the known profiles;
  • the config.idp declaration;
  • versioned @framing values and their error;
  • pairing schemas by namespace;
  • the 0.x convention, a first release at 0.1.0, the computed bump as a floor;
  • builds separate from releases: pre-release versions for builds, and a release record holding the computed and applied bump and its reason;
  • versioning.allow;
  • the "validation tightened" category, constants judged by reach, and @open enums.
  • runtime: versioned names in the handshake, a profile per versioned name in the conformance corpus with a vector behind every property, and the re-blessed vectors. comline-typescript's vendored copy follows.
  • generation, comline-rust and comline-typescript: the declared formats reach generated code.
  • cli: comline release, including 1.0 and raising or lowering a bump with a reason (cli#82), and build --deny.
  • cli: reporting the full set of changes and the bump in build and diff (cli#80).
  • language server: the quick fix for a bare format or framing name.
  • docs: Versioning rules, rewritten once the rules are agreed.