diff --git a/crates/mesh-llm-host-runtime/src/mesh/announcements.rs b/crates/mesh-llm-host-runtime/src/mesh/announcements.rs index cf44e81947..305a5ac3fe 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/announcements.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/announcements.rs @@ -467,6 +467,8 @@ impl Node { }) } + /// Builds the gossip announcement re-broadcast on behalf of an already + /// admitted peer, from that peer's locally tracked state. pub(crate) fn announcement_from_peer(peer: &PeerInfo) -> PeerAnnouncement { let latency = peer.display_latency(); PeerAnnouncement { @@ -518,6 +520,10 @@ impl Node { latency_age_ms: Some(latency.age_ms), latency_observer_id: latency.observer_id, inference_admission_state: peer.inference_admission_state, + // No live-mesh claimed-log-head state is tracked on `PeerInfo` yet — a + // rebroadcast of a peer we already admitted carries no opinion on + // its claimed log head. + claimed_log_head: None, } } @@ -533,6 +539,8 @@ impl Node { }) } + /// Builds this node's own gossip announcement from freshly collected + /// local data. pub(crate) fn build_local_announcement(&self, data: LocalAnnouncementData) -> PeerAnnouncement { PeerAnnouncement { addr: self.endpoint_addr_for_advertisement(), @@ -582,6 +590,9 @@ impl Node { latency_age_ms: None, latency_observer_id: None, inference_admission_state: data.inference_admission_state, + // No local claimed-log-head source is wired yet — this node never + // advertises its own until a companion process is plumbed in. + claimed_log_head: None, } } } diff --git a/crates/mesh-llm-host-runtime/src/mesh/mod.rs b/crates/mesh-llm-host-runtime/src/mesh/mod.rs index 27e8fa3e4f..9191223c12 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/mod.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/mod.rs @@ -162,8 +162,8 @@ pub use node::{ }; pub(crate) use node::{PeerDownReport, peer_down_endpoint_id}; pub(crate) use peer_state::{ - ControlListenerLifecycle, DEAD_PEER_TTL, MeshState, PEER_DOWN_REPORTER_COOLDOWN_SECS, - PEER_STALE_SECS, resolve_peer_leaving, + ClaimedLogHead, ControlListenerLifecycle, DEAD_PEER_TTL, MeshState, + PEER_DOWN_REPORTER_COOLDOWN_SECS, PEER_STALE_SECS, resolve_peer_leaving, }; #[expect( unused_imports, diff --git a/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs b/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs index a07b27caa0..7eaf2b8316 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs @@ -166,6 +166,26 @@ pub struct PeerAnnouncement { pub(crate) latency_age_ms: Option, pub(crate) latency_observer_id: Option, pub(crate) inference_admission_state: Option, + /// An optional, self-reported claim this peer MAY advertise about the + /// head of its own append-only history. Carried opaquely; never verified + /// by mesh-llm. + pub(crate) claimed_log_head: Option, +} + +/// A peer's latest self-reported claim about the head of its append-only log +/// — see `ClaimedLogHead` in `node.proto` for the wire shape and the +/// signing-scope note. Carried opaquely: mesh-llm never verifies +/// `claimed_signature` itself, hence the name — a consumer that does verify +/// it may define its own `VerifiedLogHead` type; none exists here. `pub(crate)` +/// to match `PeerAnnouncement::claimed_log_head`, which is also `pub(crate)`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ClaimedLogHead { + pub(crate) log_id: String, + pub(crate) size: u64, + pub(crate) root: Vec, + pub(crate) timestamp_unix_ms: u64, + pub(crate) claimed_signature: Vec, + pub(crate) signature_algorithm: String, } /// A single direct RTT measurement (e.g. from gossip exchange). diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs index 6500b10cc1..7a99ed0d3a 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs @@ -374,6 +374,9 @@ pub(super) async fn configure_requirement_node( Ok(()) } +/// Builds a `PeerAnnouncement` fixture carrying the given genesis policy hash +/// and (optionally) a release attestation, for mesh-requirements admission +/// tests. pub(super) fn requirement_peer_announcement( sender_seed: u8, policy: &crate::MeshGenesisPolicy, @@ -428,5 +431,6 @@ pub(super) fn requirement_peer_announcement( latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, } } diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs index 2bd6cc5c20..824f7289ed 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs @@ -540,6 +540,8 @@ pub(crate) fn assert_requirement_aware_mesh_without_attestation_accepts_valid_di }); } +/// Asserts that a peer whose release attestation is signed by a key outside +/// the mesh's trusted signer set is rejected, not admitted. pub(crate) fn assert_mesh_requirements_add_peer_rejects_untrusted_release_signer() { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); runtime.block_on(async { @@ -606,6 +608,7 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_untrusted_release_signer latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; node.add_peer( @@ -630,6 +633,8 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_untrusted_release_signer }); } +/// Asserts that a peer whose release attestation signature does not verify +/// against its claimed signer is rejected, not admitted. pub(crate) fn assert_mesh_requirements_add_peer_rejects_invalid_release_attestation_signature() { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); runtime.block_on(async { @@ -699,6 +704,7 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_invalid_release_attestat latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; node.add_peer( @@ -723,6 +729,8 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_invalid_release_attestat }); } +/// Asserts that a peer advertising a genesis policy hash for a different +/// mesh is rejected, not admitted. pub(crate) fn assert_mesh_requirements_add_peer_rejects_wrong_mesh_id() { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); runtime.block_on(async { @@ -789,6 +797,7 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_wrong_mesh_id() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; node.add_peer( diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs index 56c575fbb4..2fed941301 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs @@ -18,6 +18,9 @@ pub(crate) fn test_addr(seed: u8) -> EndpointAddr { } } +/// Builds a minimal `PeerAnnouncement` fixture with the given +/// `first_joined_mesh_ts`, for gossip tests that don't care about the rest +/// of the announcement's fields. pub(crate) fn test_announcement(ts: Option) -> PeerAnnouncement { PeerAnnouncement { addr: test_addr(0x11), @@ -64,6 +67,7 @@ pub(crate) fn test_announcement(ts: Option) -> PeerAnnouncement { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, } } diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs index 64f0e2bf6d..f888306472 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs @@ -357,6 +357,9 @@ fn relay_reconnect_controller_applies_cooldown_after_attempt_and_prunes_gone_pee ); } +/// Builds a minimal `PeerAnnouncement` fixture at the given address, for +/// peer-state tests that don't care about the rest of the announcement's +/// fields. fn peer_state_test_announcement(addr: EndpointAddr) -> super::PeerAnnouncement { super::PeerAnnouncement { addr, @@ -403,6 +406,7 @@ fn peer_state_test_announcement(addr: EndpointAddr) -> super::PeerAnnouncement { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, } } @@ -921,6 +925,8 @@ fn control_frame_rejects_oversize_or_bad_generation() { ); } +/// Proves a gossip-frame round trip preserves locally scanned model metadata +/// fields on the announcement. #[test] fn gossip_frame_roundtrip_preserves_scanned_model_metadata() { use crate::proto::node::{CompactModelMetadata, ExpertsSummary}; @@ -1029,6 +1035,7 @@ fn gossip_frame_roundtrip_preserves_scanned_model_metadata() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&local_ann); @@ -1306,6 +1313,8 @@ fn gossip_rejects_sender_id_mismatch_or_invalid_endpoint_len() { ); } +/// Proves a transitively gossiped update refreshes a peer's metadata fields +/// in place, without dropping unrelated state. #[test] fn transitive_peer_update_refreshes_metadata_fields() { use crate::proto::node::CompactModelMetadata; @@ -1391,6 +1400,7 @@ fn transitive_peer_update_refreshes_metadata_fields() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; apply_transitive_ann(&mut existing, &addr, &ann, make_test_endpoint_id(0xee)); @@ -1418,6 +1428,8 @@ fn transitive_peer_update_refreshes_metadata_fields() { assert!(existing.available_model_sizes.is_empty()); } +/// Proves merging a transitively gossiped peer update never discards a +/// richer, already-known direct address in favor of a sparser one. #[test] fn transitive_peer_merge_preserves_richer_direct_address() { use iroh::TransportAddr; @@ -1485,6 +1497,7 @@ fn transitive_peer_merge_preserves_richer_direct_address() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; apply_transitive_ann(&mut existing, &weak_addr, &ann, make_test_endpoint_id(0xee)); @@ -1553,6 +1566,7 @@ fn transitive_peer_merge_preserves_richer_direct_address() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; apply_transitive_ann( &mut existing, diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs index 5f0b79d76e..e01e4a69e9 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs @@ -323,6 +323,7 @@ async fn transitive_peer_update_refreshes_last_mentioned() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; node.update_transitive_peer(peer_id, &addr, &ann, make_test_endpoint_id(0xee)) diff --git a/crates/mesh-llm-host-runtime/src/protocol/convert.rs b/crates/mesh-llm-host-runtime/src/protocol/convert.rs index 6bcd8e9b35..8e72d48c8d 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/convert.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/convert.rs @@ -732,6 +732,8 @@ fn proto_cache_affinity_to_local( .then_some(advertisement) } +/// Encodes a local `PeerAnnouncement` to its wire representation, after +/// sanitizing it for outbound gossip. pub(crate) fn local_ann_to_proto_ann( ann: &PeerAnnouncement, ) -> crate::proto::node::PeerAnnouncement { @@ -879,7 +881,51 @@ pub(crate) fn local_ann_to_proto_ann( .cache_affinity .as_ref() .map(local_cache_affinity_to_proto), + claimed_log_head: ann + .claimed_log_head + .as_ref() + .map(local_claimed_log_head_to_proto), + } +} + +/// Converts a local `ClaimedLogHead` to its wire form. No validation here: +/// this node is the one asserting the claim, not receiving it. +fn local_claimed_log_head_to_proto( + head: &crate::mesh::ClaimedLogHead, +) -> crate::proto::node::ClaimedLogHead { + crate::proto::node::ClaimedLogHead { + log_id: head.log_id.clone(), + size: head.size, + root: head.root.clone(), + timestamp_unix_ms: head.timestamp_unix_ms, + claimed_signature: head.claimed_signature.clone(), + signature_algorithm: head.signature_algorithm.clone(), + } +} + +/// Decodes a remote `ClaimedLogHead`, rejecting (as absent — never a panic, +/// never a partial struct) any field that exceeds the memory-safety bounds +/// below. mesh-llm never verifies `claimed_signature`; this only bounds +/// untrusted remote byte lengths, the same ingest hygiene already applied to +/// `CacheAffinityAdvertisement.salt` and remote model names. +fn proto_claimed_log_head_to_local( + head: &crate::proto::node::ClaimedLogHead, +) -> Option { + if head.log_id.len() > MAX_CLAIMED_LOG_ID_BYTES + || head.root.len() > MAX_CLAIMED_LOG_ROOT_BYTES + || head.claimed_signature.len() > MAX_CLAIMED_LOG_SIGNATURE_BYTES + || head.signature_algorithm.len() > MAX_CLAIMED_LOG_SIGNATURE_ALGORITHM_BYTES + { + return None; } + Some(crate::mesh::ClaimedLogHead { + log_id: head.log_id.clone(), + size: head.size, + root: head.root.clone(), + timestamp_unix_ms: head.timestamp_unix_ms, + claimed_signature: head.claimed_signature.clone(), + signature_algorithm: head.signature_algorithm.clone(), + }) } pub(crate) fn build_gossip_frame( @@ -914,6 +960,42 @@ const MAX_REMOTE_MODEL_LIST_LEN: usize = 256; /// under this; anything larger is dropped rather than rendered. const MAX_REMOTE_MODEL_NAME_BYTES: usize = 512; +/// Upper bound on `ClaimedLogHead.log_id`, in bytes. This is a memory-safety +/// limit on untrusted remote bytes, not a format assertion: a log id is not +/// a model name, so it gets its own constant rather than borrowing +/// `MAX_REMOTE_MODEL_NAME_BYTES`, sized to match that existing remote-string +/// cap. +const MAX_CLAIMED_LOG_ID_BYTES: usize = 512; + +/// Upper bound on `ClaimedLogHead.root`, in bytes. This is a memory-safety +/// limit on untrusted remote bytes, not a format assertion: mesh-llm treats +/// the root as opaque and never verifies it, so the bound is sized generously +/// enough to admit hash schemes wider than this node's own SHA-256 (e.g. +/// SHA-512) rather than asserting our own scheme's exact length. +const MAX_CLAIMED_LOG_ROOT_BYTES: usize = 64; + +/// Upper bound on `ClaimedLogHead.claimed_signature`, in bytes. Sized for +/// this node's own Ed25519 (64 bytes) and classical schemes of similar +/// size, with headroom for encoding overhead — **not** for post-quantum +/// schemes: ML-DSA-65 signatures are 3,309 bytes (NIST FIPS 204 gives the +/// ML-DSA range as 2,420-4,627 bytes), all of which this bound rejects. +/// That is deliberate, not an oversight: `claimed_log_head` rides on +/// `PeerAnnouncement`, which is gossiped to every peer, and this node never +/// verifies `claimed_signature` (see `proto_claimed_log_head_to_local` +/// above) — so a bound wide enough to admit a real PQ signature would put +/// multi-kilobyte unverified blobs on a hot broadcast path. Accepting PQ +/// claims would need a deliberate bound raise with its own rationale, not +/// a default this constant already provides. +const MAX_CLAIMED_LOG_SIGNATURE_BYTES: usize = 128; + +/// Upper bound on `ClaimedLogHead.signature_algorithm`, in bytes. Same +/// memory-safety rationale as the other `ClaimedLogHead` bounds: this is an +/// untrusted remote string, not a known-set validator (mesh-llm never +/// verifies the claim, so it has no fixed list of algorithm names to check +/// against). 32 bytes comfortably fits real scheme identifiers (e.g. +/// `"ed25519"`, `"ml-dsa-65"`) while still capping the field. +const MAX_CLAIMED_LOG_SIGNATURE_ALGORITHM_BYTES: usize = 32; + /// Sanitize a remotely-supplied list of model names: drop entries that exceed /// the per-name byte cap and keep at most `MAX_REMOTE_MODEL_LIST_LEN` of them. fn cap_remote_model_names(names: &[String]) -> Vec { @@ -925,6 +1007,10 @@ fn cap_remote_model_names(names: &[String]) -> Vec { .collect() } +/// Decodes a wire `PeerAnnouncement` into its local representation, applying +/// every remote-bytes sanitization boundary (model names, cache affinity, +/// claimed log head) along the way. Returns `None` if the endpoint id itself +/// is malformed. pub(crate) fn proto_ann_to_local( pa: &crate::proto::node::PeerAnnouncement, ) -> Option<(EndpointAddr, PeerAnnouncement)> { @@ -1122,6 +1208,10 @@ pub(crate) fn proto_ann_to_local( .cache_affinity .as_ref() .and_then(proto_cache_affinity_to_local), + claimed_log_head: pa + .claimed_log_head + .as_ref() + .and_then(proto_claimed_log_head_to_local), }; crate::mesh::backfill_legacy_descriptors(&mut ann); ann.advertised_model_throughput = sanitize_model_throughput_hints_for_ann(&ann); diff --git a/crates/mesh-llm-host-runtime/src/protocol/mod.rs b/crates/mesh-llm-host-runtime/src/protocol/mod.rs index ec13eeae33..4a9227d06c 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/mod.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/mod.rs @@ -1,5 +1,7 @@ // Protocol infrastructure — extracted from mesh.rs +#[cfg(test)] +use crate::mesh::ClaimedLogHead; #[cfg(test)] use crate::mesh::NodeRole; use crate::mesh::PeerAnnouncement; diff --git a/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs b/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs index e7921da3f2..19b2750131 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs @@ -1,5 +1,7 @@ use super::*; +/// Proves owner-attestation fields survive a local-to-proto-to-local +/// announcement round trip unchanged. #[test] fn owner_fields_roundtrip_through_proto_announcement() { let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xAB; 32]).public()); @@ -64,6 +66,7 @@ fn owner_fields_roundtrip_through_proto_announcement() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann); let skippy = proto_pa @@ -134,6 +137,8 @@ pub(crate) fn assert_mixed_version_peer_ignores_missing_release_attestation() { assert!(!peer.release_attestation_summary.verified); } +/// Proves advertised model-throughput hints survive a local-to-proto-to-local +/// announcement round trip unchanged. #[test] fn advertised_model_throughput_roundtrips_through_proto_announcement() { let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xAC; 32]).public()); @@ -232,6 +237,7 @@ fn advertised_model_throughput_roundtrips_through_proto_announcement() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let mut proto_pa = local_ann_to_proto_ann(&ann); @@ -349,6 +355,8 @@ fn stale_and_far_future_cache_affinity_are_dropped_at_ingest() { assert!(future.cache_affinity.is_none()); } +/// Proves the inference-admission-state field survives a +/// local-to-proto-to-local announcement round trip unchanged. #[test] fn inference_admission_state_roundtrips_through_proto_announcement() { let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xAD; 32]).public()); @@ -401,6 +409,7 @@ fn inference_admission_state_roundtrips_through_proto_announcement() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: Some(expected_state), + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann); @@ -605,6 +614,8 @@ fn unknown_stage_feature_does_not_enable_local_gguf_content_id_support() { assert!(!ann.local_gguf_content_id_supported); } +/// Proves GPU bandwidth and TFLOPS fields survive a local-to-proto-to-local +/// announcement round trip unchanged. #[test] fn test_proto_round_trip_with_bandwidth_and_tflops() { let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xBC; 32]).public()); @@ -656,6 +667,7 @@ fn test_proto_round_trip_with_bandwidth_and_tflops() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann); @@ -1001,3 +1013,388 @@ fn memory_block_exceeding_the_placement_budget_is_dropped_at_ingest() { "usable equal to the budget is consistent" ); } + +/// Builds a `PeerAnnouncement` fixture for `ClaimedLogHead` conversion +/// tests: every field is empty/default except `claimed_log_head`. +/// Deliberately not built from `mesh::gossip::tests::test_announcement`: +/// that helper lives inside `gossip`'s private `#[cfg(test)] mod tests`, so +/// `mesh::gossip::tests` is unreachable from here (a sibling module tree) +/// regardless of the helper's own `pub(crate)` — verified with `cargo +/// check`, not just asserted. +fn claimed_log_head_test_announcement( + peer_id: EndpointId, + claimed_log_head: Option, +) -> super::super::PeerAnnouncement { + super::super::PeerAnnouncement { + addr: iroh::EndpointAddr { + id: peer_id, + addrs: Default::default(), + }, + role: super::super::NodeRole::Worker, + first_joined_mesh_ts: None, + models: vec![], + vram_bytes: 0, + model_source: None, + serving_models: vec![], + hosted_models: None, + available_models: vec![], + requested_models: vec![], + explicit_model_interests: vec![], + version: None, + model_demand: HashMap::new(), + mesh_id: None, + mesh_policy_hash: None, + gpu_name: None, + hostname: None, + is_soc: None, + gpu_vram: None, + gpu_reserved_bytes: None, + memory: None, + gpu_mem_bandwidth_gbps: None, + gpu_compute_tflops_fp32: None, + gpu_compute_tflops_fp16: None, + available_model_metadata: vec![], + experts_summary: None, + available_model_sizes: HashMap::new(), + served_model_descriptors: vec![], + served_model_runtime: vec![], + owner_attestation: None, + genesis_policy: None, + release_attestation: None, + direct_admission_proof: None, + artifact_transfer_supported: false, + stage_protocol_generation_supported: false, + stage_status_list_supported: false, + local_gguf_content_id_supported: false, + advertised_model_throughput: vec![], + cache_affinity: None, + latency_ms: None, + latency_source: None, + latency_age_ms: None, + latency_observer_id: None, + inference_admission_state: None, + claimed_log_head, + } +} + +/// Builds a wire `PeerAnnouncement` carrying the given proto `ClaimedLogHead`, +/// for tests that need to construct an out-of-bounds field directly on the +/// wire type (the local type has no length limits of its own — the bound is +/// enforced only when decoding untrusted remote bytes). +fn proto_announcement_with_claimed_log_head( + peer_id: EndpointId, + head: crate::proto::node::ClaimedLogHead, +) -> crate::proto::node::PeerAnnouncement { + let base = local_ann_to_proto_ann(&claimed_log_head_test_announcement(peer_id, None)); + crate::proto::node::PeerAnnouncement { + claimed_log_head: Some(head), + ..base + } +} + +/// Proves a `ClaimedLogHead` survives a full wire round trip: local → proto +/// → encoded bytes → decoded proto → local. Exercises tag 51 serialization +/// end-to-end, not just the in-memory conversion functions. +#[test] +fn claimed_log_head_roundtrips_through_proto_announcement() { + use prost::Message; + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xCF; 32]).public()); + let expected = super::super::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 42, + root: vec![0xAA; 32], + timestamp_unix_ms: 1_725_000_000_000, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let ann = claimed_log_head_test_announcement(peer_id, Some(expected.clone())); + + let proto_pa = local_ann_to_proto_ann(&ann); + let encoded = proto_pa.encode_to_vec(); + let decoded_proto = crate::proto::node::PeerAnnouncement::decode(encoded.as_slice()) + .expect("claimed log head announcement must decode"); + let proto_head = decoded_proto + .claimed_log_head + .as_ref() + .expect("claimed_log_head must be present on the wire announcement"); + assert_eq!(proto_head.log_id, expected.log_id); + assert_eq!(proto_head.size, expected.size); + assert_eq!(proto_head.root, expected.root); + assert_eq!(proto_head.timestamp_unix_ms, expected.timestamp_unix_ms); + assert_eq!(proto_head.claimed_signature, expected.claimed_signature); + assert_eq!(proto_head.signature_algorithm, expected.signature_algorithm); + + let (_, roundtripped) = + proto_ann_to_local(&decoded_proto).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, Some(expected)); +} + +/// Proves backward compatibility at the wire level: encoding an announcement +/// that has no `claimed_log_head` field and decoding it produces `None` for +/// the field. This is the proto3 optional-field contract. +#[test] +fn proto_announcement_without_claimed_log_head_decodes_as_absent() { + use prost::Message; + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD0; 32]).public()); + // Build a local announcement with no claimed_log_head. + let ann = super::super::PeerAnnouncement { + addr: iroh::EndpointAddr { + id: peer_id, + addrs: Default::default(), + }, + role: super::super::NodeRole::Worker, + first_joined_mesh_ts: None, + models: vec![], + vram_bytes: 0, + model_source: None, + serving_models: vec![], + hosted_models: None, + available_models: vec![], + requested_models: vec![], + explicit_model_interests: vec![], + version: None, + model_demand: HashMap::new(), + mesh_id: None, + mesh_policy_hash: None, + gpu_name: Some("NVIDIA A100".to_string()), + hostname: None, + is_soc: None, + gpu_vram: None, + gpu_reserved_bytes: None, + memory: None, + gpu_mem_bandwidth_gbps: None, + gpu_compute_tflops_fp32: None, + gpu_compute_tflops_fp16: None, + available_model_metadata: vec![], + experts_summary: None, + available_model_sizes: HashMap::new(), + served_model_descriptors: vec![], + served_model_runtime: vec![], + owner_attestation: None, + genesis_policy: None, + release_attestation: None, + direct_admission_proof: None, + artifact_transfer_supported: false, + stage_protocol_generation_supported: false, + stage_status_list_supported: false, + local_gguf_content_id_supported: false, + advertised_model_throughput: vec![], + cache_affinity: None, + latency_ms: None, + latency_source: None, + latency_age_ms: None, + latency_observer_id: None, + inference_admission_state: None, + claimed_log_head: None, + }; + // Encode to wire bytes, then decode back — this is what an old peer's + // message looks like on the wire when it has never set tag 51. + let proto_pa = local_ann_to_proto_ann(&ann); + let mut buf = Vec::new(); + proto_pa.encode(&mut buf).expect("encode must succeed"); + let decoded_proto = + crate::proto::node::PeerAnnouncement::decode(buf.as_slice()).expect("decode must succeed"); + let (_, roundtripped) = + proto_ann_to_local(&decoded_proto).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); + assert_eq!(roundtripped.gpu_name.as_deref(), Some("NVIDIA A100")); +} + +/// `ClaimedLogHead.log_id` is bounded by `MAX_CLAIMED_LOG_ID_BYTES` (512): +/// exactly at the bound must still decode as present. +#[test] +fn claimed_log_head_log_id_at_limit_decodes_as_present() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD2; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "x".repeat(512), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert!(roundtripped.claimed_log_head.is_some()); +} + +/// One byte over `MAX_CLAIMED_LOG_ID_BYTES` must decode as absent — never +/// a panic, never a partial struct. +#[test] +fn claimed_log_head_log_id_over_limit_decodes_as_absent() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD3; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "x".repeat(513), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); +} + +/// `ClaimedLogHead.root` is bounded at 64 bytes: exactly at the bound must +/// still decode as present. +#[test] +fn claimed_log_head_root_at_limit_decodes_as_present() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD4; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 64], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert!(roundtripped.claimed_log_head.is_some()); +} + +/// One byte over the 64-byte `root` bound must decode as absent — never a +/// panic, never a partial struct. +#[test] +fn claimed_log_head_root_over_limit_decodes_as_absent() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD5; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 65], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); +} + +/// `ClaimedLogHead.claimed_signature` is bounded at 128 bytes: exactly at +/// the bound must still decode as present. +#[test] +fn claimed_log_head_claimed_signature_at_limit_decodes_as_present() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD6; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 128], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert!(roundtripped.claimed_log_head.is_some()); +} + +/// One byte over the 128-byte `claimed_signature` bound must decode as +/// absent — never a panic, never a partial struct. +#[test] +fn claimed_log_head_claimed_signature_over_limit_decodes_as_absent() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD7; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 129], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); +} + +/// `MAX_CLAIMED_LOG_SIGNATURE_BYTES` (128) does **not** admit a real +/// post-quantum signature: an ML-DSA-65 signature (3,309 bytes, NIST FIPS +/// 204) must decode as absent, the same as any other oversized claim. This +/// pins down the corrected rationale on the constant's doc comment — the +/// bound is sized for Ed25519/classical schemes only, not "wide enough for +/// post-quantum signatures" as an earlier (incorrect) comment claimed. +#[test] +fn claimed_log_head_claimed_signature_rejects_ml_dsa_65_size() { + const ML_DSA_65_SIGNATURE_BYTES: usize = 3_309; + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xDB; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; ML_DSA_65_SIGNATURE_BYTES], + signature_algorithm: "ml-dsa-65".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!( + roundtripped.claimed_log_head, None, + "an ML-DSA-65-sized signature must decode as absent: the 128-byte \ + bound does not admit post-quantum schemes" + ); +} + +/// `ClaimedLogHead.signature_algorithm` is bounded at 32 bytes: exactly at +/// the bound must still decode as present. +#[test] +fn claimed_log_head_signature_algorithm_at_limit_decodes_as_present() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD8; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "x".repeat(32), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert!(roundtripped.claimed_log_head.is_some()); +} + +/// One byte over the 32-byte `signature_algorithm` bound must decode as +/// absent — never a panic, never a partial struct. +#[test] +fn claimed_log_head_signature_algorithm_over_limit_decodes_as_absent() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD9; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "x".repeat(33), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); +} + +/// proto3 defaults `signature_algorithm` to `""`; an empty value must +/// round-trip as empty and never be silently defaulted (e.g. to +/// `"ed25519"`) anywhere in the conversion path. An empty `signature_algorithm` +/// means the peer named no scheme — this is `ClaimedLogHead`'s field-level +/// analogue of the house rule already enforced on +/// `SignedMeshGenesisPolicy`/`SignedBootstrapToken`/`DirectNodeAdmissionProof` +/// (`validate_unsigned_shape` / `validate_shape` in +/// `mesh-llm-host-runtime/src/mesh/requirements.rs`), which reject an empty +/// `signature_algorithm` rather than default it. +#[test] +fn claimed_log_head_empty_signature_algorithm_roundtrips_as_empty() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xDA; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: String::new(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + let roundtripped_head = roundtripped + .claimed_log_head + .expect("empty signature_algorithm must not cause the whole head to be dropped"); + assert_eq!(roundtripped_head.signature_algorithm, ""); +} diff --git a/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs b/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs index 91b0ddadc6..8dad165a61 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs @@ -1,5 +1,7 @@ use super::*; +/// Proves `first_joined_mesh_ts` survives a local-to-proto-to-local +/// announcement round trip unchanged, both present and absent. #[test] fn test_peer_announcement_first_joined_mesh_ts_roundtrip() { use iroh::SecretKey; @@ -55,6 +57,7 @@ fn test_peer_announcement_first_joined_mesh_ts_roundtrip() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann_with_timestamp); @@ -114,6 +117,7 @@ fn test_peer_announcement_first_joined_mesh_ts_roundtrip() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann_without_timestamp); diff --git a/crates/mesh-llm-protocol/proto/node.proto b/crates/mesh-llm-protocol/proto/node.proto index d15c77e653..91c34a74ab 100644 --- a/crates/mesh-llm-protocol/proto/node.proto +++ b/crates/mesh-llm-protocol/proto/node.proto @@ -61,6 +61,80 @@ message PeerAnnouncement { optional InferenceAdmissionState inference_admission_state = 49; // Positive, short-lived cache evidence. Digests are salted and contain no tokens. optional CacheAffinityAdvertisement cache_affinity = 50; + // An optional, self-reported claim this node MAY advertise about the head + // of its own append-only history. Advisory only. Absence has four + // possible causes, and a receiver cannot tell which one applies from this + // field alone: (1) the peer does not advertise one; (2) the peer has not + // produced one yet; (3) the head originated more than one hop away — this + // implementation does not relay `claimed_log_head` transitively (see + // `apply_transitive_ann`), so a receiver only ever sees heads from + // directly-connected peers; (4) a field exceeded its bound and the whole + // head was dropped on decode (see `proto_claimed_log_head_to_local`). + // Never verified by mesh-llm itself — carried opaquely so a receiver MAY + // verify independently. + optional ClaimedLogHead claimed_log_head = 51; +} + +// A minimal, self-contained claim about the current head of a peer's +// append-only log. `claimed_signature` is claimed by the announcing peer to +// be a signature, using the scheme named in `signature_algorithm`, by the +// peer's own node key (the same key backing its `endpoint_id`) over the +// following byte string ("sig_input"), so a third party can implement a +// verifier without consulting any external document. `sig_input` is +// constructed the same way as `SignedMeshGenesisPolicy`'s canonical bytes, +// using this crate's `write_string`/`write_bytes` conventions (see +// `mesh-llm-host-runtime/src/mesh/requirements.rs`): +// +// sig_input = b"mesh-llm-claimed-log-head-v1:" +// || u64le(len(log_id)) || log_id +// || u64le(size) +// || u64le(len(root)) || root +// || u64le(timestamp_unix_ms) +// || u64le(len(signature_algorithm)) || signature_algorithm +// +// All integers are little-endian, fixed 8 bytes (u64le) — matching this +// crate's existing canonical-bytes convention, the same one used for +// `SignedMeshGenesisPolicy`, `SignedBootstrapToken`, and +// `DirectNodeAdmissionProof`. `len(log_id)` and `len(signature_algorithm)` +// are each the length in bytes of the UTF-8 encoding of the string, not a +// character count. Every variable-length field is length-prefixed, so +// concatenation cannot be ambiguous between adjacent fields. The leading +// domain-separation tag stops a signature produced for another protocol +// from being replayed here. +// `signature_algorithm` is itself bound into `sig_input` — otherwise an +// attacker could strip or swap it on the wire, and algorithm agility would +// become an attack surface instead of a feature. The value bound in is +// `signature_algorithm.trim()`, the same trimmed value a verifier must +// compare against, so two peers that differ only in surrounding whitespace +// sign and verify identical bytes. +// `signature_algorithm` names the scheme with a lowercase string, e.g. +// `"ed25519"` (matching `ED25519_SIGNATURE_ALGORITHM` in +// `mesh-llm-host-runtime/src/mesh/requirements.rs`). Comparison is +// `.trim()` then exact `==` — whitespace-tolerant, case-sensitive — the +// same convention already used to check this field on +// `SignedMeshGenesisPolicy`/`SignedBootstrapToken`/ +// `DirectNodeAdmissionProof`. An absent or empty `signature_algorithm` +// means the peer named no scheme: a verifier cannot check the claim and +// MUST NOT assume one (in particular, MUST NOT default to Ed25519) — empty +// is never defaulted, the same house rule those three messages already +// enforce. +// mesh-llm carries this opaquely and never verifies it itself — the name +// reflects that nothing here has checked the claim. A consumer that does +// verify it may define its own `VerifiedLogHead` type; none exists in this +// crate. +// `log_id`, `root`, `claimed_signature`, and `signature_algorithm` are +// bounded at the conversion boundary (see +// `mesh-llm-host-runtime/src/protocol/convert.rs`) as a memory-safety limit +// on untrusted remote bytes, not a format assertion — the bounds are wide +// enough for hash/signature schemes other than the SHA-256/Ed25519 this +// node itself uses. +message ClaimedLogHead { + string log_id = 1; + uint64 size = 2; + bytes root = 3; + uint64 timestamp_unix_ms = 4; + bytes claimed_signature = 5; + string signature_algorithm = 6; } enum InferenceAdmissionState { diff --git a/crates/mesh-llm-protocol/src/proto/node.rs b/crates/mesh-llm-protocol/src/proto/node.rs index 36b009cfeb..d9de646cd5 100644 --- a/crates/mesh-llm-protocol/src/proto/node.rs +++ b/crates/mesh-llm-protocol/src/proto/node.rs @@ -132,6 +132,87 @@ pub struct PeerAnnouncement { /// Positive, short-lived cache evidence. Digests are salted and contain no tokens. #[prost(message, optional, tag = "50")] pub cache_affinity: ::core::option::Option, + /// An optional, self-reported claim this node MAY advertise about the head + /// of its own append-only history. Advisory only. Absence has four + /// possible causes, and a receiver cannot tell which one applies from this + /// field alone: (1) the peer does not advertise one; (2) the peer has not + /// produced one yet; (3) the head originated more than one hop away — this + /// implementation does not relay `claimed_log_head` transitively (see + /// `apply_transitive_ann`), so a receiver only ever sees heads from + /// directly-connected peers; (4) a field exceeded its bound and the whole + /// head was dropped on decode (see `proto_claimed_log_head_to_local`). + /// Never verified by mesh-llm itself — carried opaquely so a receiver MAY + /// verify independently. + #[prost(message, optional, tag = "51")] + pub claimed_log_head: ::core::option::Option, +} +/// A minimal, self-contained claim about the current head of a peer's +/// append-only log. `claimed_signature` is claimed by the announcing peer to +/// be a signature, using the scheme named in `signature_algorithm`, by the +/// peer's own node key (the same key backing its `endpoint_id`) over the +/// following byte string ("sig_input"), so a third party can implement a +/// verifier without consulting any external document. `sig_input` is +/// constructed the same way as `SignedMeshGenesisPolicy`'s canonical bytes, +/// using this crate's `write_string`/`write_bytes` conventions (see +/// `mesh-llm-host-runtime/src/mesh/requirements.rs`): +/// +/// sig_input = b"mesh-llm-claimed-log-head-v1:" +/// || u64le(len(log_id)) || log_id +/// || u64le(size) +/// || u64le(len(root)) || root +/// || u64le(timestamp_unix_ms) +/// || u64le(len(signature_algorithm)) || signature_algorithm +/// +/// All integers are little-endian, fixed 8 bytes (u64le) — matching this +/// crate's existing canonical-bytes convention, the same one used for +/// `SignedMeshGenesisPolicy`, `SignedBootstrapToken`, and +/// `DirectNodeAdmissionProof`. `len(log_id)` and `len(signature_algorithm)` +/// are each the length in bytes of the UTF-8 encoding of the string, not a +/// character count. Every variable-length field is length-prefixed, so +/// concatenation cannot be ambiguous between adjacent fields. The leading +/// domain-separation tag stops a signature produced for another protocol +/// from being replayed here. +/// `signature_algorithm` is itself bound into `sig_input` — otherwise an +/// attacker could strip or swap it on the wire, and algorithm agility would +/// become an attack surface instead of a feature. The value bound in is +/// `signature_algorithm.trim()`, the same trimmed value a verifier must +/// compare against, so two peers that differ only in surrounding whitespace +/// sign and verify identical bytes. +/// `signature_algorithm` names the scheme with a lowercase string, e.g. +/// `"ed25519"` (matching `ED25519_SIGNATURE_ALGORITHM` in +/// `mesh-llm-host-runtime/src/mesh/requirements.rs`). Comparison is +/// `.trim()` then exact `==` — whitespace-tolerant, case-sensitive — the +/// same convention already used to check this field on +/// `SignedMeshGenesisPolicy`/`SignedBootstrapToken`/ +/// `DirectNodeAdmissionProof`. An absent or empty `signature_algorithm` +/// means the peer named no scheme: a verifier cannot check the claim and +/// MUST NOT assume one (in particular, MUST NOT default to Ed25519) — empty +/// is never defaulted, the same house rule those three messages already +/// enforce. +/// mesh-llm carries this opaquely and never verifies it itself — the name +/// reflects that nothing here has checked the claim. A consumer that does +/// verify it may define its own `VerifiedLogHead` type; none exists in this +/// crate. +/// `log_id`, `root`, `claimed_signature`, and `signature_algorithm` are +/// bounded at the conversion boundary (see +/// `mesh-llm-host-runtime/src/protocol/convert.rs`) as a memory-safety limit +/// on untrusted remote bytes, not a format assertion — the bounds are wide +/// enough for hash/signature schemes other than the SHA-256/Ed25519 this +/// node itself uses. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ClaimedLogHead { + #[prost(string, tag = "1")] + pub log_id: ::prost::alloc::string::String, + #[prost(uint64, tag = "2")] + pub size: u64, + #[prost(bytes = "vec", tag = "3")] + pub root: ::prost::alloc::vec::Vec, + #[prost(uint64, tag = "4")] + pub timestamp_unix_ms: u64, + #[prost(bytes = "vec", tag = "5")] + pub claimed_signature: ::prost::alloc::vec::Vec, + #[prost(string, tag = "6")] + pub signature_algorithm: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct AdvertisedModelThroughput {