Skip to main content

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}