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.
- 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
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
Defined in v0.6
proto/oip.protofixes the wire format and field numbers, and both language bindings run the same conformance vectors. - 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.
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.
/// 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) }type LegacyCore struct { Version uint64 `json:"version"` Deployment uint64 `json:"deployment"` Source uint64 `json:"source"` Target uint64 `json:"target"` Principal uint64 `json:"principal"` Key uint64 `json:"key"` Epoch uint64 `json:"epoch"` Sent uint64 `json:"sent"` TTL uint64 `json:"ttl"` UUID string `json:"uuid"` Family uint64 `json:"family"` Opcode uint64 `json:"opcode"` Words []uint64 `json:"words"` Atomicity uint64 `json:"atomicity"` Scope uint64 `json:"scope"`}type LegacyPacket struct { Core LegacyCore `json:"core"` Signature string `json:"signature"`}func (core LegacyCore) SigningBytes() ([]byte, error) { body, err := json.Marshal(core) if err != nil { return nil, ErrFormat } return append([]byte("OIP\x00LEGACY\x00"), body...), nil}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.
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)?, })}func DecodeLegacy(raw []byte, profile Profile, boundary Boundary) (*wirepb.Core, []byte, error) { if profile.Version != 5 { return nil, nil, ErrVersion } if len(raw) > boundary.MaxFrame { return nil, nil, ErrFormat } var packet LegacyPacket if !canonicalJSON(raw, &packet) { return nil, nil, ErrFormat } core, err := packet.Core.typed() if err != nil { return nil, nil, err } if err := checkScope(core, profile, boundary, 5); err != nil { return nil, nil, err } // The scheme check comes first, then the signature encoding, then the // signature itself, as in the reference decoder. if !slices.Contains(profile.Schemes, uint64(1)) { return nil, nil, ErrSignature } preimage, err := packet.Core.SigningBytes() if err != nil { return nil, nil, ErrFormat } signature, err := hex.DecodeString(packet.Signature) if err != nil || hex.EncodeToString(signature) != packet.Signature { return nil, nil, ErrFormat } if !verify(boundary.PublicKey, preimage, signature) { return nil, nil, ErrSignature } if _, err := DecodeOperands(core, boundary.MaxNesting); err != nil { return nil, nil, err } exact, err := json.Marshal(packet.Core) if err != nil { return nil, nil, ErrFormat } return core, exact, nil}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 item | Treatment | This version |
|---|---|---|
| Version string in SemVer form | Replaced. The core carries the integer version 6, and peers negotiate the version per channel. | Version check (opens in a new tab) |
| Timestamps in ISO 8601 | Replaced. 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 addresses | Replaced. Domains, principals and assets are registered 64-bit handles. | Canonical signed message (opens in a new tab) |
| Seven message type discriminators | Kept. 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 fields | Replaced 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 type | Changed 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 schema | Defined. | proto/oip.proto, Canonical field numbers (opens in a new tab) |
| Public repository with code generation scripts and version history | Defined. The generation scripts are in scripts/. | scripts/generate-wire.py, scripts/render-operations.py |
| Self-check list as executable tests | Defined as conformance vectors that both language bindings run. | tests/vectors/, scripts/test-wire-interop.py |
| Message size limits and fragmentation | Size 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 categories | Unknown 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 expressions | Not 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 field | Not 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.
/// 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(()), }}// CheckDeclaredVersion is 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 ErrVersion, whatever fields// follow. Every other frame continues to the canonical checks unchanged.func CheckDeclaredVersion(raw []byte, maxFrame int) error { if maxFrame < 0 || len(raw) > maxFrame { return ErrFormat } if version, ok := DeclaredVersion(raw); ok && version != 6 { return ErrVersion } return nil}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.
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(), })}func SelectProfile(local, remote Offer, floor uint64, legacyAllowed bool) (Profile, error) { for _, s := range [][]uint64{local.Versions, local.Families, local.Atomicities, local.Schemes, remote.Versions, remote.Families, remote.Atomicities, remote.Schemes} { if !strictlySorted(s) { return Profile{}, ErrFormat } } for _, v := range []uint64{6, 5} { if v >= floor && (v != 5 || legacyAllowed) && slices.Contains(local.Versions, v) && slices.Contains(remote.Versions, v) { families := intersect(local.Families, remote.Families) schemes := intersect(local.Schemes, remote.Schemes) if v == 5 { families = intersect(families, []uint64{4, 6}) schemes = intersect(schemes, []uint64{1}) } return Profile{Version: v, Families: families, Atomicities: intersect(local.Atomicities, remote.Atomicities), Schemes: schemes}, nil } } return Profile{}, ErrVersion}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.
"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.
| 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#
- Use the v0.6 schema and pinned generators from the specification repository.
- Replace display addresses, textual timestamps and general payload objects with the registered handles, UTC-second fields and exact operation layout.
- Bind current identity, cut, lock credential and evidence to the complete signed request.
- Test canonical rejection and effect certainty with the shared language vectors.
- 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.
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),};// ReceiveVersioned uses the negotiated codec once. A modern failure cannot// enter the configured legacy adapter.func (store *TransportBindings) ReceiveVersioned(raw []byte, channel Channel, boundary Boundary, legacyConfigured bool, attest func(*wirepb.Core) bool) (Arrival, *wirepb.Core, error) { if channel.deployment != boundary.Deployment || channel.local != boundary.Target || channel.remote != boundary.Source || channel.expires < boundary.Now { return First, nil, ErrSource } switch channel.profile.Version { case 6: arrival, packet, err := store.Receive(raw, channel.profile, boundary, attest) if err != nil { return First, nil, err } return arrival, packet.Core(), nil case 5: if !legacyConfigured { return First, nil, ErrVersion } // DecodeLegacy has already decoded the operands of the admitted Core. core, exact, err := DecodeLegacy(raw, channel.profile, boundary) if err != nil { return First, nil, err } // Keep attestation mutations separate from the admitted Core and key. if !attest(proto.Clone(core).(*wirepb.Core)) { return First, nil, ErrSource } key, err := Key(core) if err != nil { return First, nil, err } arrival, err := store.bind(key, exact) if err != nil { return First, nil, err } return arrival, core, nil default: return First, nil, ErrVersion }}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.
#[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), }}type isOperation_AppealDeadline interface { isOperation_AppealDeadline()}type Operation_AppealDeadlineIndefinite struct { // Only the value true is canonical. AppealDeadlineIndefinite bool `protobuf:"varint,4,opt,name=appeal_deadline_indefinite,json=appealDeadlineIndefinite,oneof"`}type Operation_AppealDeadlineUtcSeconds struct { // Unix time in UTC seconds. AppealDeadlineUtcSeconds uint64 `protobuf:"fixed64,5,opt,name=appeal_deadline_utc_seconds,json=appealDeadlineUtcSeconds,oneof"`}func (*Operation_AppealDeadlineIndefinite) isOperation_AppealDeadline() {}func (*Operation_AppealDeadlineUtcSeconds) isOperation_AppealDeadline() {}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.
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.
Related Pages#
Edition history#
- 0.6.0: Added this edition. The v0.5 specification remains available at its original addresses.
