Protocol Specification v0.6
Data Model
The v0.6 data model separates transport identity, durable call identity (opens in a new tab) and original event identity (opens in a new tab). A message UUID identifies signed transport bytes; a caller-qualified call identifier binds one complete admitted request; an original domain and event identify the financial obligation. A fresh UUID does not create permission to settle an original event again.
Four identities
A fresh UUID changes only the transport key; the call, event and cut identities stay the same.
- 1
Transport key
The authenticated sender and the full 16-byte UUID. Other bytes under a bound key receive
REPLAY_CONFLICT. - 2
Call identifier
The authenticated principal is the namespace, and the caller supplies the sequence. A retry of the exact admitted call returns the existing outcome without another effect.
- 3
Original event
The origin domain and event identify the financial obligation, independent of the route. They do not depend on the transport message.
- 4
Commit cut
The authority epoch and the revision of the signed cut. Together they name the authoritative state the request expects for a new effect.
- 5
Fresh UUID
A resend that changes only the UUID binds a new transport key. It keeps the same call, event and cut, so it gives no permission to settle the event again.
- The field a fresh UUID changes
Schematic: Core n is a Core field tag and word n an operand position of an ordinary, legal or spend call.
Envelope and identity#
NormativeBytes, test vectors and proofs
| Value | Representation | Meaning |
|---|---|---|
version, purpose | Unsigned varints | Negotiated wire version and signing purpose |
| Deployment, domains, principal, key, epoch, scope | 64-bit registered handles | Configured identity and authority bindings |
issued_seconds, ttl_seconds | Fixed64 seconds | Signed delivery validity, separate from an appeal deadline |
uuid | 16 bytes | UUID version 4 with the RFC 4122 variant |
| Family and opcode | Numeric discriminators | Select the message body and exact operand layout |
| Revision and authority epoch | Signed operation cut | Expected authoritative state for a new effect |
| Origin domain and event | Exact pair | Route-independent identity of a financial obligation |
An authority MUST bind a call identifier to the authenticated caller namespace and the entire admitted request, including its operation and any lock credential. The transport UUID and the original event key have separate meanings. Domains and principals carry no CAIP-2 (opens in a new tab) text or display address in this wire format.
The schema declares the Core fields with their tags and wire types. The registered handles are fixed64 fields and the UUID is a byte string:
message Core { optional uint64 version = 1; optional uint64 purpose = 2; optional fixed64 deployment = 3; optional fixed64 source_domain = 4; optional fixed64 target_domain = 5; optional fixed64 principal = 6; optional fixed64 key_id = 7; optional fixed64 authority_epoch = 8; optional fixed64 issued_seconds = 9; optional fixed64 ttl_seconds = 10; optional bytes uuid = 11; optional uint64 family = 12; optional uint64 primary = 13; optional uint64 secondary = 14; optional uint64 signature_scheme = 15; // Exactly one member is present, and its tag is 15 plus the family. oneof body { Operation state_sync = 16; Operation regulatory_action = 17; Operation lock_management = 18; Operation query = 19; Operation ack = 20; Operation heartbeat = 21; Operation governance = 22; } optional uint64 atomicity = 23; optional uint64 priority = 24; optional fixed64 route_scope = 25;}Transport key#
The receiver keys retained transport bytes by deployment, source domain, principal and the complete 16-byte UUID:
#[derive( Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize,)]pub struct TransportKey { deployment: u64, source: u64, principal: u64, uuid: [u8; 16],}impl TransportKey { pub fn for_core(core: &Core) -> Result<Self> { let bad = WireError::InvalidFormat; Ok(Self { deployment: core.deployment.ok_or(bad)?, source: core.source_domain.ok_or(bad)?, principal: core.principal.ok_or(bad)?, uuid: core .uuid .as_deref() .ok_or(bad)? .try_into() .map_err(|_| bad)?, }) }}type TransportKey struct { Deployment, Source, Principal uint64 UUID [16]byte}func Key(core *wirepb.Core) (TransportKey, error) { if core.Deployment == nil || core.SourceDomain == nil || core.Principal == nil || len(core.Uuid) != 16 { return TransportKey{}, ErrFormat } return TransportKey{Deployment: core.GetDeployment(), Source: core.GetSourceDomain(), Principal: core.GetPrincipal(), UUID: [16]byte(core.Uuid)}, nil}A shared vector row holds both languages to the whole UUID. Two UUIDs that differ only in their first octet give different keys:
Columnsidoperationinputsexpectedevidence
complete-uuid-with-distinct-upper-octets receive uuid_a=000102030405400780090a0b0c0d0e0f;uuid_b=100102030405400780090a0b0c0d0e0f;low_octets_same=true identity_keys_equal=false authenticated_wire_message_id_def,accepted_core_bytes_preserve_all_original_fields Call, event and cut identities#
The call identifier, the original event key and the commit cut (opens in a new tab) are separate types in the reference:
#[derive( Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,)]pub struct CallId { pub namespace: Principal, pub sequence: u64,}#[derive( Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,)]pub struct OriginKey { pub domain: u64, pub event: u64,}#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]pub struct CommitCut { pub authority_epoch: u64, pub revision: usize,}Words, blobs and deadlines#
NormativeBytes, test vectors and proofs
Operation.words are unpacked fixed64 fields. Operation.blobs are length-delimited byte strings. Field Tables gives every position and accepted code. An optional value starts with an exact 0 or 1 presence word; a counted set has an exact count and strictly ascending members.
The schema declares the record once, and both generated bindings keep its tags, wire types and deadline alternatives:
// An operation carries positional operands. The family of the enclosing body// member and the opcode fix the meaning and order of every entry in words and// blobs. The field-meaning table spec/operations.json lists, for each family// and opcode, the positions, enumerations and whether the reference// configuration accepts the operation.message Operation { optional uint64 opcode = 1; repeated fixed64 words = 2 [packed = false]; repeated bytes blobs = 3; // The appeal deadline of a regulatory action (family 2). Operations of // other families reject both members. In this version an absent member and // appeal_deadline_indefinite both mean that no appeal deadline applies. oneof appeal_deadline { // Only the value true is canonical. bool appeal_deadline_indefinite = 4; // Unix time in UTC seconds. fixed64 appeal_deadline_utc_seconds = 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), }}// An operation carries positional operands. The family of the enclosing body// member and the opcode fix the meaning and order of every entry in words and// blobs. The field-meaning table spec/operations.json lists, for each family// and opcode, the positions, enumerations and whether the reference// configuration accepts the operation.type Operation struct { state protoimpl.MessageState `protogen:"open.v1"` Opcode *uint64 `protobuf:"varint,1,opt,name=opcode" json:"opcode,omitempty"` Words []uint64 `protobuf:"fixed64,2,rep,name=words" json:"words,omitempty"` Blobs [][]byte `protobuf:"bytes,3,rep,name=blobs" json:"blobs,omitempty"` // The appeal deadline of a regulatory action (family 2). Operations of // other families reject both members. In this version an absent member and // appeal_deadline_indefinite both mean that no appeal deadline applies. // // Types that are valid to be assigned to AppealDeadline: // // *Operation_AppealDeadlineIndefinite // *Operation_AppealDeadlineUtcSeconds AppealDeadline isOperation_AppealDeadline `protobuf_oneof:"appeal_deadline"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache}In Go the two deadline alternatives are wrapper types that carry tags 4 and 5:
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"`}The operand reader takes a presence word only as 0 or 1, takes a set only with strictly ascending members, and requires every word to be consumed:
fn present(&mut self) -> Result<bool> { match self.word()? { 0 => Ok(false), 1 => Ok(true), _ => Err(BAD), }}fn optional_origin(&mut self) -> Result<Option<OriginKey>> { if self.present()? { Ok(Some(self.origin()?)) } else { Ok(None) }}fn optional_word(&mut self) -> Result<Option<u64>> { if self.present()? { Ok(Some(self.word()?)) } else { Ok(None) }}fn take(&mut self, count: usize) -> Result<&[u64]> { let (items, remaining) = self.0.split_at_checked(count).ok_or(BAD)?; self.0 = remaining; Ok(items)}fn set(&mut self) -> Result<BTreeSet<u64>> { let count = self.size()?; let items = self.take(count)?; if items.windows(2).any(|pair| pair[0] >= pair[1]) { return Err(BAD); } Ok(items.iter().copied().collect())}fn end(&self) -> Result<()> { if self.0.is_empty() { Ok(()) } else { Err(BAD) }}func (w *words) present() (bool, error) { value, err := w.word() if err != nil || value > 1 { return false, ErrFormat } return value == 1, nil}func (w *words) set() error { count, err := w.word() if err != nil { return err } values, err := w.take(count) if err != nil || !strictlySorted(values) { return ErrFormat } return nil}func (w *words) optional(count uint64) error { present, err := w.present() if err == nil && present { _, err = w.take(count) } return err}func (w *words) end() error { if len(w.items) != 0 { return ErrFormat } return nil}Amounts use exactly 32 big-endian bytes. The reference product accepts only the unsigned 64-bit range, so the first 24 bytes MUST be zero. Amount units are base units, not whole tokens. A decoder must reject a value outside the product range rather than truncate it.
The amount blob decodes only at exactly 32 bytes, and the conversion to the product range refuses any nonzero byte among the first 24:
#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub struct Unsigned256(pub [u8; 32]);impl Unsigned256 { pub fn from_bytes(bytes: &[u8]) -> Result<Self> { Ok(Self(bytes.try_into().map_err(|_| BAD)?)) } pub fn product_amount(self) -> Result<u64> { if self.0[..24].iter().any(|&byte| byte != 0) { return Err(BAD); } Ok(u64::from_be_bytes(self.0[24..].try_into().unwrap())) }}type Unsigned256 [32]bytefunc DecodeAmount(raw []byte) (Unsigned256, error) { if len(raw) != 32 { return Unsigned256{}, ErrFormat } return Unsigned256(raw), nil}func (amount Unsigned256) ProductAmount() (uint64, error) { for _, byte := range amount[:24] { if byte != 0 { return 0, ErrFormat } } return binary.BigEndian.Uint64(amount[24:]), nil}| Appeal deadline form | Encoded form | Product meaning in this edition |
|---|---|---|
| Absent | Neither alternative appears | No appeal deadline |
| Indefinite | appeal_deadline_indefinite = true | No appeal deadline |
| Finite | appeal_deadline_utc_seconds | UTC seconds since the Unix epoch |
The absent and indefinite forms remain different bytes even though this product gives them the same meaning. Non-regulatory layouts reject either deadline alternative.
Two shared vector rows pin both rules in Rust and Go. An all-ones 32-byte amount survives the wire and fails the product conversion, and the three deadline forms give three distinct encodings (distinct_bytes) that each decode back to their own form (distinct_semantics):
Columnsidoperationinputsexpectedevidence
unsigned256-lossless-before-product-range-check roundtrip amount_hex=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;length=32;product_amount_max=18446744073709551615 wire_roundtrip=true;product_conversion_rejected=true exact_32_octet_unsigned_amount_has_no_accepted_alias,native_envelope_matches_def three-deadline-presence-states roundtrip cases=absent,indefinite,finite_0 distinct_bytes=true;distinct_semantics=true exact_deadline_roundtrip,wellformed_wire_body_roundtrip Transfer binding and current authority#
NormativeBytes, test vectors and proofs
A transfer binds its source event, asset, funding owner, destination, recipient, amount and original authority epoch (opens in a new tab). A lock credential separately names the asset, current holder and generation. Owner and lock holder are different roles. The consuming endpoint checks the current authority and exact binding at the commit cut.
The binding names the original event, asset, owner, destination, recipient and amount. The lock credential is a separate record with its own holder, generation and epoch:
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]pub struct Binding { pub origin: OriginKey, pub asset: AssetId, pub owner: Principal, pub destination: EndpointId, pub recipient: Principal, pub amount: u64,}impl Binding { pub fn source(self) -> Account { Account { endpoint: self.origin.domain, owner: self.owner, asset: self.asset, } } pub fn destination(self) -> Account { Account { endpoint: self.destination, owner: self.recipient, asset: self.asset, } }}#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]pub struct Credential { pub asset: AssetId, pub origin: OriginKey, pub holder: Principal, pub generation: u64, pub epoch: u64,}type Origin struct{ Domain, Event uint64 }type Binding struct { Origin Origin Asset, Owner, Destination, Recipient, Amount uint64}type Credential struct { Asset uint64 Origin Origin Holder, Generation, Epoch uint64}The source balance is the authoritative unallocated source pool at the selected domain and asset. Changing the recovery owner does not choose another source pool. Current and historical reads use the pool from their own checked cut.
The reference keeps four identities distinct:
- Transport key: the authenticated sender and the full UUID.
- Call identifier: the authenticated caller namespace and the call sequence.
- Original event: the source domain and the source event.
- Commit cut: the authority epoch and the product revision.
Ordinary, legal and spend calls read the call identifier, the commit cut and the original event from one shared prefix. In Rust the call namespace and the cut's epoch come from the signed Core, and the call sequence, the revision and the original event come from the words. Go reads the same words in the same order and takes the namespace from the Core; it reads the cut's epoch from the Core where it needs it, as in the admission (opens in a new tab) scope check:
fn native<'a>(core: &Core, op: &'a Operation) -> Result<(CallId, CallPayload, Words<'a>)> { let mut words = Words(&op.words); let message_id = words.word()?; let caller = core.principal.ok_or(BAD)?; let id = CallId { namespace: caller, sequence: words.word()?, }; let session = words.word()?; let cut = CommitCut { authority_epoch: core.authority_epoch.ok_or(BAD)?, revision: words.size()?, }; let origin_epoch = words.word()?; let binding = Binding { origin: words.origin()?, asset: words.word()?, owner: words.word()?, destination: words.word()?, recipient: words.word()?, amount: amount(op.blobs.first().ok_or(BAD)?)?, }; Ok(( id, CallPayload { message_id, origin_epoch, endpoint: core.target_domain.ok_or(BAD)?, caller, session, cut, binding, credential: None, versions: Vec::new(), command: Command::PublishPrimary, }, words, ))}// native reads the call prefix that ordinary, legal and spend operations// share: the words in order, then the amount in the first blob.func native(core *wirepb.Core, op *wirepb.Operation) (*Native, words, error) { w := words{items: op.Words} n := &Native{Namespace:core.GetPrincipal(),Endpoint:core.GetTargetDomain(),Caller:core.GetPrincipal()} b := &n.Binding if err := w.read(&n.Reference, &n.Sequence, &n.Session, &n.Revision, &n.OriginEpoch, &b.Origin.Domain, &b.Origin.Event, &b.Asset, &b.Owner, &b.Destination, &b.Recipient); err != nil { return nil, w, err } if len(op.Blobs) == 0 { return nil, w, ErrFormat } value, err := amount(op.Blobs[0]) if err != nil { return nil, w, err } b.Amount = value return n, w, nil}Supporting evidence#
NormativeBytes, test vectors and proofs
An authenticated source attestation, legal proof or owner endorsement must bind the same canonical fields that the product consumes. A host adapter supplies validated proof objects; it cannot replace the request with different data. The immutable origin epoch stays distinct from the current caller's session and epoch.
Architecture-level types#
DesignCarried from v0.5, without bytes or test vectors in this version
The display-oriented TypeScript interfaces of v0.5 are design descriptions. They are not the v0.6 serialized schema. Address resolution (opens in a new tab), Canton (opens in a new tab) party mappings and general condition expressions (opens in a new tab) have no wire fields in this edition. Use Migration from v0.5 when translating an existing design.
Related Pages#
Edition history#
- 0.6.0: Added this edition. The v0.5 specification remains available at its original addresses.
