contextgraph_types/identity.rs
1//! Stable frame identity and the canonical composition order
2//! (`docs/context-reuse.md` §1).
3//!
4//! A [`FrameId`] is the triple `(provider id, frame id, content digest)` that
5//! names a frame's *exact bytes*. It is the spine the context-reuse guarantees
6//! share: deterministic composition orders frames by it (§1), a usage report
7//! references served frames by it (§2), and a `context/verify` request carries
8//! it so a provider can answer "is this still valid?" without any frame body
9//! travelling (§4).
10//!
11//! The `content_digest` is **provider-declared and opaque**: a provider picks
12//! the algorithm (the reference frames use `sha256:<hex>`, matching the
13//! `provenance` digests) and the protocol never re-derives it. That is
14//! deliberate — a host that computed the digest from its own serialization
15//! would force every out-of-Rust provider to byte-exactly reproduce that
16//! serialization just to answer a verify request, which is precisely the
17//! lock-in the protocol exists to avoid. A frame that declares no digest
18//! (`content_digest: None`) is simply *not verifiable*, and a host falls back
19//! to re-querying it (§4).
20
21use serde::{Deserialize, Serialize};
22
23/// The stable identity of one frame's exact content bytes: `(provider id,
24/// frame id, content digest)`.
25///
26/// The derived [`Ord`] is the protocol's **canonical composition order** —
27/// fields compare in declaration order, i.e. by `provider_id`, then
28/// `frame_id`, then `content_digest`. Sorting a frame set by [`FrameId`] is
29/// what makes an unchanged set render byte-identically across turns and across
30/// hosts (`docs/context-reuse.md` §1).
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
32pub struct FrameId {
33 /// The host-facing id of the provider that served the frame — the same
34 /// routing/consent key the host registered it under.
35 pub provider_id: String,
36 /// The provider-scoped frame id ([`ContextFrame::id`](crate::ContextFrame::id)),
37 /// stable for dedup across queries.
38 pub frame_id: String,
39 /// The provider-declared digest of the frame's content bytes — opaque to
40 /// the protocol (e.g. `sha256:<hex>`). `None` when the provider declared
41 /// none: such a frame is not verifiable and a host re-queries it rather
42 /// than trusting it stale (`docs/context-reuse.md` §4).
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub content_digest: Option<String>,
45}
46
47impl FrameId {
48 /// Build an identity from its parts.
49 pub fn new(
50 provider_id: impl Into<String>,
51 frame_id: impl Into<String>,
52 content_digest: Option<String>,
53 ) -> Self {
54 Self {
55 provider_id: provider_id.into(),
56 frame_id: frame_id.into(),
57 content_digest,
58 }
59 }
60
61 /// Whether this identity carries a content digest and can therefore be
62 /// revalidated by a `context/verify` request. A frame without one is
63 /// re-queried instead (`docs/context-reuse.md` §4).
64 pub fn is_verifiable(&self) -> bool {
65 self.content_digest.is_some()
66 }
67}
68
69/// Sort a set of frame identities into the protocol's canonical composition
70/// order (`docs/context-reuse.md` §1). A thin, explicit wrapper over the
71/// derived [`Ord`] so call sites read as intent, not a bare `.sort()`.
72pub fn canonical_order(ids: &mut [FrameId]) {
73 ids.sort();
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
81 fn frame_id_roundtrips_through_json() {
82 let id = FrameId::new("repo-graph", "retry-doc", Some("sha256:9f2c".into()));
83 let json = serde_json::to_string(&id).unwrap();
84 let back: FrameId = serde_json::from_str(&json).unwrap();
85 assert_eq!(back, id);
86 }
87
88 #[test]
89 fn an_absent_digest_is_omitted_and_marks_the_frame_unverifiable() {
90 let id = FrameId::new("repo-graph", "retry-doc", None);
91 assert!(!id.is_verifiable());
92 let json = serde_json::to_string(&id).unwrap();
93 assert!(
94 !json.contains("content_digest"),
95 "an absent digest must be omitted, not serialized as null: {json}"
96 );
97 let back: FrameId = serde_json::from_str(&json).unwrap();
98 assert_eq!(back, id);
99 }
100
101 #[test]
102 fn canonical_order_is_by_provider_then_frame_then_digest() {
103 let mut ids = vec![
104 FrameId::new("b-provider", "frame-1", None),
105 FrameId::new("a-provider", "frame-2", None),
106 FrameId::new("a-provider", "frame-1", Some("sha256:zz".into())),
107 FrameId::new("a-provider", "frame-1", Some("sha256:aa".into())),
108 ];
109 canonical_order(&mut ids);
110 assert_eq!(
111 ids,
112 vec![
113 // a-provider sorts before b-provider…
114 FrameId::new("a-provider", "frame-1", Some("sha256:aa".into())),
115 // …then by frame id, then by digest (aa before zz).
116 FrameId::new("a-provider", "frame-1", Some("sha256:zz".into())),
117 FrameId::new("a-provider", "frame-2", None),
118 FrameId::new("b-provider", "frame-1", None),
119 ]
120 );
121 }
122
123 #[test]
124 fn ordering_is_total_and_stable_regardless_of_input_order() {
125 let canonical = {
126 let mut ids = vec![
127 FrameId::new("p", "c", None),
128 FrameId::new("p", "a", None),
129 FrameId::new("p", "b", None),
130 ];
131 canonical_order(&mut ids);
132 ids
133 };
134 // Any permutation sorts to the same sequence.
135 let mut shuffled = vec![
136 FrameId::new("p", "b", None),
137 FrameId::new("p", "c", None),
138 FrameId::new("p", "a", None),
139 ];
140 canonical_order(&mut shuffled);
141 assert_eq!(shuffled, canonical);
142 }
143}