What every Comline implementation is held to: the wire contract as byte-exact conformance vectors, the facts each official encoding states and how they're proven, the formats of these files, and the catalog of names they share. A runtime, a third-party encoding or a tool implements against this repository, and pins it like any other dependency. Comline's own runtimes do the same.
Version: 0.10 (VERSION). While Comline is in its early days
(0.x), a minor version may change anything, and the change is named in the
pull request that makes it and in the changes below.
catalog/ defines once every name that more than one
program has to agree on: the guarantees, the facts and the values each may
take, the official encodings with their aliases, and the wire features, with
the sets they form. The profiles' facts and features.json come from it, and
catalog/gen/catalog.json holds it as JSON for
readers without CUE, and the comline-catalog crate (rust/) as
Rust constants. See the proposal
and core#219 for what
will read it.
features.json, generated from the catalog: the wire features, with stable ids:name-hash,handshake,envelope,framing-datagram,framing-datagram2,framing-datagram3,handshake-comline2,framing-jsonrpc,stream,format-json,format-msgpackandformat-packed.vectors/<feature>.json: that feature's cases.benches/: the benchmarks every runtime runs, in the same value encoding.specs/: the format of each of these files, in CUE, and the JSON Schemas generated from it, which each file names in$schema.
A case is one operation and the bytes it must produce:
{
"id": "envelope/err",
"op": "envelope",
"input": { "kind": "err", "id": 258, "body": "6669656c6473" },
"bytes": "0102016669656c6473"
}The ops, and what their input holds:
op |
input |
checked |
|---|---|---|
name_hash |
name |
the hash, 8 bytes little-endian |
handshake |
ir_hash, wire_format, framing (names, unhashed), capabilities |
encode and decode |
handshake_check |
one end's ir_hash, wire_format, framing, capabilities |
decode: bytes, the peer's comline@1 frame, is accepted (giving the input back) when the fingerprint, format and framing agree, and refused when one differs |
envelope |
kind: ok with payload, err with id and body, or fault |
encode and decode |
request |
framing, call_id, method, request_id, format, params, and one_way when true |
encode and decode |
response |
framing, request_id, and an envelope's fields |
encode and decode |
peer_refusal |
framing |
decode: bytes are the peer saying it couldn't read a request, without saying which |
encode |
format, value, and type when its bounds matter |
encode; a rejection's encode must fail |
decode |
format, type, value |
decode: bytes read as type give value |
stream_prefix |
len, max |
encode and decode: the 4-byte length prefix of a len-byte frame under the limit max |
comline2_message |
a comline@2 message: type (hello, answer or verdict) and message |
encode and decode |
comline2_negotiate |
step, and as the step needs side, params, local (this end's hello), pending; then reply and result |
decode: bytes, the peer's message, run through step give exactly reply (the message this end sends, or null) and result (accepted, or refused with its reason and side) |
stream_split |
max, frames, rest, needed |
encode and decode: a byte stream of frames, each behind its prefix, then rest |
- Encode and decode: encoding the input gives exactly
bytes, and decodingbytesgives the input back.- A datagram request carries the call id but not the method name; JSON-RPC carries the name but not the id. Decoding takes the other from the input.
- Decoded request params must be exactly
paramsencoded informat. one_wayis a one-way call on a connection: JSON-RPC sends it as a notification, with noid, and decoding one givesrequest_id"0".
"compare": "json": for bytes that are JSON text (JSON values and JSON-RPC frames), the output must equalbytesas JSON (RFC 8259) rather than byte for byte.- Whitespace and member order don't matter.
- Numbers compare as exact decimals:
1equals1.0, but9007199254740992doesn't equal9007199254740993. - The same holds for JSON inside a frame: JSON-RPC params, results and error data.
- Strings compare by the text they hold, however they're escaped:
"app.Chat\/count"equals"app.Chat/count". Most such cases' bytes are the reference's own output, but afound-*case can spell one another way, which every runtime must read.
"expect": "reject": decodingbytesmust fail. The input then names only the framing, where there is one.- An
encoderejection is a value over itstype's bounds: encoding it must fail, before anything is written. Itsbytesare empty and aren't decoded.
- An
"expect": "accept": decodingbytesmust give the input, and nothing asks to encode it: a frame a runtime must read but has no reason to write, such as one with a member JSON-RPC doesn't name.- Stream framing (
stream): a frame on a byte stream is[len: u32 LE][len bytes], and a runtime refuses alenover its limit: by default 16 MiB (16,777,216 bytes) in every runtime, andlen == maxis accepted.stream_prefix: a rejection's bytes are a prefix overmax, and its input is{len, max}: decoding the bytes and encoding the input must both fail.stream_split: decoding splitsbytesframe by frame undermax(frames, hex), until what's left (rest) isn't a whole frame.neededis the bytes that incomplete frame takes in all, prefix included, or 0 while its prefix is incomplete. A rejection's bytes declare a frame overmax, possibly after whole frames; its input is{max}.len,maxandneededare plain JSON numbers: they stay below 2^53.
- Bytes are lowercase hex.
- 64-bit integers (
ir_hash,request_id,u64andi64values) are decimal strings, so a reader that holds JSON numbers as doubles keeps them exact.
A decode case's bytes were written by a different type from the one it
reads into: a struct with a field more or one fewer, fields in another order,
an enum whose variants are ordered differently or that has one more, a wider
integer. They show what a reader generated from today's schema does with what
another version wrote, which is what profiles are about.
type describes the reader so any runtime can build one, in every shape a
generated type can have:
- the scalars
"bool","u8"to"u128","i8"to"i128","f32","f64","string","char"(one Unicode scalar value) and"unit"; {"option": T},{"array": T}forT[], and{"fixed": {"of": T, "len": N}}forT[N];- bounds (core#98):
{"bounded_string": {"max": N}}forchar[max N], at most N characters (Unicode scalar values, not bytes);{"bounded_array": {"of": T, "max": N}}forT[max N]; and{"recursive": {"max_depth": D, "of": T}}for a type that can contain itself, under@max_depth = D, where"self"insideTstands for the recursive type again. The outermost value is level 1, and a value at level D + 1 is refused; {"struct": {"name": "Point", "fields": [["x", "i32"], ["y", "i32"]]}};{"enum": {"name": "Color", "variants": ["Red", "Green", "Blue"]}}.
The reference decodes by that description through TypeSeed
(seed.rs in the Rust reference). It makes exactly the calls a plain serde
derive makes, as generated Rust declares its types, so the cases prove
generated code, not a hand-written decoder.
seed_vs_derive.rs holds it to real
derives, on valid and mutated bytes in every format.
properties.rs checks the reference's properties
over random types and values with proptest, and
runtime/fuzz fuzzes it with cargo-fuzz. Cases they
find become found-* vectors; the fuzz README says how.
A value is typed, so a runtime knows which width and shape to encode:
{"u8": 255}, {"u64": "18446744073709551615"} (and u128, i128, also as
decimal strings), {"string": "x"}, {"char": "é"}, {"tuple": [...]} for T[N],
{"unit": null},
{"array": [...]}, {"option": null},
{"struct": {"name": "Point", "fields": [["x", {"i32": 1}]]}} and
{"unit_variant": {"enum": "Color", "index": 1, "variant": "Green"}}. A value
encodes the way a generated type of the same shape does: a struct is an object
in JSON and an array in MessagePack.
This repository is the authority. A wire change starts here: a pull request
that changes the vectors, profiles or specs, and bumps VERSION
with an entry under the changes. Every runtime then follows,
Comline's own included.
The Rust runtime is the reference implementation. Its conformance crate
(runtime/conformance) computes every case from
comline-runtime, and its vectors_are_current test fails when its output
differs from this repository's vectors at the revision it pins. So a spec
change that moves bytes lands here first, and the reference is changed to
match.
Each implementation vendors this repository at a pinned revision and checks in CI that its copy hasn't drifted (see Implementing the spec).
The case format above (vectors/*.json, profiles/*.json, the ops, values
and types) and the ops protocol
(comline-reference's README) are a
public spec, at the version in VERSION. Third-party
encodings ship vectors in this format, and comline conformance --impl
drives their implementations over this protocol. While Comline is in its
early days (0.x), a minor version may change anything, and the change is
named in the pull request that makes it. The ops protocol keeps its own
number (protocol: 1 in a version reply), under which ops and reply
fields are only ever added.
What an implementation answers, and how its answers are judged:
- It reads request lines on stdin and writes one reply line each on stdout,
as
comline-referencedoes, until stdin ends. versionsays what it implements:protocol,ops, and the encoding names it ships underformatsandframings(namespaced names too,com.acme.lines@1).- Each case poses the questions the Rust runner's
runner::questionslists: a rejection asks to decode its bytes (astream_prefixrejection also asks to encode its input, which must fail); any other case asks to encode its input (unless it's adecode, ahandshake_checkor acomline2_negotiate) and to decode its bytes (unless it's aname_hashor anencode). The questions never depend on earlier answers. runner::judgepasses a case when every encode gives its bytes (as JSON, for"compare": "json"), every decode accepts and gives its input back exactly, and a rejection is refused. Anot_implementedanswer makes the case not implemented; anerrorfails it.- A profile fact is proven when every case it names passes and shows its
value (
fact_shown). A framing'scallsis shown by exactrequestcases whose bytes hold the method's name forby_nameand don't otherwise; whether a number is an index or a wire number (by_index,by_id) isn't in the bytes, so the profile's word stands for that. Itsdirectionsis shown by real frames read the other way: forboth, the bytes of an exact request refused as a response and those of an exact response refused as a request, and forone, such bytes accepted as the other. Itserrorsneeds an exact error response; a response's input holds only the ordinal, soby_ordinalandby_namerest on the case passing. Itspayloadis shown by requests: an exact one with non-JSON params foropaque, and forjson, an exact one with JSON params and a refusal of that same frame with non-JSON in their place. A format'soutputis shown by exactencodecases: all JSON forjson, and at least one not forbytes. Its other facts are shown bydecodecases of thePointandColorshapes the official profiles use. A handshake's facts are shown byhandshake_checkcases: one the peer agrees in, accepted, and, per thing the fact covers, one differing in only that, refused.
- 0.10: a framing states
directions:both, when a frame says whether it's a request or a response so calls can go both ways on one transport, orone, when it doesn't. A profile without it is refused. New cases, ondatagram@2,@3andjsonrpc@1:reject-response-as-requestandreject-request-as-response; ondatagram@1:accept-request-as-response. No frame's bytes change.jsonrpc@1refuses a response with neitherresultnorerror, which JSON-RPC 2.0 doesn't allow and which it used to read as anullresult, a request's frame among them; new casereject-response-no-outcome. - 0.9: new op
peer_refusal: JSON-RPC 2.0's answer to a request a peer can't read, an error whoseidisnull, is read as that. It answers no call, so it stays noresponse, and anullid on anything else stays refused. It answersnot_implementedon an implementation without the op. - 0.8:
jsonrpc@1leavesparamsout of a request without any, where it wrote"params": null, which JSON-RPC 2.0 doesn't allow. Readers still takenullas none. New cases:request-no-params,notification-no-params,accept-request-params-null. - 0.7: a framing states
payload(opaqueorjson) and a format statesoutput(bytesorjson), and a profile without them is refused. New"expect": "accept". New cases onjsonrpc@1: a member written twice is refused, in a request, a response and an error object; a payload inmsgpack@1orpacked@1is refused; unknown members are read. Request params a framing can't carry answernot_implementedby these facts, which replaces 0.6's rule onjsonrpc@1by name, and now covers params that aren't an array, an object ornull. - 0.6:
callsanderrorsfacts must be shown by their cases, where any passing case used to do: acallsnaming anything but exact requests, or aby_namewhose frames lack the method's name (or a number whose frames hold it), is unproven, and so is anerrorsnaming no exact error response. New cases: request params inmsgpack@1andpacked@1ondatagram@1,@2and@3;name-hash/datagram@3andhandshake/packed-datagram3. Request params in a format other thanjson@1onjsonrpc@1aren't JSON-RPC, and answernot_implemented. New benchmarks:datagram@3framing, andcomline2.json. - 0.5: the corpus's format is specified in
specs/, and every file may name its schema in a top-level$schema, which a reader ignores. The runner reads inputs and profiles by their shape, and refuses at load what it used to fail per case or per fact: aninputwith a field its op doesn't have or spelled another way ("protocol": null), a profile fact or value its kind doesn't have, a case id that isn't<feature>/<name>or appears twice, and hex that isn't lowercase. A decode's answer is compared by what it holds, so1gives back{"f64": 1.0}. Benchmark shapes may begenerated. No vector's bytes change. - 0.4:
datagram@3andcomline@2(runtime#15). New featuresframing-datagram3(arequest'scall_idis the function's wire number, andprotocolthe protocol's) andhandshake-comline2, with the opscomline2_message(encode and decode) andcomline2_negotiate(decode only). Acomline@2handshake fact is shown bycomline2_negotiatecases: one accepted with every protocol, and refusals (of the connection or one protocol) forline_mismatchandpackage_mismatch(schema_agreement) orencoding_mismatchandexchange_mismatch(encoding_agreement). New profilesdatagram@3(calls = by_id) andcomline@2. Nothing earlier changes. - 0.3: bounds and
char(core#98). New type descriptions"char",bounded_string,bounded_array,recursiveand"self", and the value{"char": ...}. Anencodecase may carry atype, and anencoderejection asks only to encode, which must fail. Each format's vectors gain a singlechar(ASCII, 2-, 3- and 4-byte, and bytes that aren't one character), bounded strings and arrays at, under and over their maximum (multi-byte characters near it, a byte length over 4 × N), and the depth limit, decoded and encoded. No profile facts change. - 0.2: handshake profiles state
schema_agreementandencoding_agreement, as JSON booleans ("value": true); a fact statedfalsenames no cases, and a handshake profile may leave a fact out, which reads asfalse. New ophandshake_check(decode only), whose cases prove them;comline@1has a profile. - 0.1: the first published version.
Each runtime declares, per feature, supported, partial or unsupported,
with an optional note and tracking issue. A claim must hold against the
vectors:
- supported: every case of the feature passes;
- partial: every case passes except those listed in
failing, which must fail. A listed case that starts passing fails the check, so the claim is upgraded as soon as it can be; - unsupported: the runtime reports every case as not implemented. A runtime that passes them should claim the feature.
A feature with no claim, or no cases, fails.
Each runtime keeps its claims (support/<runtime>.json) and the report the
check writes (reports/<runtime>.json) in its own repository, in the formats
specs/support.cue and specs/report.cue
define. Rust's are in runtime/conformance.
profiles/ holds one profile per official encoding name
(json@1.json, msgpack@1.json, packed@1.json, datagram@1.json,
datagram@2.json, datagram@3.json, jsonrpc@1.json, comline@1.json,
comline@2.json): facts
about how it encodes, which the version comparison turns into bumps. A format
states struct_fields, unknown_field, missing_optional, enum_variants,
unknown_variant, integer_width and output; a framing states calls,
directions, errors and payload. The catalog gives each fact's values and
what they mean (catalog/facts.cue).
payload says what a framing needs of a payload, and output what a format
writes. A framing takes a format when its payload is opaque (it carries the
bytes as they are) or the two name the same grammar (jsonrpc@1's is json).
Tools decide by these two facts alone, so a third-party encoding is held to the
same rule as an official one. json also bounds a request's params to what
JSON-RPC 2.0 allows: an array or an object, with params left out for none. Neither is
enforced on the wire: a runtime refuses what it can't read, and refusing a pair
that can't work is up to tools that know both, such as a schema compiler.
A handshake states what it establishes between the two ends, as booleans:
schema_agreement (both were built from the same schema) and
encoding_agreement (both speak the same format and framing). One it
doesn't state as true isn't established, and core makes a protocol using
the handshake give it up (@unverified). comline@1 states both, proven by
the handshake/check-* cases: it accepts an agreeing peer and refuses one
whose fingerprint, format or framing differs. comline@2 states both too,
proven by handshake-comline2/offer-* negotiations: a peer accepted, and
peers refused for a moved line, another package, other encodings or
another exchange.
"schema_agreement": { "value": true, "proven_by": ["handshake/check-agree", "handshake/check-reject-ir-hash"] }"unknown_field": { "value": "rejected", "proven_by": ["format-msgpack/decode-unknown-field"] }Every fact names the cases that prove it, and profiles_are_proven checks
that each named case exists, passes against comline-runtime, and shows the
stated value: a rejection where the fact says rejected, the decoded value
where it says by_name or by_position. A profile is fixed for its version:
changing a fact means a new @N.
Whether a one-way call gets a reply frame is behaviour, not bytes, so no profile states it until live cross-runtime calls can prove it (see below).
Vendor features.json, vectors/ and profiles/ at a pinned commit of this
repository, and specs/gen/ too if you want their $schema to resolve; a
reader ignores it. catalog/gen/catalog.json holds the names and their
meanings. benches/ carries its own schema, so it vendors whole on
its own. Keep the pinned revision in your repository and check in CI that your
copy still matches it. comline-typescript does this in
runtime/test/conformance/.
Drive your implementation through the ops, check your claims by the rules above, and write a report of the same shape.
Behavioural features (transports, timeouts, one-way calls) aren't here: a vector can't prove them. They're for live cross-runtime calls, later.
Beyond the fixed vectors, comline-reference is
comline-runtime as a process: it answers any op/input/bytes question
in this corpus's encoding, newline-delimited JSON on stdin and stdout, so a
fuzzer in another language can compare its implementation with the reference
on inputs no vector holds.
MPL-2.0, see LICENSE.