Skip to content

Commit f6f1d6b

Browse files
mivertowskiclaude
andcommitted
feat(core): add PROV-O provenance metadata (v1.1)
Per spec section 3.4, add optional ProvenanceHeader to MessageEnvelope with 8 core PROV-O relations for NSAI reasoning chain attribution (VynGraph integration). ProvenanceHeader: - 4 inline relation slots + overflow_ref for overflow - HLC timestamp for PROV event time - Optional plan_id (Plan is PROV-O Entity subclass) - 136 bytes when present, 1 byte (None discriminant) when absent - Zero cost for messages that don't opt in PROV-O relations supported: - wasAttributedTo (Entity → Agent) - wasGeneratedBy (Entity → Activity) - wasDerivedFrom (Entity → Entity) - used (Activity → Entity) - wasInformedBy (Activity → Activity) - wasAssociatedWith (Activity → Agent) - actedOnBehalfOf (Agent → Agent) - Plan entity subtype Builder API: ProvenanceBuilder::new(node_type, node_id).with_relation()... Validation: node_id != 0, no self-loops, type-correct relations, chain depth bounded (256), cycle detection. 20 unit tests for builder, validation, chain walking, enum roundtrips. 676 existing tests still pass (no regressions). HlcTimestamp gains rkyv derives for embedding in provenance header. MessageEnvelope constructors default to provenance: None. Qualified relations (qualifiedAttribution etc.) deferred to v1.5. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fd95dd8 commit f6f1d6b

10 files changed

Lines changed: 954 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ringkernel-core/src/dispatcher.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ mod tests {
607607
let envelope = MessageEnvelope {
608608
header,
609609
payload: vec![],
610+
provenance: None,
610611
};
611612

612613
let result = dispatcher.dispatch(envelope).await;

crates/ringkernel-core/src/dlq.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ mod tests {
285285
MessageEnvelope {
286286
header: MessageHeader::new(1, 0, 1, 64, HlcTimestamp::now(1)),
287287
payload: vec![42u8; 64],
288+
provenance: None,
288289
}
289290
}
290291

crates/ringkernel-core/src/hlc.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,22 @@ pub const MAX_CLOCK_SKEW_MS: u64 = 60_000; // 1 minute
4040
///
4141
/// This struct is 24 bytes and cache-line friendly.
4242
#[derive(
43-
Debug, Clone, Copy, PartialEq, Eq, Hash, AsBytes, FromBytes, FromZeroes, Pod, Zeroable,
43+
Debug,
44+
Clone,
45+
Copy,
46+
PartialEq,
47+
Eq,
48+
Hash,
49+
AsBytes,
50+
FromBytes,
51+
FromZeroes,
52+
Pod,
53+
Zeroable,
54+
rkyv::Archive,
55+
rkyv::Serialize,
56+
rkyv::Deserialize,
4457
)]
58+
#[archive(compare(PartialEq))]
4559
#[repr(C, align(8))]
4660
pub struct HlcTimestamp {
4761
/// Physical time component (microseconds since UNIX epoch).

crates/ringkernel-core/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ pub mod message;
7070
pub mod multi_gpu;
7171
pub mod observability;
7272
pub mod persistent_message;
73+
pub mod provenance;
7374
pub mod pubsub;
7475
pub mod queue;
7576
pub mod reduction;
@@ -176,6 +177,10 @@ pub mod prelude {
176177
message_flags, DispatchTable, HandlerRegistration, PersistentMessage,
177178
MAX_INLINE_PAYLOAD_SIZE,
178179
};
180+
pub use crate::provenance::{
181+
validate_chain, ProvNodeType, ProvRelation, ProvRelationKind, ProvenanceBuilder,
182+
ProvenanceError, ProvenanceHeader, INLINE_RELATION_SLOTS, MAX_CHAIN_DEPTH,
183+
};
179184
pub use crate::pubsub::{PubSubBroker, PubSubBuilder, Publication, QoS, Subscription, Topic};
180185
pub use crate::queue::*;
181186
pub use crate::reduction::{
@@ -281,6 +286,10 @@ pub use error::{Result, RingKernelError};
281286
pub use hlc::HlcTimestamp;
282287
pub use memory::{DeviceMemory, GpuBuffer, MemoryPool, PinnedMemory};
283288
pub use message::{priority, MessageHeader, MessageId, Priority, RingMessage};
289+
pub use provenance::{
290+
ProvNodeType, ProvRelation, ProvRelationKind, ProvenanceBuilder, ProvenanceError,
291+
ProvenanceHeader,
292+
};
284293
pub use queue::{MessageQueue, QueueStats};
285294
pub use runtime::{
286295
Backend, KernelHandle, KernelId, KernelState, KernelStatus, LaunchOptions, RingKernelRuntime,

crates/ringkernel-core/src/message.rs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use rkyv::{Archive, Deserialize, Serialize};
88
use zerocopy::{AsBytes, FromBytes, FromZeroes};
99

1010
use crate::hlc::HlcTimestamp;
11+
use crate::provenance::ProvenanceHeader;
1112

1213
/// Unique message identifier.
1314
#[derive(
@@ -372,12 +373,28 @@ pub trait RingMessage: Send + Sync + 'static {
372373
}
373374

374375
/// Envelope containing header and serialized payload.
376+
///
377+
/// The optional `provenance` slot carries PROV-O attribution metadata for
378+
/// NSAI reasoning chains (see [`crate::provenance`]). When `None`, the field
379+
/// is a single discriminant byte - zero cost for the common case. When
380+
/// populated, it adds a fixed-size [`ProvenanceHeader`] (see that type for
381+
/// size details).
382+
///
383+
/// The `provenance` field is *not* included in the legacy
384+
/// [`MessageEnvelope::to_bytes`] / [`MessageEnvelope::from_bytes`] wire format,
385+
/// which is defined by `MessageHeader` + raw payload for backwards
386+
/// compatibility. Provenance travels separately (e.g. as part of rkyv-encoded
387+
/// envelope transfer on GPU) or is reattached by the router.
375388
#[derive(Debug, Clone)]
376389
pub struct MessageEnvelope {
377390
/// Message header.
378391
pub header: MessageHeader,
379392
/// Serialized payload.
380393
pub payload: Vec<u8>,
394+
/// Optional PROV-O attribution metadata. Defaults to `None`; only
395+
/// populated when the message participates in an audited reasoning
396+
/// chain (e.g. VynGraph NSAI pipelines).
397+
pub provenance: Option<ProvenanceHeader>,
381398
}
382399

383400
impl MessageEnvelope {
@@ -399,7 +416,11 @@ impl MessageEnvelope {
399416
.with_correlation(message.correlation_id())
400417
.with_priority(message.priority());
401418

402-
Self { header, payload }
419+
Self {
420+
header,
421+
payload,
422+
provenance: None,
423+
}
403424
}
404425

405426
/// Get total size (header + payload).
@@ -408,6 +429,10 @@ impl MessageEnvelope {
408429
}
409430

410431
/// Serialize to contiguous bytes.
432+
///
433+
/// NOTE: the provenance metadata is intentionally *not* serialised here.
434+
/// This method keeps the historical wire format unchanged; provenance is
435+
/// transported out-of-band or via rkyv-encoded transfer.
411436
pub fn to_bytes(&self) -> Vec<u8> {
412437
let mut bytes = Vec::with_capacity(self.total_size());
413438
bytes.extend_from_slice(self.header.as_bytes());
@@ -416,6 +441,9 @@ impl MessageEnvelope {
416441
}
417442

418443
/// Deserialize from bytes.
444+
///
445+
/// Reconstructs an envelope with `provenance: None`. Callers that need
446+
/// provenance must reattach it via [`MessageEnvelope::with_provenance`].
419447
pub fn from_bytes(bytes: &[u8]) -> crate::error::Result<Self> {
420448
if bytes.len() < std::mem::size_of::<MessageHeader>() {
421449
return Err(crate::error::RingKernelError::DeserializationError(
@@ -445,7 +473,11 @@ impl MessageEnvelope {
445473

446474
let payload = bytes[payload_start..payload_end].to_vec();
447475

448-
Ok(Self { header, payload })
476+
Ok(Self {
477+
header,
478+
payload,
479+
provenance: None,
480+
})
449481
}
450482

451483
/// Create an empty envelope (for testing).
@@ -454,8 +486,22 @@ impl MessageEnvelope {
454486
Self {
455487
header,
456488
payload: Vec::new(),
489+
provenance: None,
457490
}
458491
}
492+
493+
/// Attach a PROV-O provenance header (builder-style).
494+
pub fn with_provenance(mut self, provenance: ProvenanceHeader) -> Self {
495+
self.provenance = Some(provenance);
496+
self
497+
}
498+
499+
/// Strip provenance (builder-style). Useful when routing a message into
500+
/// an untrusted tenant boundary where attribution must not leak.
501+
pub fn without_provenance(mut self) -> Self {
502+
self.provenance = None;
503+
self
504+
}
459505
}
460506

461507
#[cfg(test)]
@@ -499,12 +545,39 @@ mod tests {
499545
let envelope = MessageEnvelope {
500546
header,
501547
payload: vec![1, 2, 3, 4, 5, 6, 7, 8],
548+
provenance: None,
502549
};
503550

504551
let bytes = envelope.to_bytes();
505552
let restored = MessageEnvelope::from_bytes(&bytes).unwrap();
506553

507554
assert_eq!(envelope.header.message_type, restored.header.message_type);
508555
assert_eq!(envelope.payload, restored.payload);
556+
// Provenance is intentionally not round-tripped through the legacy
557+
// wire format; restored envelopes carry `None` until reattached.
558+
assert!(restored.provenance.is_none());
559+
}
560+
561+
#[test]
562+
fn test_envelope_with_provenance() {
563+
use crate::provenance::{ProvNodeType, ProvRelationKind, ProvenanceBuilder};
564+
565+
let hdr = ProvenanceBuilder::new(ProvNodeType::Entity, 0x42)
566+
.with_relation(ProvRelationKind::WasAttributedTo, 0x7)
567+
.build()
568+
.unwrap();
569+
570+
let envelope = MessageEnvelope::empty(0, 1, HlcTimestamp::now(1)).with_provenance(hdr);
571+
assert!(envelope.provenance.is_some());
572+
assert_eq!(envelope.provenance.unwrap().node_id, 0x42);
573+
574+
let stripped = envelope.without_provenance();
575+
assert!(stripped.provenance.is_none());
576+
}
577+
578+
#[test]
579+
fn test_envelope_default_has_no_provenance() {
580+
let envelope = MessageEnvelope::empty(0, 1, HlcTimestamp::zero());
581+
assert!(envelope.provenance.is_none());
509582
}
510583
}

0 commit comments

Comments
 (0)