Skip to content

Kontinuum Node — Wire Types

Formal CBOR encoding rules для inter-node wire protocol; mapping на Rust types в kontinuum-core::node; schema evolution policy; golden test vectors. P0 prerequisite — final binding между protocols.md design и actual implementation.

Audience: node developers пишущие wire layer. Также — implementers любых alternative clients (future Kotlin/Swift mobile native).

Связанные документы:


Encoding choice — CBOR

RFC 8949 Concise Binary Object Representation, length-prefixed на wire.

Rationale (см. v0.4 decision в architecture.md roadmap):

  • Cross-language standard, IETF-ratified. Future Kotlin/Swift native clients не требуют Rust binding.
  • Schema-explicit on-wire (tagged types) — позволяет detect malformed input без relying на Rust serde guesswork.
  • Schema evolution through optional fields + unknown-field skipping. Critical для long-lived wire contract (5+ years).
  • Smaller chance corruption attacks — CBOR validators reject malformed input cleanly.
  • Slight overhead (~10-15%) vs bincode acceptable для inter-node traffic (не bottleneck).

bincode оставлен только для internal SQLite blobs (dht_records.signed_record, etc.) — где cross-language не нужен и performance matters.

Canonical CBOR

Используем deterministic encoding (RFC 8949 §4.2.1):

  • Definite-length encoding для всех arrays/maps/strings (no indefinite-length).
  • Shortest integer encoding (0..23 в 1 byte, 24..255 в 2 bytes, и т.д.).
  • Map keys в byte-wise sorted order (lexicographic ordering of CBOR encodings).
  • No half-precision floats (используем 32 или 64 bit).
  • String length минимально кодирована.

Это гарантирует что encode(decode(X)) == X byte-for-byte — критично для signature verification (подпись над canonical encoding).

Library choice (Rust)

CrateProsCons
ciboriumPure Rust, modern, serde integrationНе enforces canonical encoding by default — нужны custom checks
serde_cborMature, широко usedDeprecated, unmaintained
minicborNo-std friendly, canonical modeCustom derive (не serde-compatible)

Recommendation: ciborium 0.2+ с custom to_canonical_vec() wrapper, который проверяет deterministic encoding constraints. Альтернатива: minicbor если важна no-std support для embedded.


Frame structure

Outer envelope

cbor
;; Top-level frame, length-prefixed on wire.
;; Length prefix: 4-byte big-endian u32 (max frame size 16 MiB).

Frame = {
    0: dht_namespace,   ;; bytes (32) — selects routing table
    1: payload,         ;; WireMessage tagged union
}

;; Map keys integers (compact). Alternative: string keys для human-readable
;; debugging но больший overhead. Integer keys chosen для production.

Rust mapping (kontinuum-core/src/node/wire.rs):

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frame {
    #[serde(rename = "0")]
    pub dht_namespace: DhtId,
    #[serde(rename = "1")]
    pub payload: WireMessage,
}

(Или через ciborium::tag::Required если нужны tagged numbers — TBD на implementation phase.)

Tagged union — WireMessage

CBOR tagged structure: первое поле — discriminator (integer), второе — variant payload.

cbor
WireMessage = [discriminator, body]
;; where discriminator is uint:
;;   0  = NodeHello
;;   1  = NodeHelloAck
;;   10 = DhtPut
;;   11 = DhtGet
;;   12 = DhtGetResponse
;;   13 = DhtFindNode
;;   14 = DhtFindNodeResponse
;;   20 = ReplicaOffer
;;   21 = ReplicaPull
;;   22 = ReplicaPush
;;   23 = ReplicaAck
;;   30 = MerkleRootExchange
;;   31 = MerkleDescend
;;   32 = MerkleLeaf
;;   40 = StorageChallenge
;;   41 = StorageProof
;;   42 = ChallengeResult
;;   50 = MailboxDeposit
;;   51 = MailboxHandover
;;   52 = MailboxCursorUpdate
;;   53 = MailboxFlush
;;   60 = CertRevocation
;;   61 = CertRenewal
;;   62 = TierTransition
;;   70 = NodeStatus

Numbers reserved by category — 10 slots per category для future expansion без conflicts. Категории см. protocols.md §11 (a-h).

Rust mapping:

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]   // or untagged with manual deserialization
pub enum WireMessage {
    NodeHello(NodeHello) = 0,
    NodeHelloAck(NodeHelloAck) = 1,
    DhtPut(DhtPut) = 10,
    // ... etc
}

Note: Rust enum discriminant с explicit number требует #![feature(arbitrary_enum_discriminant)] (stable since 1.66) или custom Serialize impl. Решить при имплементации.


Frame catalog

Полные schemas для каждого frame type. Field numbering preserved для backwards compatibility.

(a) Handshake

NodeHello (0)

cbor
NodeHello = {
    0: node_id,                  ;; bytes(32)
    1: tier,                     ;; uint (0 | 1 | 2)
    2: cert?,                    ;; Tier0SignedCert или null (для Tier 0)
    3: capabilities,             ;; [Capability]
    4: capacity,                 ;; NodeCapacity
    5: participates_in_dhts,     ;; [bytes(32)]
    6: protocol_version,         ;; uint
    ;; ?7-15 reserved for future fields (additive)
}

NodeCapacity = {
    0: storage_bytes,            ;; uint
    1: dht_records,              ;; uint
    2: bandwidth_mbps,           ;; uint
}

Capability = uint
;; 0 = dht_routing (always set, redundantly)
;; 1 = relay
;; 2 = storage
;; 3 = mailbox
;; 4 = s3_gateway
;; 5 = anti_entropy
;; 6 = rendezvous
;; 100+ reserved for future capabilities

Tier0SignedCert = {
    0: body,                     ;; CertBody (см. ниже)
    1: signatures,               ;; [SignedByTier0] — multi-sig array
}

SignedByTier0 = {
    0: tier0_pubkey,             ;; bytes(32)
    1: signature,                ;; bytes(64)
}

CertBody = {
    0: node_id,                  ;; bytes(32)
    1: node_pubkey,              ;; bytes(32)
    2: tier,                     ;; uint
    3: tenancy,                  ;; uint (0 = multi, 1 = single)
    4: operator,                 ;; uint (0 = org, 1 = byo:vps, 2 = byo:home)
    5: served_identity?,         ;; bytes(32) для single-tenant; null для multi
    6: capabilities,             ;; [Capability]
    7: capacity,                 ;; NodeCapacity
    8: issued_at,                ;; uint (unix epoch sec)
    9: valid_until,              ;; uint
}

Notes:

  • В v0.1 signatures array содержит 1 entry (single-key Tier 0). v1.0 — 3-of-5, array length = 3.
  • Validation: для каждого signature в array, tier0_pubkey must быть в TIER0_PUBKEYS (hardcoded). Threshold check: signatures.len() >= TIER0_MULTISIG_CERT_THRESHOLD.

NodeHelloAck (1)

cbor
NodeHelloAck = {
    0: node_id,                  ;; bytes(32) responder's node_id
    1: accepted,                 ;; bool
    2: reject_reason?,           ;; text (если accepted = false)
    3: my_clock,                 ;; uint (unix epoch sec)
}

Reject reasons (reject_reason enum string): "cert_invalid", "protocol_version_mismatch", "drain_mode", "rate_limited", "unknown_error".

(b) DHT operations

DhtPut (10)

cbor
DhtPut = {
    0: key,                      ;; bytes(32) DHT key
    1: signed_record,            ;; bytes — CBOR-encoded Signed<DhtRecord>
    2: transaction_id?,          ;; bytes(32) для atomic-group ops (§5.2.2)
    3: transaction_total?,       ;; uint
    4: transaction_index?,       ;; uint
}

Receiving node behaviour:

  • Если transaction_id отсутствует → apply immediate.
  • Если present → quarantine, ждать остальные op'ы транзакции; timeout 60 sec → reject все.

DhtGet (11) → DhtGetResponse (12)

cbor
DhtGet = {
    0: key,                      ;; bytes(32)
    1: request_id,               ;; uint (correlation)
}

DhtGetResponse = {
    0: request_id,               ;; uint (echo)
    1: records,                  ;; [bytes] (concurrent records — caller resolves через signed timestamp LWW)
}

DhtFindNode (13) → DhtFindNodeResponse (14)

cbor
DhtFindNode = {
    0: target,                   ;; bytes(32) — target xor-distance point
    1: request_id,               ;; uint
}

DhtFindNodeResponse = {
    0: request_id,               ;; uint
    1: closest,                  ;; [PeerEntry] — up to k=20 closest peers known
}

PeerEntry = {
    0: node_id,                  ;; bytes(32)
    1: multiaddrs,               ;; [text] — libp2p multiaddr strings
}

(c) Replication

cbor
ReplicaOffer = {
    0: blob_hash,                ;; bytes(32)
    1: size,                     ;; uint
    2: owner_identity,           ;; bytes(32)
    3: pin_proof,                ;; bytes — signed proof of pin ownership
}

ReplicaPull = {
    0: blob_hash,                ;; bytes(32)
}

ReplicaPush = {
    0: blob_hash,                ;; bytes(32)
    1: chunks,                   ;; [bytes] (chunked для streaming)
    2: manifest,                 ;; bytes — CBOR-encoded BlobManifest
}

ReplicaAck = {
    0: blob_hash,                ;; bytes(32)
    1: result,                   ;; uint (0 = Stored, 1 = RejectedQuota, 2 = RejectedDuplicate)
}

(d) Anti-entropy

cbor
MerkleRootExchange = {
    0: dht_id,                   ;; bytes(32)
    1: partition_start,          ;; bytes(32) — keyspace range start
    2: partition_end,            ;; bytes(32) — keyspace range end
    3: root_hash,                ;; bytes(32) — Merkle root over keys in range
    4: partition_record_count,   ;; uint — count of records (для quick sanity check)
}

MerkleDescend = {
    0: dht_id,                   ;; bytes(32)
    1: partition_start,          ;; bytes(32)
    2: partition_end,            ;; bytes(32)
    3: branch_path,              ;; bytes — bit-path в Merkle tree
}

MerkleLeaf = {
    0: dht_id,                   ;; bytes(32)
    1: key_range_start,          ;; bytes(32)
    2: key_range_end,            ;; bytes(32)
    3: records,                  ;; [bytes] — signed records in range
}

(e) Challenge-response (PoS)

cbor
StorageChallenge = {
    0: challenger,               ;; bytes(32) NodeId
    1: blob_hash,                ;; bytes(32)
    2: byte_offset,              ;; uint
    3: byte_length,              ;; uint
    4: merkle_path,              ;; MerklePath
    5: nonce,                    ;; bytes(16)
}

MerklePath = {
    0: depth,                    ;; uint
    1: index,                    ;; uint
}

StorageProof = {
    0: challenger,               ;; bytes(32)
    1: blob_hash,                ;; bytes(32)
    2: byte_window_hash,         ;; bytes(32) — blake3 of requested byte window
    3: merkle_proof,             ;; MerkleProof
    4: signature,                ;; bytes(64) — signed proof by responder
}

MerkleProof = {
    0: leaf,                     ;; bytes(32)
    1: siblings,                 ;; [bytes(32)]
    2: path,                     ;; MerklePath
}

ChallengeResult = {
    0: responder,                ;; bytes(32) NodeId
    1: blob_hash,                ;; bytes(32)
    2: outcome,                  ;; uint (0 = Passed, 1 = Failed, 2 = Timeout)
    3: score_delta,              ;; int (могут быть negative)
}

(f) Mailbox

cbor
MailboxDeposit = {
    0: recipient_identity,       ;; bytes(32)
    1: encrypted_payload,        ;; bytes — E2E encrypted by sender
    2: sender_signature,         ;; bytes(64)
    3: ttl,                      ;; uint (seconds)
}

MailboxHandover = {
    0: recipient_identity,       ;; bytes(32)
    1: target_node,              ;; bytes(32) NodeId
}

MailboxCursorUpdate = {
    0: device_id,                ;; bytes(32)
    1: cursor_seqno,             ;; uint
    2: device_signature,         ;; bytes(64) — signed by device, anti-forge
}

MailboxFlush = {
    0: recipient_identity,       ;; bytes(32)
    1: drained_until_seqno,      ;; uint
}

(g) Lifecycle

cbor
CertRevocation = {
    0: revoked_node_id,          ;; bytes(32)
    1: reason,                   ;; text
    2: tier0_signatures,         ;; [SignedByTier0] — multi-sig (v1.0)
    3: revoked_at,               ;; uint
}

CertRenewal = {
    0: node_id,                  ;; bytes(32)
    1: new_cert,                 ;; Tier0SignedCert
}

TierTransition = {
    0: node_id,                  ;; bytes(32)
    1: from_tier,                ;; uint
    2: to_tier,                  ;; uint
    3: effective_at,             ;; uint
}

(h) Status

cbor
NodeStatus = {
    0: current_load_pct,         ;; uint (0..100)
    1: storage_used_pct,         ;; uint (0..100)
    2: dht_records,              ;; uint
    3: last_lookup_ms,           ;; uint — p99 lookup latency last cycle
    4: recent_failures,          ;; uint
}

Universal: Signed envelope

cbor
Signed<T> = {
    0: node_id,                  ;; bytes(32)
    1: signature,                ;; bytes(64) — signed over CBOR-canonical encoding of payload
    2: payload,                  ;; T
}

Все frames sent over wire выше — wrapped в Signed<Frame> (sender's signature). Recipient verifies signature через cached pubkey (узнал в NodeHello handshake).


DHT record types

Records хранятся в dht_records.signed_record (BLOB в SQLite). Тоже CBOR-encoded Signed<X> где X — один из:

cbor
;; node:{node_id}
NodeRegistryRecord = {
    0: node_id,                  ;; bytes(32)
    1: multiaddrs,               ;; [text]
    2: tier,                     ;; uint
    3: capabilities,             ;; [Capability]
    4: geo_zone,                 ;; text
    5: last_seen,                ;; uint
}

;; crl:{tier0_pubkey}
CrlRecord = {
    0: tier0_pubkey,             ;; bytes(32)
    1: revoked_node_ids,         ;; [bytes(32)]
    2: generation,               ;; uint (monotonic)
    3: timestamp,                ;; uint
}

;; identity:{identity_id_hash}
IdentityStubRecord = {
    0: identity_id_hash,         ;; bytes(32) blake3(identity_id) (one extra hop for privacy)
    1: pubkey,                   ;; bytes(32) Ed25519
    2: personal_space_hint?,     ;; PersonalSpaceHint или null
    3: last_seen,                ;; uint
}

PersonalSpaceHint = {
    0: space_dht_id,             ;; bytes(32)
    1: bootstrap_multiaddrs,     ;; [text]
}

;; pub-space:{space_id}
PublicSpaceManifest = {
    0: space_id,                 ;; bytes(32)
    1: owner_identity,           ;; bytes(32)
    2: manifest_hash,            ;; bytes(32) blake3 of full content manifest
    3: replication_set,          ;; [bytes(32)] NodeIds
}

;; cnt:{blob_hash}
ContentProviderRecord = {
    0: blob_hash,                ;; bytes(32)
    1: provider_node_id,         ;; bytes(32)
    2: region,                   ;; text
    3: provider_kind,            ;; uint (0 = LocalRelayFree, ... — см. db-schemas.md)
    4: until,                    ;; uint (verified_until)
}

;; member-op record (Space DHT)
SignedMemberOp = {
    0: op_payload,               ;; MemberOp
    1: op_seqno,                 ;; uint (monotonic)
    2: transaction_id?,          ;; bytes(32) optional
    3: transaction_total?,       ;; uint
    4: transaction_index?,       ;; uint
    5: signed_by,                ;; bytes(32) identity_id
    6: signature,                ;; bytes(64)
}

MemberOp = [discriminator, body]
;; 0 = Add { identity_id, pubkey, role, added_at }
;; 1 = Revoke { identity_id, revoked_at, reason? }
;; 2 = KeyRotation { new_space_key_version, wrapped_keys, rotated_at }
;; 3 = RoleChange { identity_id, new_role, changed_at }
;; 4 = SelfLeave { identity_id, left_at }

Schema evolution

Rules для backwards compatibility

  1. Adding fields — OK if optional (нет в required map). Receivers ignoring unknown fields.
  2. Removing fields — Major version bump (protocol_version).
  3. Changing field type — Major version bump.
  4. Changing field numberForbidden ever (would break old encoders).
  5. Adding new variant в tagged union — OK; older receivers reject unknown discriminator.
  6. Removing variant — Major version bump.
  7. Adding new top-level frame type — OK; new discriminator number.
  8. Renaming variants — OK on Rust side (CBOR uses numeric discriminator, не string).

Version negotiation

NodeHello.protocol_version — uint, incremented при breaking changes.

VersionRangeCompatibility
0v0.1 — v0.6 (pre-implementation drafts)N/A (no production traffic)
1v1.0 launchProduction baseline
2v1.1+ (first breaking change)Maintain compat for 6 months overlap
...Always overlap window ≥ 6 months

Mismatch handling:

  • responder.version > initiator.version — responder может downgrade response к initiator's version (if known compatible).
  • responder.version < initiator.version — responder rejects with protocol_version_mismatch.
  • responder.version - initiator.version > 2 major versions — always reject.

Golden test vectors

Для catch wire-format regressions cross-implementations.

Location

kontinuum-core/tests/wire_vectors/:

wire_vectors/
├── node_hello/
│   ├── basic_tier1.cbor          # binary
│   ├── basic_tier1.json          # human-readable expected encoding
│   ├── multi_sig_tier2.cbor
│   └── multi_sig_tier2.json
├── dht_put/
│   ├── simple_record.cbor
│   ├── simple_record.json
│   ├── transaction_part1.cbor
│   └── transaction_part1.json
├── replica_push/
└── ...

Format

Каждый test vector — pair:

  • *.cbor — actual byte-perfect canonical CBOR encoding.
  • *.json — equivalent JSON для human readability + golden assertion.

Test harness

rust
#[test]
fn test_node_hello_vector_basic_tier1() {
    let vector_cbor = include_bytes!("wire_vectors/node_hello/basic_tier1.cbor");
    let vector_json: serde_json::Value = serde_json::from_str(
        include_str!("wire_vectors/node_hello/basic_tier1.json")
    ).unwrap();

    // Decode CBOR
    let decoded: NodeHello = ciborium::from_reader(vector_cbor.as_slice()).unwrap();

    // Re-encode canonically
    let mut re_encoded = Vec::new();
    ciborium::into_writer(&decoded, &mut re_encoded).unwrap();

    // Assert byte-perfect roundtrip
    assert_eq!(re_encoded, vector_cbor);

    // Assert JSON representation matches
    let as_json = serde_json::to_value(&decoded).unwrap();
    assert_eq!(as_json, vector_json);
}

Generating vectors

bash
cargo run --bin wire-vector-gen -- node_hello --tier 1 --output tests/wire_vectors/node_hello/basic_tier1

При schema changes — regenerate vectors через CI bot, review changes carefully (если byte-different, это intentional or regression?).


Mapping table — Rust → CBOR

Rust typeWire encodingNotes
NodeId = [u8; 32]CBOR byte string len=32Fixed length enforced
Signature = Vec<u8>CBOR byte string len=64Length validated at decode
IdentityId = [u8; 32]CBOR byte string len=32
DhtId = [u8; 32]CBOR byte string len=32
MultiaddrCBOR text stringlibp2p multiaddr format
Capability enumCBOR uintMap per §Frame catalog
Tenancy enumCBOR uint (0 = multi, 1 = single)
Operator enumCBOR uint (0 = org, 1 = byo:vps, 2 = byo:home)
Option<T>CBOR null или encoded T
Vec<T>CBOR arrayDefinite length
u64 (timestamps)CBOR uintAlways non-negative
i32 (score_delta)CBOR intNegative-capable

Implementation checklist

  • [ ] В kontinuum-core/Cargo.toml добавить ciborium = "0.2".
  • [ ] Implement canonical encoding wrapper (to_canonical_vec, from_canonical_slice).
  • [ ] Implement custom Serialize/Deserialize для frames с numeric discriminants (если стандартный serde не подходит).
  • [ ] Сгенерировать первый набор golden vectors:
    • NodeHello basic Tier 1 single
    • NodeHello multi-sig Tier 2 byo:vps multi (с family-mode tenants signed quotas)
    • DhtPut simple + DhtPut transaction (Revoke + KeyRotation pair)
    • ReplicaPush, ReplicaAck
    • MerkleRootExchange + MerkleDescend + MerkleLeaf
    • StorageChallenge + StorageProof + ChallengeResult
    • MailboxDeposit + MailboxCursorUpdate + MailboxFlush
    • CertRevocation, CertRenewal, TierTransition
    • NodeStatus
  • [ ] CI integration: на schema changes — regenerate vectors, require explicit approval.
  • [ ] Property test (proptest): cbor_roundtrip(arbitrary X) → X для всех types.
  • [ ] Fuzz target (cargo-fuzz): malformed CBOR не должен crash'ить decoder.
  • [ ] Update kontinuum-core/src/node/wire.rs — заменить bincode references на ciborium (текущий комментарий упоминает bincode — устарел after v0.4 decision).

Open implementation questions

  1. Field numbering — int vs string keys. Integer keys более compact (~30% smaller), но string keys debugger-friendly. Production preference — integer (current spec). Решить firmly до first vector generation.

  2. Tagged union — explicit discriminant vs serde tag. Rust enum с explicit discriminant число (= 0, = 10) более straightforward to CBOR mapping. serde(tag = "kind") сложнее обернуть в numeric. Likely custom Serialize impl нужен.

  3. Discriminant gaps. Я numbered categories через 10 (10..14, 20..23, 30..32, ...). Альтернатива: continuous numbering (0..30). Continuous compact, gaps — easier to extend. Принять continuous до freeze.

  4. Versioning gateway — что если v2 нода читает старую запись v1 в DHT (legacy persistence)? Convert on-fly? Reject? Решение: convert on-fly с deprecation warning, drop legacy support за 6 месяцев.

  5. Wire vector format — JSON vs CBOR-diag. CBOR Diagnostic Notation (RFC 8949 §8) более точно describes encoding. JSON less ambiguous about byte strings. Я выбрал JSON для convenience; CBOR-diag — superior choice но требует tooling.

  6. Test vectors location. В kontinuum-core/tests/ (compile-time) или в отдельной crate kontinuum-wire-vectors? Latter allows external use без core compile.