Extending the call system
Status: under discussion · tracked in core#120 (with core#121 and runtime#18) · nothing here is implemented
The question: how a developer brings their own wire format, framing, handshake, transport or dispatcher to Comline, what separates an official extension from a third-party one, and how an extension that gives up Comline's guarantees is made visible.
Where things stand
The call system is described as pluggable, and its runtime half is. The generated half isn't, and nothing connects the two.
| Extension point | Today |
|---|---|
| Transport | Open. Anything that moves bytes implements Rust's Transport or its TypeScript equivalent. |
| Wire format | Open at runtime: WireFormat in Rust, Codec in TypeScript, picked at the call site. contract/wire.rs asks a third-party format to namespace its name ("com.acme.myformat"). |
| Framing | The runtime trait is open; generated code isn't. A generator bakes the framing into the generated client and server, and knows only datagram and jsonrpc. Core checks @framing against that list, so a third-party framing can't be named in a schema. An unknown default_framing silently becomes datagram (cli#81). |
| Handshake | Built (runtime#18): a declared axis. comline@1 or none, from @handshake, the package's handshake, or the default; a runtime trait with connect_with / serve_with; skip paths named *_unverified. Custom names wait on third-party encodings. |
| Dispatcher | The generated <Proto>Dispatcher implements Dispatch, and the server is generic over it, so it can be wrapped (for logging, say). That isn't documented. |
| Generator | Compiled into the CLI behind cargo features. A third-party language means rebuilding the CLI (core#8; Proposal D). |
Schema compatibility adds one more requirement. A format or framing now has a versioned name and a profile, the facts the version comparison reads. A third-party encoding needs both, and core has to find them somewhere.
Principles
Official and third-party encodings use the same mechanism. "Official" means
maintained in this organisation and shipped by default. It doesn't mean built in
through a path nobody else can use. That's how the standard library already
works: std is a package like any other. If json@1 is defined the way
com.acme.cbor@1 would be, the public mechanism is exercised every day and not
only described.
Only what affects compatibility is declared. Formats, framings and handshakes change what's on the wire, so they're named, versioned, profiled and declared. Transports and dispatchers don't, so they stay plain runtime code.
Guarantees can be given up, but never quietly. Comline talking to Comline
keeps every guarantee by default. Giving one up, to talk to a legacy system say,
is allowed. It takes an explicit, reasoned marker, it's off unless a package
turns it on, and every consumer sees it and accepts it in writing. That's the
role unsafe plays in Rust; see Giving up a guarantee.
Encodings: formats and framings
What defines one
An encoding is a definition, separate from its implementations:
- a versioned name: bare for official encodings (
msgpack@1), namespaced for everyone else (com.acme.cbor@1); - a profile: its compatibility facts;
- vectors: byte-exact cases in the conformance corpus format, with a vector behind every profile fact.
Implementations, one per language, prove themselves against the vectors, as the official runtimes do today. The corpus case format and the profile schema become a public, versioned spec. The conformance runner becomes a library a third party runs in their own CI, against their own vectors.
How core learns about one
Core knows the official profiles from its pinned copy of the corpus. A
third-party encoding comes from a dependency: a Comline package can declare
the encodings it defines in its config.idp, for example:
(The shape is illustrative.) A package that depends on it can use
com.acme.cbor@1 in its declared formats, and use a declared framing in
@framing.
Core can't run another language's code, so it trusts the profile the way it trusts that dependency's schemas: as declared. Whether the profile is proven depends on the vectors behind it (see Test vectors and trust). An encoding named with no profile anywhere is compared under the strictest profile, with a warning.
Test vectors and trust
Decided 2026-10-20 (core#120):
| Kind | Requirement | Result |
|---|---|---|
| Official | Vectors for every fact in its profile, implementations for every official target, conformance passing in CI, and a spec page: everything Comline requires | Fully trusted |
| Third-party, with vectors | Vectors shipped by its package, and a check (say comline conformance) that runs an implementation against them |
Conformance proven; nothing given up |
| Third-party, without vectors | None | Encoding conformance is given up: @unverified with a reason, and the consumer's acceptance |
Nothing forces a third-party author to write vectors, but skipping them is visible to every consumer.
How generated code reaches one
A generator doesn't need to understand a framing to wire it. It needs to know
which type implements that name in its language. That's consumer-side
configuration, so it lives in comline.toml, per target. Which encoding a
protocol uses is the producer's choice, made in the schema and its
config.idp; only how it's implemented in each language is the consumer's:
[[generate.target]]
language = "rust"
encodings = { "com.acme.frame@1" = "acme_frame::AcmeFraming" }
- The encoding's package can suggest a default mapping per language (in
Rust
acme_frame::AcmeFraming, in TypeScript@acme/frame), so a consumer writes nothing unless it wants something else (decided 2026-10-20). Shipping the implementation itself, so generatedliboutput depends on it automatically, waits for the third-party half of dependency publishing. - The generator emits the mapped type where it would emit
JsonRpcFraming. - An encoding that's declared but neither mapped nor suggested for a target is a generation error, never a fallback.
- The per-package format trait (generation#67) is implemented for each mapped name, so it can't be sealed. A sealed trait would shut third-party formats out entirely.
What a format can't be
A format serializes the data the generated code already produces: the serde data model in Rust, Shapes in TypeScript. A format that needs more from the schema, such as field tags or explicit ordinals, isn't a plug-in. It needs annotations and generator support, and goes through the normal proposal process as a language feature.
Becoming official
A third-party encoding becomes official by contributing its profile and vectors to the corpus and its implementations to the official runtimes. It keeps its namespaced name: renaming it would change the handshake and break every peer already using it. Bare names are reserved for encodings that start out official.
Handshakes
Comline's handshake guarantees that both ends were built from the same schema and agree on format and framing. That's right for Comline talking to Comline. An implementer fronting a legacy system may need that system's own hello, version negotiation or authentication exchange, or no handshake at all, knowing they give up those guarantees. So the handshake is a declared axis like the other two:
- Versioned names:
comline@1is today's frame,noneis an official choice, and a custom handshake is namespaced (com.acme.legacy-hello@1). A handshake that checks compatibility instead of exact identity (runtime#15) becomescomline@2, a new version, not a change forced on everyone. - Chosen by the producer, with the same layering as framings: the
protocol's
@handshake, then the package default inconfig.idp, then core's defaultcomline@1. Talking to a legacy system is a property of the protocol, not of one call site. A skip decided at a call site can leave one peer handshaking and the other not, with nothing in the schema showing it. - Its profile states what it establishes, not how it's encoded: schema agreement, encoding agreement, both or neither. A custom handshake isn't weaker or stronger than Comline's; it may well do more, authentication for instance. What matters is whether it establishes the guarantees Comline relies on. If it doesn't, the protocol gives those guarantees up.
- Implemented like any encoding. In the runtimes,
Handshakebecomes a trait that receives the transport plus what generated code knows (IR hash, package version, format and framing names) and decides what to send and check. A custom one is mapped to a type per target incomline.toml(runtime#18).
Comline can then also describe a protocol it doesn't own, with a custom framing, handshake and perhaps format. The guarantees Comline can't give are the implementer's, and say so.
Checking compatibility: strategies and comline@2
Decided with the user (2026-10-20), for
runtime#15.
comline@1 compares a hash of the whole frozen schema, so any edit, even a
docstring, makes peers refuse each other, and the refusal carries no reason.
How systems answer "can these two talk?" differs, and each of these should be
achievable as a handshake, even where it isn't what comline@2 does:
| Approach | Used by |
|---|---|
| One exact version number | Minecraft (protocol version) |
| Exchange versions, then use the lower one | Bitcoin (version message); SSH and TLS pick from lists |
| A version range per API | Kafka ApiVersions: min and max per request type, the highest common one wins |
| Send the schema and resolve the differences | Avro (writer and reader schema, or a registry fingerprint) |
| No handshake; tolerance rules only | Protobuf, Thrift, Cap'n Proto |
Core provides the facts; the handshake picks the strategy
Core computes compatibility facts from the package's history, and generated code embeds them. Every strategy works from the same set:
| Fact | Exact (Minecraft) | Lower version (Bitcoin) | Per-API ranges (Kafka) | Schema exchange (Avro) | comline@2 |
|---|---|---|---|---|---|
| package identity (a stored id, see below) | ✓ | ✓ | ✓ | ✓ | ✓ |
| package version | ✓ | ✓ | ✓ | ||
| per-protocol line: the version it last broke at | ✓ | ✓ | |||
| per-protocol wire-shape hash | ✓ (in place of the version) | ✓ (as a fingerprint) | ✓ | ||
| per-function "since" version | ✓ | ✓ | |||
| encoding name and parameter hash | ✓ | ✓ | ✓ | ✓ | ✓ |
| the wire shape itself, as a compact description | ✓ |
- A handshake's profile lists the facts it needs, and generated code embeds only those. The wire shape itself is emitted only for a handshake that asks for it.
- A custom handshake (
com.acme.…@1) reads them through the sameHandshaketrait ascomline@2. - The wire-shape hash covers only what reaches the wire, with aliases resolved. It leaves out docstrings, spans, validators, constants, advisory annotations and the namespace, and it includes the encoding's parameters.
- Package identity is an id stored once in
config.idp, not the package name, so renaming a package doesn't break its peers, as function ids survive function renames.
comline@2 and its parameters
What decides a connection under comline@2:
- The line decides, and the shape hash is sent as well. For 1.0 and up
the line follows the major version; before 1.0 it follows the minor, as
Cargo's
0.xconvention does. - The rest is situational, so it's a parameter of the handshake,
declared in
config.idplike framing instances. The defaults are shown:
handshakes = {
"default" = {
handshake = "comline@2"
scope = "protocol" # or "package"
newer_consumer = "degrade" # or "refuse"
same_version_differs = "refuse" # or "warn"
exchange = "offer" # or "symmetric"
}
}
A package with one handshake never writes this; plain "comline@2" means the
defaults.
| Parameter | Values | Meaning |
|---|---|---|
scope |
protocol (default), package |
Compare each protocol's line, so a break in one protocol doesn't stop peers using only another; or compare the package's |
newer_consumer |
degrade (default), refuse |
A consumer newer than its provider either connects, knowing the peer's version, so a call to a function the peer lacks fails locally with a clear "peer is on 1.2" error and is never sent; or is refused |
same_version_differs |
refuse (default), warn |
Two builds claiming one version with different shapes, typically two branches that each added a function, are refused with a reason naming both; or only warned about |
exchange |
offer (default), symmetric |
The client offers per protocol (its id, line, version and shape hash) and the server answers each with accepted plus its own version, or refused plus a reason; or both sides send the same frame and check independently |
- Wire parameters and policy parameters.
exchangechanges the bytes exchanged, so both sides must match, and it's hashed into the frame like an encoding's parameters.scope,newer_consumerandsame_version_differsdecide what each side accepts, and each side applies its own. A refusal's reason code says which side refused and why: a different line, the same version with a different shape, an unknown protocol, or a different encoding.- One handshake per connection. A protocol can't override the handshake or its parameters for itself. Protocols sharing a connection must declare the same one, as they must for framings.
degradecovers functions first. Refusing locally to send an enum variant a peer can't read (the enum caveat in the compatibility rules) follows later, with the explicit variant values of core#126.- A weaker policy gives up a guarantee, through the
existing mechanism: core infers it from the
parameters.
same_version_differs = "warn"gives up version honesty, andnone, or a custom handshake that checks nothing, gives up schema agreement. The default settings need nothing extra. - Changing a parameter: a stricter policy is a patch. A weaker one gives
something up, and adding
@unverifiedis already major.
Giving up a guarantee
Built for handshakes (core#132):
@unverified, the frozen set, its diagnostics, the version comparison andaccept_unverified, andcomline accept/comline addasking before they write it (cli#89). The editor reports an unaccepted dependency on itsconfig.idpentry, with a quick fix (language-server#78). Encodings without vectors and lowered bumps join when third-party encodings exist, and when releases can lower a bump.
Between peers, Comline gives four guarantees, each established by something specific:
| Guarantee | Established by |
|---|---|
| Schema agreement: both ends were built from compatible schemas | the comline@N handshake |
| Encoding agreement: both ends use the same format and framing | the handshake |
| Encoding conformance: an implementation encodes what its name promises | conformance vectors behind its profile |
| Version honesty: a version bump says what the change breaks | the version comparison |
An extension can leave one of them unestablished. The author then vouches for whatever replaces it (core#121):
- A handshake that doesn't establish schema or encoding agreement:
none, or a custom handshake that doesn't do Comline's checks. - An encoding with no proof: a format or framing whose profile is declared without vectors, or that has no profile at all. Encoding conformance is given up.
- Accepting a breaking change without a major bump: version honesty is given up (schema compatibility's open escape hatch).
- Skipping the handshake at a call site: the hand-wired paths without one
are renamed
Client::new_unverified/Server::serve_unverified, so skipping there is as explicit as in a schema.
The marker: @unverified
Decided in Marking a given-up guarantee (Proposal C):
@handshake = "com.acme.legacy-hello@1"
@unverified = "the 2009 gateway has no schema; its version is pinned by contract"
protocol Billing {
function charge(order: Order) -> Receipt;
}
- One marker, with a reason. The author doesn't name the guarantees.
- Core works out the set from what it already knows:
- the handshake's profile, for schema and encoding agreement;
- whether each encoding has vectors, for conformance;
- the version comparison itself, for version honesty.
Naming the guarantees would only repeat what core computes.
- The set is frozen into the compiled schema with the reason. Diagnostics,
hover and generated docs name it, using the four guarantee names as fixed
vocabulary: "Billing is unverified: schema agreement isn't established
(handshake com.acme.legacy-hello@1)".
The rules
- A reason is mandatory. It's frozen into the compiled schema and shown to every consumer.
- Off by default. The marker is gated like
@framingis today, throughsettings. Cross-package settings let an organisation forbid it everywhere. - Required where it applies. Giving up a guarantee without the marker is a
build error. A marker where nothing is given up is a warning, like an unused
unsafe. - Consumers accept it, per protocol, and the acceptance records the set.
A package that depends on, or imports, such a protocol from another package
names it in its own
config.idp, under the dependency. Until it does, the build fails. The acceptance records the set of guarantees it was given for, so a dependency update that gives up another one stops the build again until it's accepted too. Using one is always a choice written down by the consumer, never inherited. - Visible everywhere.
- The compiled schema records the marker and its reason.
- Build output, editor hover and generated docs show it.
- Generated code documents it on the client and the dispatcher.
- Major in the version comparison: giving up a
guarantee is major, since peers relied on it, and so is the set growing
("
Billingnow also gives up schema agreement"). Restoring one is minor.
At the package level
Decided 2026-10-20 (core#121). Some cases aren't on a protocol:
- An encoding without vectors: the reason goes where the encoding is
declared in
config.idp:
framings = {
"legacy" = {
encoding = "com.acme.legacy@1"
unverified = "no vectors yet; tested by hand against the 2009 gateway"
}
}
- Lowering a bump: a decision about one release, made with
comline release --lower … --reason "…"and stored on the release record (Builds and releases), not inconfig.idp. - A consumer's acceptance, under the dependency in its own
config.idp:
dependencies = {
billing = {
path = "../billing"
accept_unverified = {
Billing = ["schema_agreement"]
}
}
}
- The list is the set accepted, a snapshot, so the build stops again if the set grows. It's the one place guarantee names are written; on the producer's side core works them out.
- Nobody types it by hand: the build error offers a quick fix, and
comline accept billing::Billingwrites the entry, as ids are written into the source on build. - Diagnostics: given-up guarantees get their own code family
(
unverified::schema-agreement, …) underdiagnostics.<code>.level, so an organisation can raise the informational warnings to errors or quiet them. The "not accepted" error can't be lowered: if it could, accepting would mean nothing.
Transports and dispatchers
Neither changes what's on the wire, so neither needs a name, a profile or a declaration.
- Transports stay runtime traits per language. What's missing is a guide: the trait, its framing expectations (a message boundary, or a byte stream), and how to test one.
- Dispatchers are extended by wrapping the generated one. A
Dispatchimplementation that delegates to the generated<Proto>Dispatchercan log, authorise, meter or reject a call before or after it. That should be the documented middleware pattern, and the runtime may grow a small helper for composing wrappers.
Generators
Third-party languages are a separate question
(core#8,
Proposal D). Nothing above
depends on loading generators at runtime. The two meet only when a
third-party language wants a third-party encoding, and the comline.toml
mapping already covers that.
Open questions
- Shipping implementations. Could an encoding package carry its crate or npm
package, so
lib-mode output depends on it automatically? Waits for the third-party half of dependency publishing. - Transports in the handshake. Should a transport ever be named, as a capability for instance, or stay invisible?
What it touches
- runtime:
- the public corpus and profile spec;
Handshakeas a trait,comline@1andnoneas official handshakes, and the call-site skip renamed*_unverified;- the conformance runner as a library;
- a transport guide;
- the dispatcher-wrapping pattern.
- core:
config.idpdeclarations of defined encodings (building on core#117);- names declared by dependencies accepted in
@framingand in declared formats; @handshake;- the marker for a given-up guarantee: its gate, its required-where-it-applies rule, and the consumer's acceptance.
- cli: the per-target mapping in
comline.toml. - generation, comline-rust and comline-typescript: mapped types wired into generated code; an unmapped encoding is an error.
- language server: a given-up guarantee shown on hover, and the acceptance an importing package lacks offered as a fix.
- docs: a guide to writing each extension.