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.mddesign и actual implementation.
Audience: node developers пишущие wire layer. Также — implementers любых alternative clients (future Kotlin/Swift mobile native).
Связанные документы:
protocols.md— semantic descriptions всех frame types (§11)db-schemas.md— на disk persistence (signed_record BLOBхранит CBOR-encoded version)architecture.md— overview / glossary
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)
| Crate | Pros | Cons |
|---|---|---|
ciborium | Pure Rust, modern, serde integration | Не enforces canonical encoding by default — нужны custom checks |
serde_cbor | Mature, широко used | Deprecated, unmaintained |
minicbor | No-std friendly, canonical mode | Custom derive (не serde-compatible) |
Recommendation: ciborium 0.2+ с custom to_canonical_vec() wrapper, который проверяет deterministic encoding constraints. Альтернатива: minicbor если важна no-std support для embedded.
Frame structure
Outer envelope
;; 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):
#[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.
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 = NodeStatusNumbers reserved by category — 10 slots per category для future expansion без conflicts. Категории см. protocols.md §11 (a-h).
Rust mapping:
#[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)
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
signaturesarray содержит 1 entry (single-key Tier 0). v1.0 — 3-of-5, array length = 3. - Validation: для каждого signature в array,
tier0_pubkeymust быть вTIER0_PUBKEYS(hardcoded). Threshold check:signatures.len() >= TIER0_MULTISIG_CERT_THRESHOLD.
NodeHelloAck (1)
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)
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)
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)
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
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
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)
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
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
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
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
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 — один из:
;; 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
- Adding fields — OK if optional (нет в required map). Receivers ignoring unknown fields.
- Removing fields — Major version bump (
protocol_version). - Changing field type — Major version bump.
- Changing field number — Forbidden ever (would break old encoders).
- Adding new variant в tagged union — OK; older receivers reject unknown discriminator.
- Removing variant — Major version bump.
- Adding new top-level frame type — OK; new discriminator number.
- Renaming variants — OK on Rust side (CBOR uses numeric discriminator, не string).
Version negotiation
NodeHello.protocol_version — uint, incremented при breaking changes.
| Version | Range | Compatibility |
|---|---|---|
| 0 | v0.1 — v0.6 (pre-implementation drafts) | N/A (no production traffic) |
| 1 | v1.0 launch | Production baseline |
| 2 | v1.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 withprotocol_version_mismatch.responder.version - initiator.version > 2major 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
#[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
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 type | Wire encoding | Notes |
|---|---|---|
NodeId = [u8; 32] | CBOR byte string len=32 | Fixed length enforced |
Signature = Vec<u8> | CBOR byte string len=64 | Length validated at decode |
IdentityId = [u8; 32] | CBOR byte string len=32 | |
DhtId = [u8; 32] | CBOR byte string len=32 | |
Multiaddr | CBOR text string | libp2p multiaddr format |
Capability enum | CBOR uint | Map per §Frame catalog |
Tenancy enum | CBOR uint (0 = multi, 1 = single) | |
Operator enum | CBOR uint (0 = org, 1 = byo:vps, 2 = byo:home) | |
Option<T> | CBOR null или encoded T | |
Vec<T> | CBOR array | Definite length |
u64 (timestamps) | CBOR uint | Always non-negative |
i32 (score_delta) | CBOR int | Negative-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— заменитьbincodereferences наciborium(текущий комментарий упоминает bincode — устарел after v0.4 decision).
Open implementation questions
Field numbering — int vs string keys. Integer keys более compact (~30% smaller), но string keys debugger-friendly. Production preference — integer (current spec). Решить firmly до first vector generation.
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 нужен.Discriminant gaps. Я numbered categories через 10 (10..14, 20..23, 30..32, ...). Альтернатива: continuous numbering (0..30). Continuous compact, gaps — easier to extend. Принять continuous до freeze.
Versioning gateway — что если v2 нода читает старую запись v1 в DHT (legacy persistence)? Convert on-fly? Reject? Решение: convert on-fly с deprecation warning, drop legacy support за 6 месяцев.
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.
Test vectors location. В
kontinuum-core/tests/(compile-time) или в отдельной cratekontinuum-wire-vectors? Latter allows external use без core compile.