The core ↔ target contract
Status: draft — surfaces 1–3 (schema IR, config IR, codegen contract) are
solid and ready to build a code generator on; surface 4 (runtime API +
generated protocol) has a worked design in §4 — no_std-first, borrowed
generated types, memory configured once at setup, a zero-alloc call budget and a
hardening baseline (§4.6), and every §4.4 decision (call addressing, error
grouping, synchronous / one-way, transport / framing / format, per-call
settings) made. §4.3's trait-level types are built in comline-runtime's
contract module, with a MessagePack WireFormat (7b) and an end-to-end
Dispatch round-trip test proving the surface fits (7c). The stubbed setup/
layer is replaced by real wire (framing) + transport (Transport trait,
InMemory + Tcp impls) + serve (Server<D, W>) + client (Client<T, W>)
modules (7d–7e), tested end to end over both transports. The §4.4 IR changes are
landed in comline-core (drop synchronous, Function.parameters,
KindValue::Unit — core#46; throws: Vec<u16> + error ordinals — core#47).
Pluggable framing with a JSON-RPC impl (runtime#10 + comline-rust#8), the
per-protocol @framing selector (comline-rust#9), and the comline.toml
package-wide default_framing (generation#17 + comline-rust#10 + cli#27) are
built. Open: the Alloc seam, the async layer (7f+) ·
Affects
ComlineProject/core, ComlineProject/generation, ComlineProject/runtime,
ComlineProject/comline-<lang>, ComlineProject/cli
Under the repo decision, every comline-<lang> repo
builds against comline-core and comline-codegen — nothing else from the org.
This page writes down that boundary. It is step 2 of the rollout and gates
the second target repo: once two repos depend on the contract, a change to it is
a coordinated bump across all of them, so it has to be a named, deliberate thing.
Everything is consumed by git rev (no crates.io — see the repo decision), so "versioning the contract" here means a discipline, not a registry.
The surfaces
The contract is five distinct surfaces, at very different maturity:
| # | Surface | Defined in | A target repo uses it to… | Maturity |
|---|---|---|---|---|
| 1 | Schema IR — FrozenUnit, KindValue |
core: schema/ir/frozen/unit.rs, …/interpreted/kind_search.rs |
drive the code generator | stable-ish |
| 2 | Config IR — FrozenUnit (config) |
core: package/config/ir/frozen/mod.rs |
fill in a Lib build's manifest |
stable |
| 3 | Codegen contract — GenRequest, GeneratedFile, Mode, PackageMeta |
comline-codegen (today comline-codelib-gen) |
be invoked by the CLI | stable |
| 4 | Core-runtime API + generated protocol — Dispatch, WireFormat, wire / transport / serve, the per-protocol codegen |
runtime: comline-runtime (contract/, format/, wire, transport, serve, package_abi/) |
link the per-language runtime against; shape the generated client / dispatcher | worked design, §4; contract + serve path built |
| 5 | FFI / dylib ABI — PackageLib root module |
runtime: package_abi/interface.rs (abi_stable) |
load a compiled package at run time | exists, deferred |
Surfaces 1–3 are what a code generator needs and are ready to build on now.
Surface 4 is what a runtime / lib generator needs; §4 works it out in full
with the open decisions named. Surface 5 is parked with
G2c until the runtime dylib story and
core#8 land.
1 — Schema IR
A compiled schema is Vec<FrozenUnit> (serde, Debug + Eq + Clone). One unit
per declaration; see the IR guide for the reader's view.
The variants a generator will actually match on:
Namespace(String),Name(String)Struct { docstring, parameters, name, fields, span }—fieldsareFieldField { docstring, parameters, optional, name, kind_value, span }Enum { docstring, name, variants, span }—variantsareEnumVariant(KindValue, span)Protocol { docstring, parameters, name, functions, span }—functionsareFunctionFunction { docstring, parameters, name, arguments, _return, throws, span }—argumentsareFrozenArgument { name, kind, span },_returnisOption<KindValue>(None⟹ one-way,Some(KindValue::Unit)⟹ empty ack, §4.4);throwsisVec<u16>— schema-global error ordinals resolved at freeze;parametersareProperty { name, expression }from@key=valuefunction annotations. (synchronousdropped — core#46;throws/ordinals — core#47.)Error { docstring, parameters, ordinal, imported_from, name, message, fields }—ordinalis this error's schema-global slot (theu16in the envelope'serrid);imported_fromisSome(ns)when the unit is a re-export slot for aused foreign error athrowsnames (<unresolved: Name>if it couldn't be located),Nonefor a localerrorConstant { docstring, name, kind_value, span }Validator,ValidatorRef,ExpressionBlock,Assert,Settings— the validators surface; acodegenerator can ignore all of these, alibgenerator that enforces validation cannot
Types — KindValue
Every typed slot (Field.kind_value, FrozenArgument.kind, Function._return,
Constant.kind_value) is a KindValue:
enum KindValue {
Primitive(Primitive), // bool, u8..u128, s8..s128, String, Namespaced
EnumVariant(String, Option<Box<KindValue>>),
Union(Vec<KindValue>),
Namespaced(String, Option<Box<KindValue>>), // a reference to another declared type
}
Primitive carries an optional literal value (its default). Widths are explicit
(u8…u128, s8…s128); there is no bare int. Float variants are commented
out in core today — a generator must not assume f32/f64 exist yet. A
Unit variant is slated (for -> (), §4.4). Mapping this enum to a language's
type system is the bulk of a code generator and is worked in
Codegen by language.
Stability
Recently through an "audit IR" pass (spans added to most variants and folded
into CAS identity; Import gained the alias slot; validators phase 3 landed).
Not frozen, but changes now are deliberate. A generator should match
exhaustively and fail loudly on an unknown variant, not silently skip.
2 — Config IR
The package manifest (congregation) lowers to its own Vec<FrozenUnit>
(package/config/ir/frozen/mod.rs):
enum FrozenUnit {
Namespace(String),
SpecificationVersion(u8),
PackageVersion(String),
SchemaPath(String),
Dependency(Dependency { author, project, version }),
CodeGeneration(LanguageDetails { name }), // a declared target language
PublishRegistry((String, PublishRegistry { kind, uri })),
}
A lib generator reads PackageVersion and Namespace for the manifest it
emits (Cargo.toml, pyproject.toml, …). CodeGeneration is a capability
declaration — "this package supports being generated as rust" — not consumer
config; where the code lands is the consumer's comline.toml.
SpecificationVersion is the manifest format version (u8, currently 1).
Slated: a transport-requirements unit (reliable / ordered / duplex /
max_message_bytes / one-way delivery-ack), the same capability-declaration
kind as CodeGeneration, read by the runtime at connect time (§4.4).
3 — Codegen contract
How the CLI drives a generator (comline-codegen; landed generation#5):
struct GeneratedFile { path: PathBuf, contents: String } // relative to the target's output root
enum Mode { Code, Lib }
struct PackageMeta { name: String, version: String }
struct GenRequest<'a> {
mode: Mode,
schemas: &'a [(String, Vec<FrozenUnit>)], // every namespace in the package + its schema IR
package: PackageMeta,
}
type GeneratorFn = fn(&GenRequest) -> Result<Vec<GeneratedFile>>;
Mode::Code→ one source file per schema.Mode::Lib→ a buildable package (manifest + module tree).dylibis not inModeyet (G2c).- The generator sees all schemas at once (it needs them for the
libindex file) and returns a flat file list; the CLI writes it — one loop, both modes. - Text only (
contents: String). No binary artifacts, no post-generate compile step in the contract.
This is the crisp part of the boundary. See
Generator output contract for the
history and the settled edges (paths, comline clean, no file-kind tag).
4 — Core-runtime API and the generated protocol
This is the least settled surface, and it gates comline-rust — not
comline-typescript, which is code-only and needs none of it. What follows is
the working design, not a finished contract: §4.1 is what the runtime code
already commits to, §4.2–4.3 the proposed shape, §4.4 the decisions (one pinned,
the rest still to design).
4.1 — What the runtime already commits to
In comline-runtime (runtime repo), after rollout steps 7a–7e the shapes are
built and tested (no more todo!() on this path):
- Three layers, cleanly separated. Transport (
transport::Transport— frame-orientedsend/recv, sync;InMemorympsc +Tcplength-prefixed stream impls) → framing (wire—[call_id][request_id][params]request,[request_id]+Enveloperesponse) → generated code (aDispatchimpl on the provider viaserve::Server, a stub overclient::Clienton the consumer). - Args are a serde value, not
dyn Any.WireFormat::{encode, decode}takeT: Serialize/T: Deserialize<'de>; the oldMessage/Parameter(&dyn Any)dynamic path is gone. - The serialization axis is a trait.
WireFormat;format::MsgPackis the first impl. Nothing hard-codesserde_json. - Request / response, not one type parameter.
Client::call<P>(call_id, &P)returns(Envelope<'_>, &W)— the generated stub decodesOkasRor maps anErrordinal to its schema-error enum, all under one&mut selfborrow (one call outstanding; pipelining is additive). - Sync core. No
async_trait, no tokio on the contract path — an async layer sits behind thestdfeature and is emitted additively (§4.6). Kindcarries either anId(u16)(index into the protocol's call list) or aNamed(&'static str);Kind::resolve(&[&str])maps either to an ordinal.
Framing is a pluggable axis (built — runtime#10 + comline-rust#8). contract::Framing is orthogonal to WireFormat — Client<T, W, F> / Server<D, W, F> are generic over one, default DatagramFraming. framing::JsonRpcFraming (std) is the name-oriented alternative: {"jsonrpc":"2.0","method":…,"params":…,"id":N}, a raised schema error → a JSON-RPC error object keyed by ordinal, pairs with format::Json. Dispatch writes into a Reply (framing-agnostic ok/err/none) instead of an Envelope buffer, and exposes calls() so a method name resolves to an ordinal; the generated stub passes Call::new(id, name) (both addresses, the framing picks).
A schema selects its framing (comline-rust#9). @framing = "jsonrpc" on a protocol (frozen into Protocol.parameters as a Property, same path as @timeout_ms / @provider — no core change) makes the generator emit the JSON-RPC stack instead of the datagram one: <Proto>Client wraps Client<T, W, JsonRpcFraming>, connect / serve call Client::connect_with_framing / Server::with_framing, and the Handshake carries framing.name() rather than FRAMING_DATAGRAM. Absent / unrecognised keeps the datagram default byte-for-byte. Recognised: jsonrpc, json-rpc, jsonrpc-2.0, plus datagram to opt a single protocol back out.
A package-wide default (generation#17 + comline-rust#10 + cli#27). comline.toml's [generate] default_framing (per-[[generate.target]] overridable) rides GenRequest.default_framing to the generator and applies to every protocol with no @framing of its own. Resolution is @framing → package default → datagram. The contract crate treats the value as an opaque name; the target generator validates it.
Still open, to fix as part of this design:
- The
Allocseam for owned bits (.to_owned(), decoded collection spines). - The async (
std) layer —AsyncDispatch+ an executor, emitted additively.
4.2 — The generated protocol
Built for Rust — comline-codegen-rust's code / lib output (comline-rust#2); a tests/compiles.rs generates a protocol crate and cargo builds it against comline-runtime. The full pipeline is exercised end to end in cli (cli#24): a .comline schema → the real comline generate binary → the crate compiled against comline-runtime → a ChatClient ⇆ ChatDispatcher round-trip over duplex() (request/response, a typed raised error, a one-way notify).
- Borrowed str args (comline-rust#3) — a
strarg decodes borrowed:<Proto><Fn>Params<'a> { #[serde(borrow)] name: &'a str }, trait/client take&str. Array-of-string args, nested struct args and all return/data types are still owned (threading<'a>through the data types forces an owned/borrowed split on returns). - One-way (comline-rust#4 + runtime#6) —
_return: Nonegenerates fire-and-forget: trait methodfn f(&self, …);(noResult, no error enum), the dispatcher writes noEnvelope, the client method is-> Result<(), RuntimeError>overClient::notify._return: Some(KindValue::Unit)stays request/response with an empty ack. - Still open —
Function.parameters(per-call settings like@timeout_ms) not consumed; needs a runtime timeout mechanism first.
For protocol Chat { function send(msg: Msg) -> Ack ! Rejected; function history(limit: u32) -> Msg[]; }:
// 1. one params struct per function — this is struct codegen, reused
#[derive(Serialize, Deserialize)] struct ChatSendParams { msg: Msg }
#[derive(Serialize, Deserialize)] struct ChatHistoryParams { limit: u32 }
// 2. one *schema-only* error enum per function — its `! Errors`, no runtime types (§4.4)
enum ChatSendError { Rejected(Rejected) }
enum ChatHistoryError { /* no `!` → empty */ }
// + a per-protocol union, additive, for one broad handler:
enum ChatError { Rejected(Rejected) /* ∪ every `!` in Chat */ }
impl From<ChatSendError> for ChatError { /* … */ }
// 3. provider trait — the user implements this; schema errors only (sync core; async is additive, §4.6)
trait Chat {
fn send(&self, msg: Msg<'_>) -> Result<Ack, ChatSendError>;
fn history(&self, limit: u32) -> Result<Vec<Msg<'static>>, ChatHistoryError>;
}
// 4. consumer stub — wraps a CallSystemConsumer; CallError<E> adds infra failure
struct ChatClient<C> { cs: C }
impl<C: CallSystemConsumer> ChatClient<C> {
fn send(&mut self, msg: Msg<'_>) -> Result<Ack, CallError<ChatSendError>> {
self.cs.call(Kind::Id(0), &ChatSendParams { msg }) // encodes into a reused buffer,
} // decodes the ok/err envelope borrowed
// history → Kind::Id(1) …
}
// 5. dispatcher — generated, implements the runtime `Dispatch` trait
struct ChatDispatcher<T: Chat> { inner: T }
impl<T: Chat> Dispatch for ChatDispatcher<T> {
fn dispatch<W: WireFormat>(&self, call: Kind, params: &[u8], fmt: &W, out: &mut dyn BufMut)
-> Result<(), RuntimeError>
{
match call.resolve(Chat::CALLS).ok_or(RuntimeError::UnknownCall)? { // index jump table
0 => { let p: ChatSendParams = fmt.decode(params)?; // borrows `params`, no copy
encode_envelope(self.inner.send(p.msg), fmt, out) }
1 => { let p: ChatHistoryParams = fmt.decode(params)?;
encode_envelope(self.inner.history(p.limit), fmt, out) }
_ => Err(RuntimeError::UnknownCall),
}
}
}
// 6. impl CallProtocolMeta for the marker type — calls_names() in declaration order
Everything here is mechanical over the Protocol / Function IR. The only
inputs a generator needs beyond what it already reads for structs are the
runtime trait names (CallSystemConsumer, Dispatch, WireFormat,
RuntimeError) — which is what this section pins down. Note the 'de lifetime
on Msg and *Params: generated types borrow from the receive buffer rather
than owning copies (§4.6).
How params, results and errors are carried
There is no runtime message object — no Message, no Parameter, no
Vec<arg>. The schema is fully known at codegen time, so every call site is
statically typed and the generator emits a named struct per function instead:
| Piece | Emitted as |
|---|---|
| params | struct <Proto><Fn>Params<'de> { … } — one field per argument, in declaration order |
| result | the return type directly (Ack, a primitive, …) |
| error | enum <Proto><Fn>Error { <Named>(<Named>), … } — schema ! errors only; plus a per-protocol union <Proto>Error. The client wraps in CallError<E> for infra failure (§4.4) |
| envelope | a small runtime type: { ok: R } | { err: { id: u16, body: &'de [u8] } } (§4.4) |
The params struct is the message. It is a stack struct literal at the call site (zero cost), serialized in one pass straight into the call system's reused buffer, and on the receiving side decoded as a borrow into the receive buffer.
Message / Parameter (the old setup/abstract_call.rs) were built for a
dynamic, runtime-assembled argument list (&dyn Any) — a model Comline doesn't
need, and one that can't serialize anyway (dyn Any has no Serialize). The
whole setup/ layer was deleted in 7d.
Cases:
- Zero args (
function poke();) — no params struct; empty payload;call(Kind::Id(n), &()). - Many args — one struct, or a tuple where the framing wants positional params (JSON-RPC arrays); field / element order is declaration order.
AbstractCall<M>— its only content was{ settings, parameters }. Per-call settings are deferred (§4.4), socalltakes&Pdirectly andAbstractCallCallProtocolMeta::make_callare dropped; a thin wrapper returns only if settings land.CallProtocolMetakeepscalls_names()/call_name_from_id().
4.3 — Runtime additions this needs
Shapes chosen for the §4.6 budget — sync core, write-into-buffer, borrowed
decode. The trait-level ones landed in comline-runtime's contract module
(rollout step 7a); the MessagePack WireFormat in format (7b); the framing +
transport + serve path in wire / transport / serve (7d); the client
side and a Tcp transport (7e). Rows below reflect what was built; only the
Alloc seam is still open.
| Add | Shape | For |
|---|---|---|
RuntimeError |
enum { Transport, Serialization, Framing, Timeout, UnknownCall, Remote { id: u16 } } — core::error::Error, lifetime-free ('static, storable) |
replace Result<T, ()> everywhere |
trait Dispatch |
fn dispatch<W: WireFormat>(&self, Kind, params: &[u8], &W, out: &mut dyn BufMut) -> Result<(), RuntimeError> — sync; generic over the format (a &dyn WireFormat isn't object-safe — generic methods), the provider is generic over D anyway (no vtable) |
provider call system holds &D and routes inbound frames to it |
Client::call<P> |
fn call<P: Serialize + ?Sized>(&mut self, call_id: u16, &P) -> Result<(Envelope<'_>, &W), RuntimeError> — frames + sends, blocks for the reply, hands back the envelope (borrowing the recv buffer) and the format, both out of one &mut self. The generated stub decodes Ok → R or maps an Err ordinal → CallError<E>. Replaces send_async_call<M> (the one-type-param bug). Built (7e); one call outstanding, pipelining additive. |
|
enum CallError<E> |
{ App(E), Runtime(RuntimeError) } + From<RuntimeError> |
the one runtime type that adds infra failure to a schema-only error enum (§4.4) |
trait WireFormat |
encode<T: Serialize + ?Sized>(&self, &T, &mut dyn BufMut) -> Result<(), RuntimeError> / decode<'de, T: Deserialize<'de>>(&self, &'de [u8]) -> Result<T, RuntimeError> — no Vec return, borrow on decode. format::MsgPack implements it over rmp-serde (7b, std-gated). |
the serialization axis; the call system is generic over one |
wire framing |
free fns — encode_request / encode_request_header (client frames the header then serializes params in after it) / decode_request(&[u8]) -> Option<(u16, u64, &[u8])> and the response pair (request_id + envelope). no_std, alloc-free, borrow on decode |
one frame per message; datagram-oriented |
trait Transport + Server / Client |
Transport::{send(&[u8]), recv(&mut Vec<u8>)} — sync, frame-oriented; InMemory (mpsc, duplex()) and Tcp (u32-length-prefixed stream, MAX_FRAME-bounded) impls under std. Server<D, W> holds D + W + 3 reused buffers; Client<T, W> owns the transport + format + request-id counter + 2 reused buffers |
the provider loop and the consumer call side; alloc-gated (Vec buffers) |
| wire envelope | Envelope<'a> — Ok(&'a [u8]) | Err { id: u16, body: &'a [u8] }, one tag byte (0 / 1) then id little-endian; encode_ok / encode_err / decode helpers |
carry a raised error back to the client stub |
trait BufMut |
put_slice (+ put_u8 / put_u16_le / put_u64_le defaults); Vec<u8> impls it under alloc; SliceBuf<'a> wraps a fixed &mut [u8] with an overflowed flag for the no-alloc tier |
the receive + encode buffers, injected once at setup (§4.6), reset not realloc'd per call |
trait Alloc |
seam for owned bits — global (alloc) | arena | none; chosen once at setup. Not built yet. |
.to_owned() copies and decoded collection spines |
An AsyncDispatch / async client .call().await layer sits behind the
std feature (§4.6); the generator emits it additively.
4.4 — Decisions
Call addressing — pinned: append-only declaration order
A function's id is its index in calls_names(), i.e. its position in the
protocol block. Protocol functions are append-only: a removed function is
deprecated in place, its slot never reused; a new function is appended. This is
the protobuf field-number / Cap'n Proto ordinal discipline, and Comline's CAS +
automatic versioning is what enforces it (a
reorder or a slot reuse is a detectable breaking change). Kind::Named stays on
the wire for debuggability and for framings that are name-oriented (JSON-RPC);
Kind::Id is the compact form.
Error grouping — decided
Generated types.
- Per-function schema-error enum —
<Proto><Fn>Error, one variant per! Ein that function,Variant(E)whereEis the generated struct from theerrordecl. Pure schema: no runtime types,no_std, reusable. A function with no!gets an empty enum (orInfallible). - Per-protocol union —
<Proto>Errorover every!in the protocol, withFrom<<Proto><Fn>Error>for each function. Additive, for a caller that wants onematchfor the whole service. enum CallError<E> { App(E), Runtime(RuntimeError) }— the one runtime type that adds infra failure. Not generated; lives incomline-runtime.
Where each lands.
| side | signature | why |
|---|---|---|
| provider trait | fn send(&self, …) -> Result<Ack, ChatSendError> |
schema errors only — the impl can't fabricate a RuntimeError |
| client stub | fn send(&mut self, …) -> Result<Ack, CallError<ChatSendError>> |
the caller can hit transport / timeout / a garbage frame |
| broad client handler | CallError<ChatError> via ? and the From impls |
one match for the service |
On the wire — schema-global error ordinal. (Built — core#47.) The
envelope's err carries { id: u16, body: &'de [u8] }. id is
FrozenUnit::Error.ordinal — compiler-assigned at freeze (plan_error_space
in incremental.rs): local error decls take 0..N in declaration order, and
each foreign error a throws names is appended a re-export slot. Function.throws
is Vec<u16> of those ordinals. Append-only discipline (retire in place, never
reorder or reuse) is the author's to keep; version-diff enforcement of it is a
follow-up. body is the error struct's fields, borrowed from the receive
buffer.
- Name-oriented framings (JSON-RPC) put the error name on the wire instead;
the generator has the ordinal↔name↔struct mapping both directions. Mirrors
Kind::{ Id, Named }. - A
used cross-schema error gets a re-export slot in the importing schema's ordinal space (FrozenUnit::Errorwithimported_from: Some(ns), fields + message carried over from the source schema), so the wireidstays a singleu16. An unresolvable! Namestill gets a stable slot, marked<unresolved: Name>. - Unknown ordinal (newer peer raised an
! Epast what the client generated) →CallError::Runtime(RuntimeError::Remote { name, raw }), borrowed per §4.6. Adding! Eis a compatible change (old peers land here); removing a!reference is compatible (dead variant).
message renders client-side. error Foo { message = "…{self.name}…" } →
a generated Display / core::error::Error impl that does the {self.field}
substitution locally. Only fields travel; the template is baked into codegen.
Raise-site field values — the schema can't yet write
! Foo(name = x) (guide); the generated Rust impl
returns a fully-built Foo { name }, so this language gap doesn't block codegen.
synchronous / one-way — decided; IR changes built (core#46)
Function.synchronous: bool conflated three things: whether the call has a
response (a wire fact), whether the generated API blocks or .awaits (a binding
choice), and how the peer schedules handlers (server config). Only the first
belongs in the schema, and it is already carried by _return.
synchronousis removed from the IR. (Done.)- One-way ⟺
_return == None. No request id, no response frame;! Eon such a function is a compile error (nowhere to deliver it). The generated caller returnsResult<(), TransportError>— it can learn the frame didn't leave the box, never a remote outcome. One-way calls are inherently sync on the client (nothing to await) even under the async layer. KindValuegainsUnit(done) so-> ()is expressible and distinct from omitting the return:commit() -> ()freezes as_return: Some(KindValue::Unit)and gets an emptyokack;commit();freezes as_return: Noneand doesn't reply. Without it, "ack, no value" would force an emptystructper call. (Streaming, when designed, extends this further —Return::{ None, One(KindValue), Stream(KindValue) }, §4.7.)- Blocking vs async is the §4.6 generator option — sync core trait always,
asyncadditive behindstd. Not in the schema, does not travel.
One-way means no application response. TCP still gives byte-delivery; UDP / lossy transports are best-effort.
Transport, framing & format — needs design
Three layers, mostly orthogonal, none named in the schema:
| Layer | Examples |
|---|---|
| transport | TCP, UDP, QUIC, Unix socket, WebSocket, in-process |
| framing / call system | JSON-RPC, a compact length-prefixed binary framing, gRPC-style |
serialization (WireFormat, §4.3) |
JSON, MessagePack, bincode, CBOR |
Per §4.6 the generated code is serde-only and codec-agnostic, so there is
nothing format-specific to generate — nothing to forward-declare.
The schema declares requirements, not mechanisms. In the congregation (a
config FrozenUnit, like CodeGeneration), or a schema settings block
(guide):
reliable— no silent message lossordered— per-connection ordering (a stateful protocol,open()beforewrite(), needs this)duplex— the peer can push (needed once streaming exists)max_message_bytes- delivery ack for one-way calls — default fire-and-forget; an API where a
lost notification is unacceptable declares it required, and the generated
notify(...) -> Result<(), TransportError>then also fails on no ack in a window (still no value, just confirmed receipt)
A one-way-only API can waive reliable / ordered — that is what makes it
legal over UDP.
The runtime picks the concrete stack and checks it. Transport + framing +
WireFormat are chosen at .serving() / .connect() (like §4.6's memory
config). Setup verifies the composed stack meets the declared requirements —
compile-time via generator options where it can, runtime assert otherwise
(reliable + ordered → TCP / QUIC-stream / Unix / in-process qualify; raw UDP
does not).
A connection handshake — built (runtime#8 + comline-rust#6). Each end sends Handshake { ir_hash, wire_format, framing, capabilities } as the first frame and checks the peer's — refuses (RuntimeError::Handshake) on an ir_hash / wire_format / framing mismatch (capability bits may differ). Catches "one end msgpack, the other JSON", which no-declaration otherwise leaves as garbage at runtime.
wire_format/framingare carried as an FNV-1aname_hashof a name, not a numeric id —WireFormat::name()("msgpack"), a user add-on picks a namespaced name; no central id registry.Handshakestays fixed-size (31 bytes) andCopy.- Two modes.
Client::connect/Server::serve_handshaked(checked) vsClient::new/Server::serve(skip it — "misaligned mode", documented, for a legacy peer / no back-channel / an embedded target that can't spend the round trip). The generator emits<Proto>Client::connectand<Proto>Dispatcher::servefor the checked path, filling theHandshakefrom a generatedIR_HASHconst. ir_hashis canonical (core#49 + generation#18 + comline-rust#11) —comline_core::schema::ir::frozen::schema_ir_hash: BLAKE3 over the schema'sbincodeencoding (the CAS's own blob serialization), folded tou64. Every generator embeds the same value, so two ends generated from one schema agree regardless of target language. Replaced each generator's FNV-over-Debugfingerprint.- Still open: a
WarnOnlymiddle mode (exchange, log, proceed) for migrations.
The schema's transport requirements still constrain your stack selection at setup; nothing cross-checks that the peer honours them.
Datagram vs stream is the one real structural fork. Stream (TCP, QUIC-stream, Unix) → framing length-prefixes for message boundaries. Datagram (UDP, QUIC-datagram) → one message per datagram, size-bounded; request/response then needs the framing to own correlation + retransmit + dedup, or to restrict to one-way. A framing declares stream-vs-datagram, a transport declares what it provides, setup checks compat. QUIC's stream and datagram multiplexing is a later opportunity, not now.
In-order processing. Default: in-order on one connection with the §4.6 sync
dispatcher. The std AsyncDispatch layer may start / complete handlers out of
order; a stateful protocol declares ordered, a throughput-oriented one opts
into concurrent.
Optionally, a default_wire = "msgpack" hint in the package — advisory, for
tooling and comline new, not a constraint.
This wants its own design pass before comline-rust, and it adds a config
FrozenUnit on the §2 side.
Per-call settings — decided
IR. FrozenUnit::Function gains parameters: Vec<FrozenUnit> (like
Struct / Protocol) holding @key=value function annotations as
Property { name, expression }. Open namespace — the runtime documents and
validates the keys it acts on and ignores the rest (forward-compat, same as the
generator options map in Generation).
v1 knob set — two:
| annotation | meaning | travels? |
|---|---|---|
@timeout_ms = N |
how long the client waits for the response; request/response only | no — local wait |
@idempotent |
marker: calling twice is safe. No behavior yet — the gate a future retry will require |
no — advisory metadata |
priority / deadline (would travel) / retry / compression are named as
later, each with its wire implication noted when it lands.
idempotent is a function property, not a call option — it's fixed per
function, baked into generated metadata (e.g. CallProtocolMeta::is_idempotent(id)),
never in CallOptions.
timeout resolves through three levels, call-site wins:
@timeout_ms— the schema's default.- consumer
comline.toml— override for a whole dependency ([calls."pkg::chat"] timeout_ms = 5000). - call-site —
client.with_options(CallOptions { timeout: Some(d) }).send(msg)..with_options(…)returns a lightweight view with the same method set, so the bareclient.send(msg)?(§4.6) is untouched and no_withvariant doubles the method count.
Falls back to a runtime global default if none is set. Nothing travels in
v1; when deadline / priority land they go in the frame and the handshake
advertises support.
4.6 — no_std, memory, and the performance budget
Decided: no_std-first (one crate, alloc / std additive); borrowed
generated types by default with .to_owned() as the escape; memory is
configured once at setup, never threaded through a call; the hardening
measures below. Open: the arena Alloc mode ships after the global-default one.
The user-facing rule
Set memory up at the start if you want to. After that, calls and handlers are plain — no buffer, no allocator, no lifetime past the borrow.
// configure once — or skip it entirely under the `alloc` feature, where
// `Server::new` just grows a `Vec<u8>` for each reused buffer.
let mut server = Server::new(ChatDispatcher(MyChat), MsgPack);
// .with_buffers(recv, envelope, response) // no-alloc: three &mut [u8; N]
// .with_arena(&mut region) // opt-in: bump region, reset per call
server.serve(&mut transport)?; // transport: any `Transport` impl
// every call site, forever:
impl Chat for MyChat {
fn send(&self, msg: Msg<'_>) -> Result<Ack, ChatSendError> { /* … */ }
}
client.send(msg)?; // no buffer, no allocator in the signature
The only ambient complexity at a call site is the '_ on borrowed args — the
deliberate cost of borrowed-by-default (§4.2), and what makes "wipe after use"
actually work.
The budget
One call, transport already connected, is designed to cost:
| header | one u16 call id + one u64 request id written into a reused buffer |
| encode | one serialize pass of the params struct straight into that buffer — no intermediate Message, no Vec |
| send | one transport write |
| receive | one transport read into a reused buffer |
| decode | one borrowed deserialize — R / *Params point into the read buffer |
| dispatch | one index into a jump table (match idx { 0 => … }), no hashing, no string compare |
| allocations | zero on the happy path |
Anything that breaks "zero allocations on the happy path" has to justify itself. The error path may allocate.
7e's Client::call and Server::serve_one already hold to this — request-id
counter aside, they only clear() and refill buffers they own. Still to close:
the dispatcher needs a reusable scratch for the reply body before it becomes an
Envelope (the per-arm Vec in the hand-written test dispatchers) — either a
buffer serve hands down or one the generated dispatcher owns.
Memory
- Buffers are injected once, at setup. After
.serving(…)/.connect(…)the call system owns the receive and encode buffers and reuses them —clear()/ reset per call, never realloc'd. Default underalloc: a growableVec<u8>. Under no-alloc: the caller passes&mut [u8; N]and an over-long frame is aRuntimeError, not a panic. - An
Allocmode, also chosen once at setup, for the owned bits —.to_owned()copies and the spine of a decodedVec<T>/BTreeMap(the elements borrow; the container can't, unless the wire format is already laid out as&'de [T]):
| mode | what it is | for |
|---|---|---|
global — default, alloc feature |
the registered #[global_allocator] |
you don't want to think about it |
| arena — opt-in | a caller-supplied bump region, reset (not freed) per call; doubles as the zeroization unit | embedded, throughput, hardening |
none — no-alloc |
— | the generator then rejects schemas whose types need heap, or forces those fields to &'de |
Vec / String / Box are alloc, not std — they work in no_std
with a #[global_allocator]. The strict tier is no_std and no alloc.
Rust's per-container allocator API (Vec::new_in, Allocator) is still
nightly, so the arena is its own seam, not alloc::Vec with a custom A.
- Ship the global-default first; the arena is a follow-on on the same seam.
Hardening (decided, independent of the above)
- Bounded decode.
WireFormatdecoders enforce a max frame size, max collection length and max nesting depth; a hostile length prefix is rejected before any allocation. - Buffer zeroization. The receive + encode buffers (and the arena, if used)
are
zeroized after each call, behind ahardeningfeature that is on by default. - No
unsafe.#![forbid(unsafe_code)]in generated code and in the decode path.
Sync core Dispatch
async fn in a dyn trait forces a boxed future per call, so the core
Dispatch is sync and the generated provider trait is sync by default;
the provider is generic over D: Dispatch (static dispatch, no vtable, no box).
Async server concurrency is an AsyncDispatch + executor layer behind the std
feature — emitted additively, never on the no_std path.
no_std layering
The repo decision folds core_no-std into a std feature on one crate:
| Tier | Has | Contents |
|---|---|---|
core (no_std, no alloc) |
— | contract/ — Dispatch, WireFormat, RuntimeError, Kind, BufMut + SliceBuf, Envelope, CallError — plus wire framing. Pure (&[u8], id) -> Result<(), _> transforms over injected buffers. (Built: 7a + 7d.) |
alloc feature |
alloc |
serve::Server, client::Client, the transport::Transport trait, owned generated types, the Alloc seam. (Built: 7d–7e; Alloc open.) |
std feature |
std |
format::MsgPack, transport::{InMemory, Tcp}, package_abi; later — AsyncDispatch + executor, blocking wrappers. (Built: 7b MsgPack, 7d InMemory, 7e Tcp.) |
Transport is not in the no_std core — an embedded target hands the runtime
bytes in and takes bytes out itself. The no_std runtime is framing + dispatch
+ (de)serialization over borrowed, injected buffers. Generated code
(comline-rust output) targets the core traits and is #![no_std] +
extern crate alloc by default; std conveniences are additive.
4.7 — Still open beyond the above
- The per-language-runtime ↔ core-runtime seam. The
runtime guide says each language has a "thin
runtime that speaks to the core runtime" — that trait boundary is nowhere yet.
For Rust it's degenerate (the target repo is Rust); it becomes real with the
second
lib-mode language, and it has to respect the §4.6 budget across the FFI edge (surface 5) too. - Streaming / server-push.
Event<Incoming, Outgoing>and thewatchplumbing hint at it; no design. Afunctionreturning a stream has no IR representation.
5 — FFI / dylib ABI
package_abi/interface.rs, abi_stable:
#[sabi(kind(Prefix(prefix_ref = PackageLibRef)))]
struct PackageLib {
to_message: extern "C" fn(data: RVec<u8>) -> MessageBox,
}
// RootModule: BASE_NAME = NAME = "package_lib"
load_root_module(directory: &Path) -> Result<PackageLibRef, LibraryError>
A compiled package is a cdylib exposing package_lib; the host loads it and
calls to_message. Deferred with G2c
— it needs the runtime dylib-loading story and core#8. Listed here so a target
repo knows the shape it will eventually target.
Versioning the contract
Git revs, so there is no semver gate — the discipline is:
FrozenUnitchanges are additive by default. A new variant, or a new field on a struct-like variant behind#[serde(default)], lets an older generator keep working. A rename or a removed field is a breaking change and needs everycomline-<lang>rev-bumped in lockstep.- One
comline-coreper tree. The CLI composes N target generators; they must all pin the samecomline-corerev (the constraint already hit betweengenerationandcli). A contract change = bumpcore, then bump every consumer to that rev in one pass. SpecificationVersioncovers the manifest only. It does not version the schema IR or the runtime API. If the schema IR needs an explicit version, add aFrozenUnit::IrVersion(u16)rather than overloading the spec version.- The CAS makes each frozen commit self-describing — a stored version
records the IR that produced it, so old versions stay readable even as the
live
FrozenUnitmoves. Cross-version generation still needs the generator to handle the older shape; the corpus (rollout step 3) is where that's checked.
Where this is unresolved
Surfaces 1–3 are ready to build a code generator on now (comline-typescript,
rollout step 4). Surface 4's design decisions are all made (§4.4 + §4.6);
what remains is below the decision line:
- §4.4 — the design is settled, the
WireFormat/Transport/ framing trait signatures are built (7b–7e), and the IR changes the decisions imply are landed (Function.parameters,KindValue::Unit, dropsynchronous— core#46;throws: Vec<u16>+ error ordinals — core#47). The connection handshake (runtime#8 + comline-rust#6) with a canonicalir_hashfromcore(core#49 + generation#18 + comline-rust#11), pluggable framing incl. JSON-RPC (runtime#10 + comline-rust#8), the per-protocol@framingselector on the generatedconnect/servehelpers (comline-rust#9), and itscomline.tomlpackage-widedefault_framing(generation#17 + comline-rust#10 + cli#27) are built. Still open at the wire level: aWarnOnlyhandshake mode, multi-throws(! A, B) grammar, version-diff enforcement of the ordinal append-only rule, and the transport-requirements config unit. - §4.6 — decided; the buffer-reuse budget is met by 7d–7e (
Client/Server), the dispatcher's reply-body scratch and the arenaAllocmode are the follow-ons. - §4.7 — the per-language-runtime seam, streaming — deliberately later.
- §5 — the FFI ABI, parked with G2c.
Smaller, outside surface 4: float primitives are commented out in core
(surface 1); whether the schema IR gets an explicit FrozenUnit::IrVersion
(Versioning, above).