Protocol Specification v0.6

Migration from v0.5

Treat v0.6 as an explicit new wire contract. The public v0.5 documents specify design semantics but do not supply byte-level interoperability with the reference legacy codec. Keeping seven family discriminators (opens in a new tab) does not make their payloads interchangeable.

Migration

Only the seven family numbers and names carry over; other items are replaced, changed, newly defined or left out.

Only the seven family numbers and names carry over; other items are replaced, changed, newly defined or left out. Items of the v0.5 documents on the left map to the v0.6 specification on the right. The seven message types keep their numbers and names as family codes 1 to 7. The SemVer version string, ISO 8601 timestamps and CAIP-2 ids and addresses become the integer version 6, UTC seconds in fixed64 fields and registered 64-bit handles, routing-table updates give way to host configuration, and the wire format and executable self-checks are newly defined as proto/oip.proto and conformance vectors. Restricted DSL conditions, the Multi-Signatory authority field, which v0.5 left pending definition, and Canton driver attestation have no v0.6 mechanism. v0.5 documents v0.6 specification kept seven message types family 1 to 7 1 replaced or changed SemVer version string integer version 6 2 ISO 8601 timestamps UTC seconds in fixed64 CAIP-2 ids and addresses registered 64-bit handles routing-table updates host configuration defined wire format, field numbers proto/oip.proto 3 executable self-checks conformance vectors restricted DSL conditions Multi-Signatory authority Canton driver attestation not defined 4 no v0.6 mechanism
  1. 1

    Numbers carry over

    Family codes 1 to 7 keep the v0.5 numbers and names, but their payloads are not interchangeable. Family 7 now carries owner handover and authorized epoch installation.

  2. 2

    Replaced or changed

    Peers negotiate the version per channel, times are UTC seconds since the Unix epoch, and domains, principals and assets are registered 64-bit handles. Registrations that v0.5 changed through GOVERNANCE routing-table updates now come from host configuration.

  3. 3

    Defined in v0.6

    proto/oip.proto fixes the wire format and field numbers, and both language bindings run the same conformance vectors.

  4. 4

    Left out

    v0.6 defines no mechanism for these items, and v0.5 left the Multi-Signatory field pending definition. A frame names one principal and carries one SINGLE signature.

  • Maps to its v0.6 counterpart
  • Not defined by that edition

Schematic: a selection of the items; the table under Items of the v0.5 documents lists every item and its treatment.

Figure 1: v0.5 items kept, replaced, changed, newly defined or left out in v0.6

Legacy path#

NormativeBytes, test vectors and proofs

The reference legacy codec is a reference compatibility record. It does not interoperate with the wire format of the public v0.5 documents.

The record is a JSON object: a core of integer fields, a hex UUID and the operand words, and a hex signature. Its signing input is the prefix OIP\0LEGACY\0 followed by the JSON of core.

Compatibility recordsrc/wire/legacy.rsL38-L76Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
/// The reference compatibility record is a configured codec for the common/// Query and Heartbeat subset, rather than implicit version fallback.#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]pub struct LegacyCore {    pub version: u64,    pub deployment: u64,    pub source: u64,    pub target: u64,    pub principal: u64,    pub key: u64,    pub epoch: u64,    pub sent: u64,    pub ttl: u64,    pub uuid: String,    pub family: u64,    pub opcode: u64,    pub words: Vec<u64>,    pub atomicity: u64,    pub scope: u64,}#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]pub struct LegacyPacket {    pub core: LegacyCore,    pub signature: String,}impl LegacyPacket {    pub fn encode(&self) -> Result<Vec<u8>> {        serde_json::to_vec(self).map_err(|_| BAD)    }}impl LegacyCore {    pub fn signing_bytes(&self) -> Result<Vec<u8>> {        let mut bytes = b"OIP\0LEGACY\0".to_vec();        bytes.extend(serde_json::to_vec(self).map_err(|_| BAD)?);        Ok(bytes)    }

Only a channel that negotiated version 5 reads the record. The record must re-encode to the received bytes, and its operation must be a Query (opens in a new tab) or Heartbeat (opens in a new tab) with opcode 1 or 2; any other operation receives UNSUPPORTED_VERSION. The record then passes the version, source and time checks of admission (opens in a new tab) for version 5 and must verify under the SINGLE (opens in a new tab) scheme.

Compatibility record admissionsrc/wire/legacy.rsL142-L169Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
pub fn receive(bytes: &[u8], profile: &Profile, boundary: &Boundary) -> Result<ValidatedLegacy> {    if profile.version != 5 {        return Err(WireError::UnsupportedVersion);    }    if bytes.len() > boundary.max_frame {        return Err(BAD);    }    let packet: LegacyPacket = serde_json::from_slice(bytes).map_err(|_| BAD)?;    if packet.encode()? != bytes {        return Err(BAD);    }    let core = packet.core.typed()?;    check_versioned_scope(&core, profile, boundary, 5)?;    if !profile.schemes.contains(&1)        || !verify(            &boundary.public_key,            &packet.core.signing_bytes()?,            &unhex(&packet.signature)?,        )    {        return Err(WireError::InvalidSignature);    }    super::operands::decode(&core, boundary.max_nesting)?;    Ok(ValidatedLegacy {        core,        exact: serde_json::to_vec(&packet.core).map_err(|_| BAD)?,    })}

Items of the v0.5 documents#

NormativeBytes, test vectors and proofs

Each row gives the v0.5 item, its treatment in this version and where this version defines it. "Replaced" means this version defines a different mechanism for the same purpose. "Not defined" means this version contains no mechanism for the item.

v0.5 itemTreatmentThis version
Version string in SemVer formReplaced. The core carries the integer version 6, and peers negotiate the version per channel.Version check (opens in a new tab)
Timestamps in ISO 8601Replaced. Times are unsigned UTC seconds since the Unix epoch in fixed64 fields.Canonical signed message (opens in a new tab)
Chain identifiers in CAIP-2 form and display addressesReplaced. Domains, principals and assets are registered 64-bit handles.Canonical signed message (opens in a new tab)
Seven message type discriminatorsKept. Family codes 1 to 7 keep the v0.5 message type numbers and names.Canonical field numbers (opens in a new tab)
Canton origins and the Canton driver attestation fieldsReplaced for origins: a Canton domain is a registered domain handle. The driver attestation and party mapping fields are not defined.Canonical signed message (opens in a new tab)
GOVERNANCE message typeChanged meaning. Family 7 carries owner handover and authorized epoch installation. Registry, routing table and party mapping changes are not wire operations.Operations (opens in a new tab)
Wire format and field numbers in a .proto schemaDefined.proto/oip.proto, Canonical field numbers (opens in a new tab)
Public repository with code generation scripts and version historyDefined. The generation scripts are in scripts/.scripts/generate-wire.py, scripts/render-operations.py
Self-check list as executable testsDefined as conformance vectors that both language bindings run.tests/vectors/, scripts/test-wire-interop.py
Message size limits and fragmentationSize and nesting limits are configuration values of the receiving host. A frame over the limit receives INVALID_FORMAT, and a request whose response could exceed the limit is refused before effect. Fragmentation is not defined, so a message fits in one frame.Canonical signed message (opens in a new tab)
Procedure for new error codes and categoriesUnknown response codes are accepted as their kind. A procedure for adding codes is not defined.Reference outbound Response (opens in a new tab)
Restricted DSL condition expressionsNot defined. The core carries no condition expression.None
Routing registry records and updates (v0.5 source)Changed mechanism. v0.5 populates ChainRegistry, AssetLocationMap, AuthorityRegistry and NodeRegistry through GOVERNANCE routing-table updates. This version takes registrations from host configuration and binds the selected policy fingerprint into retained authority state; those routing-table updates are not wire operations. A standalone signed registry-record encoding is not defined.Identity and ownership handover (opens in a new tab), JSON policy binding (opens in a new tab)
Verifiable primary-finality preservation after lock expiry or reconciliation (v0.5 invariant)Partial scope. The reference binds primary decisions, pending source obligations, authoritative historical prefixes and secondary work into its durable authority state and authenticates exact recovery/archive inputs. A deployment-wide long-term archive policy, retention periods, compaction and storage tiers are not defined. The operational strategy remains open as recorded in v0.5 Open Issues; an archive mechanism alone does not settle that policy.Authenticated persistence and recovery (opens in a new tab), Primary decisions and publication (opens in a new tab)
Multi-Signatory authority fieldNot defined. A frame names one principal and carries one SINGLE signature. Owner handover requires separate consents of the current owner and the successor.Identity and ownership handover (opens in a new tab)

Version in code#

The integer version sits in the first Core field. The declared version check reads it before the canonical decoder and refuses any version other than 6 with UNSUPPORTED_VERSION.

Declared version checksrc/wire/codec.rsL353-L365Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
/// The version check that runs in front of the canonical decoder and the/// admission gate. A frame within the size limit that declares a version/// other than 6 receives UnsupportedVersion, whatever fields follow. Every/// other frame continues to the canonical checks unchanged.pub fn check_declared_version(bytes: &[u8], max_frame: usize) -> Result<()> {    if bytes.len() > max_frame {        return Err(BAD);    }    match declared_version(bytes) {        Some(version) if version != 6 => Err(WireError::UnsupportedVersion),        _ => Ok(()),    }}

Peers choose the version once per channel: the first of 6 and 5 that both offers list and the floor allows. Version 5 also needs the host to allow the legacy path, and its channel keeps only the Query and Heartbeat families and the SINGLE scheme.

Version selectionsrc/wire/negotiation.rsL153-L188Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
pub fn select_profile(    local: &Offer,    remote: &Offer,    floor: u64,    legacy_allowed: bool,) -> Result<Profile> {    let version = [6, 5]        .into_iter()        .find(|&version| {            version >= floor                && (version != 5 || legacy_allowed)                && local.versions.contains(&version)                && remote.versions.contains(&version)        })        .ok_or(WireError::UnsupportedVersion)?;    Ok(Profile {        version,        families: local            .families            .intersection(&remote.families)            .copied()            .filter(|family| version != 5 || matches!(family, 4 | 6))            .collect(),        atomicities: local            .atomicities            .intersection(&remote.atomicities)            .copied()            .collect(),        schemes: local            .schemes            .intersection(&remote.schemes)            .copied()            .filter(|scheme| version != 5 || *scheme == 1)            .collect(),    })}

Error codes#

NormativeBytes, test vectors and proofs

v0.6 response codes are numbers grouped by kind. Effect and retry by code (opens in a new tab) gives the closest v0.5 code for each v0.6 code. The v0.5 codes with no listed counterpart have no v0.6 response code of their own.

The operation table defines the three kinds that group the codes.

JSONResponse kindsspec/operations.jsonL147-L154Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
"response_kind": {  "description": "Kind of a correlated response. A recorded response reports a retained product outcome; a delivery response reports the delivery state; a refusal reports a validation refusal.",  "values": [    {"code": 1, "name": "RECORDED"},    {"code": 2, "name": "DELIVERY"},    {"code": 3, "name": "REFUSAL"}  ]},

The last column of the specification table gives the closest v0.5 code.

TextEffect and retry by codespec/wire-conformance.mdL209-L221Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
| Codes | Effect of the original call | How to continue | Closest v0.5 code || --- | --- | --- | --- || 1 Rejected | No effect. The outcome is recorded and consumes the call identifier. | The same request returns this outcome. New work needs a new call. | ACK type REJECTED || 2 Busy | No effect, for example because the signed cut was not the current cut. The outcome is recorded and consumes the call identifier. | The same request returns Busy. Send a new signed cut with a new call sequence number, transport UUID and signature. | None || 3 Unavailable | No effect. The outcome is recorded and consumes the call identifier. | As for Busy. | None || 4 | The original Debit was closed without effect. | The original Debit cannot execute later. | None || 10 to 28 | The named effect is committed. | The same request returns the same outcome without another effect. | None || 30 to 38 | No legal effect. The outcome is recorded and consumes the call identifier. | New work needs a new call. | 33 FREEZE_REQUIRED_BEFORE_SEIZE, 34 SEIZE_REQUIRED_BEFORE_CONFISCATE, 35 APPEAL_PERIOD_NOT_EXPIRED, 31 INVALID_STATE_TRANSITION || 50 Unresolved, 51 ResponseLost, 52 DeadlineExpired | Unknown. The effect can have been committed. | Resolve the original signed request through an exact authorized resolution before reissuing the work. | DELIVERY_TIMEOUT for 50 and 52 || 53 Cancelled, 55 DeliveryBusy, 56 DeliveryUnavailable | No effect. | Send a new signed request. | None || 54 TtlExpired | No effect. | Send a new signed request with a new transport UUID and issued time. | MESSAGE_TTL_EXPIRED || 70 to 75, 77 | No effect. Nothing is recorded, and the call identifier is not consumed. | Correct the cause and send a new signed frame with a new transport UUID. | 70 INVALID_HEADER_FORMAT, 71 UNSUPPORTED_VERSION, 72 INVALID_SOURCE_AUTHENTICATION, 73 INVALID_SIGNATURE, 74 INVALID_THRESHOLD_SIGNATURE || 76 ReplayConflict | No effect. The transport UUID is already bound to different bytes, and the earlier request is unchanged. | Use a new transport UUID. Sending the identical bytes returns the retained result. | DEDUPLICATION_DETECTED |

Implementation migration sequence#

  1. Use the v0.6 schema and pinned generators from the specification repository.
  2. Replace display addresses, textual timestamps and general payload objects with the registered handles, UTC-second fields and exact operation layout.
  3. Bind current identity, cut, lock credential and evidence to the complete signed request.
  4. Test canonical rejection and effect certainty with the shared language vectors.
  5. Implement result resolution before treating a lost response as permission to issue new financial work.

Do not infer fallback from a failure. A parse, signature, timeout or rejection cannot select a legacy profile. The host must explicitly permit the authenticated legacy peer and install the corresponding codec.

Both reference receivers pick the codec from the negotiated channel version before they read the frame. A version 5 channel uses the compatibility codec only when the host configured it, and a failure on one codec returns its own error.

One codec per channelsrc/authority/recovery/wire.rsL605-L615Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
let input = match channel.profile().version {    6 => {        check_declared_version(bytes, frame_limit)?;        Input::Modern(Box::new(decode_packet(bytes, frame_limit)?))    }    5 if product.security.legacy_configured(channel) => Input::Legacy {        bytes,        core: Box::new(legacy::decode_shape(bytes, frame_limit)?),    },    _ => return Err(WireError::UnsupportedVersion),};

Renamed deadline API#

The operation's appeal deadline (opens in a new tab) uses appeal_deadline_indefinite or appeal_deadline_utc_seconds. The discriminator and byte tags retain their defined meaning. Bindings generated with earlier draft names should be regenerated together; hand-editing generated Rust or Go output breaks reproducibility.

The generated bindings name the two members of the appeal_deadline oneof after the schema fields, on tags 4 and 5.

Generated appeal deadlinesrc/wire/generated.rsL73-L93Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
#[derive(Clone, PartialEq, ::prost::Message)]pub struct Operation {    #[prost(uint64, optional, tag = "1")]    pub opcode: Option<u64>,    #[prost(fixed64, repeated, packed = "false", tag = "2")]    pub words: Vec<u64>,    #[prost(bytes = "vec", repeated, tag = "3")]    pub blobs: Vec<Vec<u8>>,    #[prost(oneof = "operation::AppealDeadline", tags = "4, 5")]    pub appeal_deadline: Option<operation::AppealDeadline>,}pub mod operation {    #[derive(Clone, PartialEq, ::prost::Oneof)]    pub enum AppealDeadline {        #[prost(bool, tag = "4")]        AppealDeadlineIndefinite(bool),        #[prost(fixed64, tag = "5")]        AppealDeadlineUtcSeconds(u64),    }}

One script writes both files from the pinned schema. With --check it leaves both files untouched and fails when either differs from a fresh generation.

PythonRegenerating both bindingsscripts/generate-wire.pyL143-L171Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
def main():    parser = argparse.ArgumentParser()    parser.add_argument("--protoc", type=Path)    parser.add_argument("--go-plugin", type=Path)    parser.add_argument("--check", action="store_true")    args = parser.parse_args()    args.protoc = tool(args.protoc, "PROTOC", "protoc")    args.go_plugin = tool(args.go_plugin, "PROTOC_GEN_GO", "protoc-gen-go")    if run([args.protoc, "--version"]) != b"libprotoc 33.5":        raise SystemExit("protoc 33.5 required")    if run([args.go_plugin, "--version"]) not in (b"protoc-gen-go v1.36.8", b"protoc-gen-go.exe v1.36.8"):        raise SystemExit("protoc-gen-go 1.36.8 required")    (ROOT / "target").mkdir(exist_ok=True)    with tempfile.TemporaryDirectory(prefix="wire-generate-", dir=ROOT / "target") as temporary:        target = Path(temporary)        descriptor = target / "schema.pb"        run([args.protoc, f"--descriptor_set_out={descriptor}", SCHEMA])        run([args.protoc, f"--plugin=protoc-gen-go={args.go_plugin}", f"--go_out={target}",             f"--go_opt=module={GO_MODULE_ROOT}", SCHEMA])        files = {RUST: rust_file(descriptor.read_bytes()), GO: (target / GO).read_bytes()}        for relative, content in files.items():            path = ROOT / relative            if args.check:                if path.read_bytes() != content:                    raise SystemExit(f"generated bytes differ: {relative}")            else:                path.parent.mkdir(parents=True, exist_ok=True)                path.write_bytes(content)    print("Rust and Go generated bindings match the pinned schema.")

Keep the two editions available#

The v0.5 pages remain at /oip-v05/. The version switcher opens the same topic in v0.6, or the closest topic when a new page has no identical predecessor. A reader can compare the design context without mistaking it for the new executable profile.


Edition history#

  • 0.6.0: Added this edition. The v0.5 specification remains available at its original addresses.