Skip to main content

contextgraph_types/
frame.rs

1//! `ContextFrame` — the unit of exchange between a CGP host and a provider.
2//! `SPEC.md` §6 fixes this exact
3//! shape; frames, never blobs, carry relevance, cost, and provenance so a
4//! budgeting, citing host can compose sources honestly.
5//!
6//! ## Frame representations (CGEP lifecycle, phase 2)
7//!
8//! A frame states how it carries its content through [`Representation`]:
9//! `full` inlines the content (the legacy default), `compact` inlines a
10//! transformed rendering alongside a resolver handle, and `reference` carries
11//! no inline content at all — only a [`ContentRef`] and a
12//! [`canonical_content_hash`](ContextFrame::canonical_content_hash) so a host
13//! can rehydrate honestly and verifiably. `representation` absent means `full`,
14//! so pre-representation providers and stored frames deserialize unchanged.
15
16use serde::{Deserialize, Serialize};
17
18use crate::identity::FrameId;
19use crate::token::budget_tokens;
20use crate::validate::{is_protocol_timestamp, is_well_formed_digest};
21
22/// The wire strings of the seven base frame kinds.
23const KIND_SNIPPET: &str = "snippet";
24const KIND_SYMBOL: &str = "symbol";
25const KIND_FACT: &str = "fact";
26const KIND_DOC: &str = "doc";
27const KIND_MEMORY: &str = "memory";
28const KIND_EPISODE: &str = "episode";
29const KIND_GRAPH: &str = "graph";
30
31/// What kind of thing a frame represents (`SPEC.md` §6).
32///
33/// # Why this is not a closed enum
34///
35/// The protocol guarantees no flag day inside a major family: a `contextgraph/1.0`
36/// host and a `contextgraph/1.1` host interoperate, and minor versions may add
37/// vocabulary. A closed enum contradicts that guarantee twice over. A frame
38/// carrying a kind introduced in 1.1 would fail to **deserialize** on a 1.0
39/// host — not degrade, *fail* — and every exhaustive `match` in downstream Rust
40/// would break the day a variant was added.
41///
42/// So `FrameKind` follows the same shape as [`EgressScope`](crate::EgressScope):
43/// a closed base vocabulary of seven kinds, plus an
44/// [`Unknown`](Self::Unknown) variant that **preserves the original string**.
45/// A host that does not recognize a kind can still parse the frame, route it,
46/// budget it, cite it, and re-serialize it byte-identically.
47///
48/// That last property is why `Unknown` carries a `String` rather than being a
49/// bare unit variant with `#[serde(other)]`. `#[serde(other)]` collapses every
50/// unrecognized value into one variant and *discards* the original, so a host
51/// relaying a frame it did not fully understand would silently rewrite
52/// `"kind": "trajectory"` to something else on the way out. A forward-compat
53/// mechanism that corrupts data in a relay is worse than the failure it
54/// replaces.
55///
56/// # `#[non_exhaustive]`
57///
58/// The attribute forces downstream `match` expressions to carry a wildcard arm,
59/// which makes every *future* kind addition a non-breaking change for every
60/// consumer. It is a one-time cost paid now so the version promise holds
61/// forever after.
62///
63/// # Not `Copy`
64///
65/// Preserving an unknown kind's string means the type owns an allocation, so it
66/// cannot be `Copy`. Forward compatibility is a protocol guarantee; `Copy` was
67/// an ergonomic convenience. When the two conflict the guarantee wins.
68#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
69#[non_exhaustive]
70pub enum FrameKind {
71    Snippet,
72    Symbol,
73    Fact,
74    Doc,
75    Memory,
76    Episode,
77    Graph,
78    /// A kind this revision does not define — most likely one introduced by a
79    /// later minor version of the same major family. The original wire string
80    /// is retained verbatim so the frame round-trips unchanged.
81    ///
82    /// A host **MUST NOT** reject a frame for carrying an unknown kind. It may
83    /// decline to *specialize* its handling — that is a rendering decision, not
84    /// a validity one.
85    Unknown(String),
86}
87
88impl FrameKind {
89    /// The canonical wire string of this kind.
90    pub fn as_str(&self) -> &str {
91        match self {
92            Self::Snippet => KIND_SNIPPET,
93            Self::Symbol => KIND_SYMBOL,
94            Self::Fact => KIND_FACT,
95            Self::Doc => KIND_DOC,
96            Self::Memory => KIND_MEMORY,
97            Self::Episode => KIND_EPISODE,
98            Self::Graph => KIND_GRAPH,
99            Self::Unknown(kind) => kind,
100        }
101    }
102
103    /// Parse a wire string: a known base name maps to its variant, anything
104    /// else to [`Unknown`](Self::Unknown). Never fails — an unrecognized kind
105    /// is a frame a host cannot specialize, not a frame it must refuse.
106    pub fn from_wire(kind: impl Into<String>) -> Self {
107        let kind = kind.into();
108        match kind.as_str() {
109            KIND_SNIPPET => Self::Snippet,
110            KIND_SYMBOL => Self::Symbol,
111            KIND_FACT => Self::Fact,
112            KIND_DOC => Self::Doc,
113            KIND_MEMORY => Self::Memory,
114            KIND_EPISODE => Self::Episode,
115            KIND_GRAPH => Self::Graph,
116            _ => Self::Unknown(kind),
117        }
118    }
119
120    /// Whether this kind is one of the seven this revision defines.
121    pub fn is_known(&self) -> bool {
122        !matches!(self, Self::Unknown(_))
123    }
124
125    /// Every kind this revision names — a registry, not a restriction.
126    pub const KNOWN: &'static [&'static str] = &[
127        KIND_SNIPPET,
128        KIND_SYMBOL,
129        KIND_FACT,
130        KIND_DOC,
131        KIND_MEMORY,
132        KIND_EPISODE,
133        KIND_GRAPH,
134    ];
135}
136
137impl std::fmt::Display for FrameKind {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.write_str(self.as_str())
140    }
141}
142
143impl Serialize for FrameKind {
144    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
145        serializer.serialize_str(self.as_str())
146    }
147}
148
149impl<'de> Deserialize<'de> for FrameKind {
150    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
151        let kind = String::deserialize(deserializer)?;
152        Ok(Self::from_wire(kind))
153    }
154}
155
156/// How a frame carries its content
157/// (CGEP lifecycle build prompt, §"ContextFrame representations").
158///
159/// - `full`: canonical inline [`content`](ContextFrame::content) is required.
160/// - `compact`: inline content, inline hash, canonical hash, a [`Transform`]
161///   identity, and a [`ContentRef`] are all required.
162/// - `reference`: inline content is **absent**; a [`ContentRef`] and
163///   [`canonical_content_hash`](ContextFrame::canonical_content_hash) are
164///   required; the inline content hash and transform are omitted.
165///
166/// A `full` frame omits this field on the wire ([`is_full`](Self::is_full)) so
167/// legacy frames round-trip byte-for-byte.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum Representation {
171    #[default]
172    Full,
173    Compact,
174    Reference,
175}
176
177impl Representation {
178    /// Whether this is the legacy default representation. A `full` frame omits
179    /// the `representation` field on the wire, so a frame emitted before this
180    /// field existed round-trips unchanged and `representation` absent means
181    /// `full`.
182    pub fn is_full(&self) -> bool {
183        matches!(self, Representation::Full)
184    }
185}
186
187/// The fidelity of a frame's carried content relative to its canonical source.
188/// A missing value means `exact` for a legacy full frame.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum ContentFidelity {
192    Exact,
193    Normalized,
194    Summarized,
195    Omitted,
196}
197
198/// Whether a frame's point of use requires the content inline, or accepts a
199/// resolvable reference. Blocking constraints, guarded rules, ordered
200/// procedures, and executable contracts require inline content at their point
201/// of use; this keeps a reference choice from being confused with fidelity.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum InlineContentRequirement {
205    Required,
206    ResolvableReferenceAllowed,
207}
208
209/// An opaque resolver handle for a `compact`/`reference` frame's content.
210///
211/// [`ContextFrame::uri`] identifies the source resource; `ContentRef::uri` is a
212/// **distinct** opaque resolver handle. A `ContentRef` also names the exact
213/// [`provider_id`](Self::provider_id) that returned it, so a fan-out host routes
214/// resolution back to that provider.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ContentRef {
217    /// The exact provider that returned this frame; a fan-out host routes
218    /// `context/resolve` back to it.
219    pub provider_id: String,
220    /// Opaque resolver handle, distinct from [`ContextFrame::uri`].
221    pub uri: String,
222    /// When the handle stops resolving. Absent ⇒ no declared expiry.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub expires_at: Option<String>,
225}
226
227/// The transformation identity a `compact` frame applies to its source to
228/// produce the inline rendering, so a consumer knows what it is reading.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct Transform {
231    /// e.g. `extractive_summary`, `truncation`.
232    pub method: String,
233    /// e.g. `provider_default`, or a named implementation.
234    pub implementation: String,
235    pub version: String,
236}
237
238/// One link in a frame's provenance chain, ordered closest-to-source first.
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct Provenance {
241    /// e.g. "file", "derivation", "episode".
242    #[serde(rename = "type")]
243    pub kind: String,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub uri: Option<String>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub range: Option<String>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub digest: Option<String>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub method: Option<String>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub by: Option<String>,
254}
255
256impl Provenance {
257    /// Whether this link addresses bytes a host could independently re-read.
258    pub fn is_file_provenance(&self) -> bool {
259        self.kind == "file"
260    }
261
262    /// Whether the digest, if present, matches the grammar in `SPEC.md` §F5.
263    ///
264    /// Absent counts as *not* well-formed: for file provenance the digest is
265    /// what makes tamper-detection possible at all, and treating "no digest"
266    /// as acceptable is how the guarantee stayed decorative for so long.
267    pub fn has_well_formed_digest(&self) -> bool {
268        self.digest.as_deref().is_some_and(is_well_formed_digest)
269    }
270}
271
272/// The recommended relation vocabulary (`SPEC.md` §Graph).
273///
274/// `Relation.rel` is an **open** vocabulary — a provider may emit any string,
275/// and a host must not reject an unknown one. These constants exist so that
276/// independent providers converge on the same spelling for the same edge
277/// instead of each inventing `calls` / `call` / `code.call`. Using them is
278/// SHOULD-level, not MUST.
279///
280/// Namespacing is the part that matters: a provider-specific edge belongs
281/// under its own prefix (`myindex.owns`), which keeps the shared namespace
282/// meaningful and makes a future registry possible.
283pub mod rel {
284    /// The subject calls the target.
285    pub const CODE_CALLS: &str = "code.calls";
286    /// The subject imports the target.
287    pub const CODE_IMPORTS: &str = "code.imports";
288    /// The subject defines the target.
289    pub const CODE_DEFINES: &str = "code.defines";
290    /// The subject references the target without calling it.
291    pub const CODE_REFERENCES: &str = "code.references";
292    /// The subject documents the target.
293    pub const DOC_DOCUMENTS: &str = "doc.documents";
294    /// The subject episode follows the target episode in time.
295    pub const EPISODE_FOLLOWS: &str = "episode.follows";
296
297    /// Every relation this revision names. A registry, not a restriction.
298    pub const RECOMMENDED: &[&str] = &[
299        CODE_CALLS,
300        CODE_IMPORTS,
301        CODE_DEFINES,
302        CODE_REFERENCES,
303        DOC_DOCUMENTS,
304        EPISODE_FOLLOWS,
305    ];
306}
307
308/// A graph relation a frame participates in, surfaced with a human label —
309/// raw ids are never the primary identifier (`SPEC.md` §G1).
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311pub struct Relation {
312    /// The edge label. See [`rel`] for the recommended vocabulary; unknown
313    /// values are valid and **MUST NOT** be rejected by a host.
314    pub rel: String,
315    pub target_uri: String,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub display_name: Option<String>,
318}
319
320impl Relation {
321    /// Whether this edge can be surfaced to a human by name.
322    ///
323    /// The "never a raw id" rule has been documented since the protocol's first
324    /// draft and was checked by nothing; `SPEC.md` §G1 now makes it a
325    /// conformance requirement for graph-capable providers, and this is the
326    /// predicate behind it.
327    pub fn has_display_name(&self) -> bool {
328        self.display_name
329            .as_deref()
330            .is_some_and(|name| !name.trim().is_empty())
331    }
332
333    /// Whether this edge points somewhere (`SPEC.md` §G2).
334    ///
335    /// `target_uri` is a required field, so serde guarantees it is *present* —
336    /// but `""` deserializes happily, and an edge to nowhere is not an edge.
337    /// The schema has carried `minLength: 1` here from the start; §G2 claimed
338    /// `frame-validity` verified it and no code did, which is the
339    /// self-attestation §11.1 exists to rule out.
340    pub fn has_target_uri(&self) -> bool {
341        !self.target_uri.trim().is_empty()
342    }
343
344    /// Whether `rel` uses the recommended vocabulary. Advisory only — a `false`
345    /// here is a hint for a provider author, never a conformance failure.
346    pub fn uses_recommended_vocabulary(&self) -> bool {
347        rel::RECOMMENDED.contains(&self.rel.as_str())
348    }
349}
350
351/// The optional embedding carried by a frame. The vector itself is
352/// elidable — a host may want the fingerprint without the payload.
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct FrameEmbedding {
355    pub fingerprint: String,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub vector: Option<Vec<f32>>,
358}
359
360/// One context frame returned from `context/query`.
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362pub struct ContextFrame {
363    /// Provider-scoped, stable for dedup across queries.
364    pub id: String,
365    pub kind: FrameKind,
366    /// Human label — never a bare uuid.
367    pub title: String,
368    /// Text the host may quote into a prompt. Untrusted data: a conforming
369    /// host delimits this as quoted material, never as instructions.
370    ///
371    /// Present for `full`/`compact` frames; **absent** for `reference` frames,
372    /// which carry only a [`content_ref`](Self::content_ref). A reference is
373    /// never encoded as `content: ""` — the field is omitted entirely.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub content: Option<String>,
376    /// The provider-declared digest of this frame's **inline** content bytes —
377    /// the third component of its stable [`FrameId`](crate::FrameId) identity,
378    /// opaque to the protocol (e.g. `sha256:<hex>`). This is the spec's
379    /// `content_hash` (SHA-256 over the exact inline UTF-8 content) under its
380    /// established name; see [`canonical_content_hash`](Self::canonical_content_hash)
381    /// for the full-source hash. Absent ⇒ the frame is not verifiable and a
382    /// host re-queries it rather than reusing it unchecked
383    /// (`docs/context-reuse.md` §1, §4). A `reference` frame omits it.
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub content_digest: Option<String>,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub uri: Option<String>,
388    /// How this frame carries its content. Absent ⇒ [`Representation::Full`],
389    /// so legacy frames deserialize unchanged and full frames omit the field.
390    #[serde(default, skip_serializing_if = "Representation::is_full")]
391    pub representation: Representation,
392    /// Fidelity of the carried content relative to the source. Absent ⇒ `exact`
393    /// for a legacy full frame.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub content_fidelity: Option<ContentFidelity>,
396    /// SHA-256 over the **complete source content** bytes (distinct from the
397    /// inline [`content_digest`](Self::content_digest)). Required for
398    /// `compact`/`reference` frames so a resolved rehydration is verifiable.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub canonical_content_hash: Option<String>,
401    /// The opaque resolver handle for a `compact`/`reference` frame's content.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub content_ref: Option<ContentRef>,
404    /// The transformation a `compact` frame applied to its source. Omitted for
405    /// `full`/`reference` frames.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub transform: Option<Transform>,
408    /// The lowest content fidelity acceptable at this frame's point of use.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub minimum_content_fidelity: Option<ContentFidelity>,
411    /// Whether this frame's point of use requires inline content.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub inline_content_requirement: Option<InlineContentRequirement>,
414    /// Provider-normalized relevance in `[0, 1]`.
415    pub score: f32,
416    /// Honest, conformance-audited token cost of the **inline** rendering.
417    pub token_cost: u32,
418    /// Token cost of the complete canonical source content, when the provider
419    /// declares it. If present, [`tokenizer_ref`](Self::tokenizer_ref) SHOULD
420    /// name the tokenizer it was measured with. Hosts compute model-specific
421    /// costs when providers omit it.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub canonical_token_cost: Option<u32>,
424    /// Identifies the tokenizer that produced the declared costs
425    /// (e.g. `openai:o200k_base`).
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub tokenizer_ref: Option<String>,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub valid_from: Option<String>,
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub valid_to: Option<String>,
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub recorded_at: Option<String>,
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub provenance: Vec<Provenance>,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub citation_label: Option<String>,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub embedding: Option<FrameEmbedding>,
440    #[serde(default, skip_serializing_if = "Vec::is_empty")]
441    pub relations: Vec<Relation>,
442}
443
444impl ContextFrame {
445    /// A `full`-representation frame carrying inline `content` — the shape every
446    /// legacy provider emits. The representation/cost/resolver fields default to
447    /// absent, so a call site need only supply the core, then set extras as
448    /// needed (the build prompt asks for constructors to reduce source
449    /// breakage).
450    pub fn full(
451        id: impl Into<String>,
452        kind: FrameKind,
453        title: impl Into<String>,
454        content: impl Into<String>,
455        score: f32,
456        token_cost: u32,
457    ) -> Self {
458        Self {
459            id: id.into(),
460            kind,
461            title: title.into(),
462            content: Some(content.into()),
463            content_digest: None,
464            uri: None,
465            representation: Representation::Full,
466            content_fidelity: None,
467            canonical_content_hash: None,
468            content_ref: None,
469            transform: None,
470            minimum_content_fidelity: None,
471            inline_content_requirement: None,
472            score,
473            token_cost,
474            canonical_token_cost: None,
475            tokenizer_ref: None,
476            valid_from: None,
477            valid_to: None,
478            recorded_at: None,
479            provenance: Vec::new(),
480            citation_label: None,
481            embedding: None,
482            relations: Vec::new(),
483        }
484    }
485
486    /// A `reference`-representation frame: no inline content, only a resolver
487    /// handle and the canonical source hash for honest, verifiable rehydration.
488    /// `token_cost` is the inline cost (0 — nothing is inlined).
489    pub fn reference(
490        id: impl Into<String>,
491        kind: FrameKind,
492        title: impl Into<String>,
493        content_ref: ContentRef,
494        canonical_content_hash: impl Into<String>,
495        score: f32,
496    ) -> Self {
497        Self {
498            representation: Representation::Reference,
499            content: None,
500            content_ref: Some(content_ref),
501            canonical_content_hash: Some(canonical_content_hash.into()),
502            ..Self::full(id, kind, title, String::new(), score, 0)
503        }
504        // `..full(..)` seeds every other field; the overrides above make this a
505        // structurally honest reference (content absent, content_digest None,
506        // transform None) that satisfies `representation_invariants`.
507    }
508
509    /// Score must be normalized into `[0, 1]` per the protocol contract.
510    /// Conformance suites assert this; providers should self-check too.
511    pub fn has_valid_score(&self) -> bool {
512        (0.0..=1.0).contains(&self.score)
513    }
514
515    /// The frame's stable identity under the given provider: `(provider id,
516    /// frame id, content digest)`. The digest is carried through from
517    /// [`content_digest`](Self::content_digest), so a frame without one yields
518    /// an unverifiable identity (`docs/context-reuse.md` §1).
519    pub fn identity(&self, provider_id: impl Into<String>) -> FrameId {
520        FrameId::new(provider_id, self.id.clone(), self.content_digest.clone())
521    }
522
523    /// The token cost this frame's **inline** content is *required* to declare
524    /// (`SPEC.md` §B3) — see [`budget_tokens`](crate::budget_tokens). A
525    /// `reference` frame carries no inline content, so its expected cost is 0.
526    ///
527    /// Distinct from the [`canonical_token_cost`](Self::canonical_token_cost)
528    /// *field*, which is the provider-declared cost of the full source content.
529    pub fn expected_inline_token_cost(&self) -> u32 {
530        budget_tokens(self.content.as_deref().unwrap_or(""))
531    }
532
533    /// Whether `token_cost` matches the canonical count for this frame's
534    /// content.
535    ///
536    /// This is the check that turned budget honesty from arithmetic into
537    /// truth: previously a provider could declare `token_cost: 1` on a
538    /// ten-thousand-token frame and pass every check in the suite.
539    pub fn declares_honest_token_cost(&self) -> bool {
540        self.token_cost == self.expected_inline_token_cost()
541    }
542
543    /// The names of any temporal fields that are not in the protocol's
544    /// timestamp profile (`SPEC.md` §F4).
545    ///
546    /// Returns the field *names* rather than a bare bool so a conformance
547    /// failure can say which field was wrong — an evidence string reading
548    /// "valid_from" is actionable in a way that "temporal validation failed"
549    /// is not.
550    pub fn invalid_temporal_fields(&self) -> Vec<&'static str> {
551        [
552            ("valid_from", self.valid_from.as_deref()),
553            ("valid_to", self.valid_to.as_deref()),
554            ("recorded_at", self.recorded_at.as_deref()),
555        ]
556        .into_iter()
557        .filter(|(_, value)| value.is_some_and(|v| !is_protocol_timestamp(v)))
558        .map(|(name, _)| name)
559        .collect()
560    }
561
562    /// Whether every temporal field present on this frame is well-formed.
563    pub fn has_valid_temporal_fields(&self) -> bool {
564        self.invalid_temporal_fields().is_empty()
565    }
566
567    /// Provenance entries that address a file but carry a malformed or missing
568    /// digest (`SPEC.md` §F5).
569    ///
570    /// File provenance is held to a stricter standard than other kinds because
571    /// it is the one the host can independently verify: the bytes are on disk.
572    /// A `derivation` or `episode` link has no addressable bytes, so requiring
573    /// a digest of it would be theatre.
574    pub fn provenance_with_unusable_digests(&self) -> Vec<usize> {
575        self.provenance
576            .iter()
577            .enumerate()
578            .filter(|(_, p)| p.is_file_provenance() && !p.has_well_formed_digest())
579            .map(|(index, _)| index)
580            .collect()
581    }
582
583    /// Whether this frame's own `content_digest`, if it carries one, is in the
584    /// protocol's digest form (`SPEC.md` §D1).
585    ///
586    /// Absent is fine — §D1 binds the digest only "when present". What is not
587    /// fine is a *present* digest that no host can compare against, which is
588    /// what `sha256:abc` is. §D1 named `frame-validity` as its verifier while
589    /// nothing read this field: the digest that anchors deterministic
590    /// composition, usage reports, and `context/verify` was the one digest in
591    /// the protocol that went unchecked, and §F5 held provenance to a stricter
592    /// standard than the frame's own identity.
593    pub fn has_usable_content_digest(&self) -> bool {
594        self.content_digest
595            .as_deref()
596            .is_none_or(is_well_formed_digest)
597    }
598
599    /// Whether this frame's fields satisfy the invariants of its declared
600    /// [`representation`](Self::representation). Providers emit conforming
601    /// frames; hosts reject a frame that lies about its shape (e.g. a
602    /// `reference` carrying inline content, or a `compact` missing its
603    /// canonical hash). The `Err` string names the exact violation.
604    pub fn representation_invariants(&self) -> Result<(), String> {
605        match self.representation {
606            Representation::Full => {
607                if self.content.is_none() {
608                    return Err("full frame requires inline content".into());
609                }
610            }
611            Representation::Compact => {
612                if self.content.is_none() {
613                    return Err("compact frame requires inline content".into());
614                }
615                if self.content_digest.is_none() {
616                    return Err(
617                        "compact frame requires an inline content hash (content_digest)".into(),
618                    );
619                }
620                if self.canonical_content_hash.is_none() {
621                    return Err("compact frame requires canonical_content_hash".into());
622                }
623                if self.transform.is_none() {
624                    return Err("compact frame requires a transform identity".into());
625                }
626                if self.content_ref.is_none() {
627                    return Err("compact frame requires content_ref".into());
628                }
629            }
630            Representation::Reference => {
631                // "Never encode a reference as content: \"\"" — any inline
632                // content, empty or not, is a violation.
633                if self.content.is_some() {
634                    return Err("reference frame must not carry inline content".into());
635                }
636                if self.content_ref.is_none() {
637                    return Err("reference frame requires content_ref".into());
638                }
639                if self.canonical_content_hash.is_none() {
640                    return Err("reference frame requires canonical_content_hash".into());
641                }
642                if self.content_digest.is_some() {
643                    return Err(
644                        "reference frame must omit the inline content hash (content_digest)".into(),
645                    );
646                }
647                if self.transform.is_some() {
648                    return Err("reference frame must omit transform".into());
649                }
650            }
651        }
652        Ok(())
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    fn sample_frame() -> ContextFrame {
661        let mut frame = ContextFrame::full(
662            "frm_1",
663            FrameKind::Snippet,
664            "workspace.ts L120-160",
665            "export interface Workspace { ... }",
666            0.83,
667            412,
668        );
669        frame.content_digest = Some("sha256:abc".into());
670        frame.uri = Some("file:///repo/workspace.ts".into());
671        frame.recorded_at = Some("2026-07-10T00:00:00Z".into());
672        frame.provenance = vec![Provenance {
673            kind: "file".into(),
674            uri: Some("file:///repo/workspace.ts".into()),
675            range: Some("L120-160".into()),
676            digest: Some("sha256:abc".into()),
677            method: None,
678            by: None,
679        }];
680        frame.citation_label = Some("workspace.ts L120-160".into());
681        frame
682    }
683
684    #[test]
685    fn context_frame_roundtrips_through_json() {
686        let frame = sample_frame();
687        let json = serde_json::to_string(&frame).unwrap();
688        let back: ContextFrame = serde_json::from_str(&json).unwrap();
689        assert_eq!(back, frame);
690    }
691
692    #[test]
693    fn score_out_of_range_fails_the_conformance_check() {
694        let mut frame = sample_frame();
695        assert!(frame.has_valid_score());
696        frame.score = 1.5;
697        assert!(!frame.has_valid_score());
698    }
699
700    #[test]
701    fn an_honest_frame_declares_the_canonical_cost_of_its_content() {
702        let mut frame = sample_frame();
703        frame.content = Some("abcd".repeat(10)); // 40 bytes -> 10 budget tokens
704        frame.token_cost = 10;
705        assert!(frame.declares_honest_token_cost());
706        assert_eq!(frame.expected_inline_token_cost(), 10);
707    }
708
709    #[test]
710    fn the_budget_lie_that_used_to_pass_every_check_is_now_caught() {
711        // Issue #8's headline case: a provider reporting `token_cost: 1` on a
712        // huge frame satisfied `sum(token_cost) <= max_tokens` perfectly.
713        let mut frame = sample_frame();
714        frame.content = Some("x".repeat(10_000));
715        frame.token_cost = 1;
716        assert!(!frame.declares_honest_token_cost());
717        assert_eq!(frame.expected_inline_token_cost(), 2_500);
718    }
719
720    #[test]
721    fn over_reporting_cost_is_a_lie_too_even_though_it_is_self_harming() {
722        // Exact equality, not an upper bound: an inflated cost would let a
723        // provider crowd honest peers out of a shared budget.
724        let mut frame = sample_frame();
725        frame.content = Some("abcd".into());
726        frame.token_cost = 500;
727        assert!(!frame.declares_honest_token_cost());
728    }
729
730    #[test]
731    fn malformed_temporal_fields_are_reported_by_name() {
732        let mut frame = sample_frame();
733        frame.valid_from = Some("last tuesday".into());
734        frame.valid_to = Some("2026-08-01T00:00:00Z".into());
735        frame.recorded_at = Some("2026-07-10".into());
736
737        // The names are what make a conformance failure actionable.
738        assert_eq!(
739            frame.invalid_temporal_fields(),
740            vec!["valid_from", "recorded_at"]
741        );
742        assert!(!frame.has_valid_temporal_fields());
743    }
744
745    #[test]
746    fn absent_temporal_fields_are_valid_because_they_are_optional() {
747        let mut frame = sample_frame();
748        frame.valid_from = None;
749        frame.valid_to = None;
750        frame.recorded_at = None;
751        assert!(frame.has_valid_temporal_fields());
752    }
753
754    #[test]
755    fn file_provenance_without_a_usable_digest_is_flagged_by_index() {
756        let mut frame = sample_frame();
757        // `sha256:abc` is the placeholder the pre-spec fixtures used.
758        assert_eq!(frame.provenance_with_unusable_digests(), vec![0]);
759
760        frame.provenance[0].digest = Some(format!("sha256:{}", "a".repeat(64)));
761        assert!(frame.provenance_with_unusable_digests().is_empty());
762    }
763
764    #[test]
765    fn non_file_provenance_is_not_required_to_carry_a_digest() {
766        // A derivation has no addressable bytes to digest, so demanding one
767        // would be theatre rather than integrity.
768        let mut frame = sample_frame();
769        frame.provenance = vec![Provenance {
770            kind: "derivation".into(),
771            uri: None,
772            range: None,
773            digest: None,
774            method: Some("summarized".into()),
775            by: Some("contextgraph-docs".into()),
776        }];
777        assert!(frame.provenance_with_unusable_digests().is_empty());
778    }
779
780    #[test]
781    fn a_graph_edge_must_be_citable_by_a_human_label() {
782        let edge = Relation {
783            rel: rel::CODE_CALLS.into(),
784            target_uri: "file:///repo/src/net.rs#retry".into(),
785            display_name: Some("net::retry".into()),
786        };
787        assert!(edge.has_display_name());
788        assert!(edge.uses_recommended_vocabulary());
789
790        // A raw id with no label is exactly what the "never a bare uuid" rule
791        // forbids, and nothing checked it before.
792        let unlabeled = Relation {
793            rel: "myindex.owns".into(),
794            target_uri: "file:///repo/src/net.rs".into(),
795            display_name: None,
796        };
797        assert!(!unlabeled.has_display_name());
798        // ...but an out-of-vocabulary `rel` is perfectly legal.
799        assert!(!unlabeled.uses_recommended_vocabulary());
800    }
801
802    #[test]
803    fn a_whitespace_only_display_name_does_not_count_as_a_label() {
804        let edge = Relation {
805            rel: rel::DOC_DOCUMENTS.into(),
806            target_uri: "file:///docs/net.md".into(),
807            display_name: Some("   ".into()),
808        };
809        assert!(!edge.has_display_name());
810    }
811
812    #[test]
813    fn a_present_content_digest_must_be_usable_but_an_absent_one_is_fine() {
814        // §D1 binds the digest only "when present": a frame that carries none
815        // is conformant, a frame that carries `sha256:abc` is not.
816        let mut frame = sample_frame();
817        frame.content_digest = None;
818        assert!(frame.has_usable_content_digest(), "absent is permitted");
819
820        frame.content_digest = Some(format!("sha256:{}", "a".repeat(64)));
821        assert!(frame.has_usable_content_digest());
822
823        for malformed in ["sha256:abc", &format!("sha256:{}", "A".repeat(64))] {
824            frame.content_digest = Some(malformed.to_string());
825            assert!(
826                !frame.has_usable_content_digest(),
827                "{malformed} is not a comparable digest"
828            );
829        }
830    }
831
832    #[test]
833    fn an_edge_pointing_nowhere_does_not_satisfy_g2() {
834        // §G2 says `target_uri` MUST be a non-empty URI and claimed
835        // `frame-validity` checked it; nothing read the field at all. serde
836        // guarantees presence (it is not an Option), so the reachable breach is
837        // the empty — or whitespace-only — string, which the schema's
838        // `minLength: 1` rejects and the Rust side silently accepted.
839        let labelled_but_dangling = Relation {
840            rel: rel::DOC_DOCUMENTS.into(),
841            target_uri: String::new(),
842            display_name: Some("Net docs".into()),
843        };
844        assert!(labelled_but_dangling.has_display_name(), "§G1 is satisfied");
845        assert!(!labelled_but_dangling.has_target_uri(), "but §G2 is not");
846
847        let whitespace = Relation {
848            target_uri: "   ".into(),
849            ..labelled_but_dangling.clone()
850        };
851        assert!(!whitespace.has_target_uri());
852
853        let real = Relation {
854            target_uri: "file:///docs/net.md".into(),
855            ..labelled_but_dangling
856        };
857        assert!(real.has_target_uri());
858    }
859
860    #[test]
861    fn optional_fields_are_omitted_when_absent() {
862        let frame = sample_frame();
863        let mut minimal = frame.clone();
864        minimal.uri = None;
865        minimal.valid_from = None;
866        minimal.content_digest = None;
867        minimal.provenance.clear();
868        let json = serde_json::to_string(&minimal).unwrap();
869        assert!(!json.contains("\"uri\""));
870        assert!(!json.contains("\"provenance\""));
871        assert!(!json.contains("\"content_digest\""));
872    }
873
874    #[test]
875    fn full_frame_omits_representation_on_the_wire() {
876        // A full frame keeps the legacy wire shape: `representation` is absent,
877        // so pre-representation consumers see no new field.
878        let frame = sample_frame();
879        assert_eq!(frame.representation, Representation::Full);
880        let json = serde_json::to_string(&frame).unwrap();
881        assert!(
882            !json.contains("representation"),
883            "full frames must omit the representation field: {json}"
884        );
885        assert!(frame.representation_invariants().is_ok());
886    }
887
888    #[test]
889    fn reference_frame_omits_content_and_round_trips_its_handle() {
890        let frame = ContextFrame::reference(
891            "frm_ref_1",
892            FrameKind::Doc,
893            "Deployment runbook",
894            ContentRef {
895                provider_id: "provider_example".into(),
896                uri: "context://provider_example/records/doc_runbook_v1".into(),
897                expires_at: None,
898            },
899            "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
900            0.9,
901        );
902        frame
903            .representation_invariants()
904            .expect("constructed reference frame must be structurally honest");
905
906        let json = serde_json::to_string(&frame).unwrap();
907        assert!(
908            !json.contains("\"content\""),
909            "a reference frame must not carry inline content: {json}"
910        );
911        assert!(json.contains("\"representation\":\"reference\""));
912
913        let back: ContextFrame = serde_json::from_str(&json).unwrap();
914        assert_eq!(back, frame);
915        assert_eq!(back.representation, Representation::Reference);
916        assert_eq!(
917            back.content_ref.as_ref().unwrap().provider_id,
918            "provider_example"
919        );
920    }
921
922    #[test]
923    fn a_reference_with_inline_content_violates_its_invariants() {
924        let mut frame = ContextFrame::reference(
925            "frm_ref_2",
926            FrameKind::Doc,
927            "Runbook",
928            ContentRef {
929                provider_id: "p".into(),
930                uri: "context://p/r".into(),
931                expires_at: None,
932            },
933            "sha256:aa",
934            0.5,
935        );
936        // Even an empty string is a lie for a reference.
937        frame.content = Some(String::new());
938        assert!(frame.representation_invariants().is_err());
939    }
940
941    #[test]
942    fn compact_frame_requires_its_full_metadata_set() {
943        let mut frame = sample_frame();
944        frame.representation = Representation::Compact;
945        // full()-seeded frame lacks the compact metadata → invalid.
946        assert!(frame.representation_invariants().is_err());
947
948        frame.content_digest = Some("sha256:inline".into());
949        frame.canonical_content_hash = Some("sha256:canonical".into());
950        frame.transform = Some(Transform {
951            method: "extractive_summary".into(),
952            implementation: "provider_default".into(),
953            version: "1".into(),
954        });
955        frame.content_ref = Some(ContentRef {
956            provider_id: "provider_example".into(),
957            uri: "context://provider_example/records/x".into(),
958            expires_at: None,
959        });
960        frame.content = Some("summary…".into());
961        assert!(frame.representation_invariants().is_ok());
962    }
963
964    #[test]
965    fn every_known_kind_round_trips_through_its_canonical_wire_string() {
966        for (kind, wire) in [
967            (FrameKind::Snippet, "snippet"),
968            (FrameKind::Symbol, "symbol"),
969            (FrameKind::Fact, "fact"),
970            (FrameKind::Doc, "doc"),
971            (FrameKind::Memory, "memory"),
972            (FrameKind::Episode, "episode"),
973            (FrameKind::Graph, "graph"),
974        ] {
975            assert_eq!(kind.as_str(), wire);
976            let json = serde_json::to_string(&kind).unwrap();
977            assert_eq!(json, format!("\"{wire}\""));
978            let back: FrameKind = serde_json::from_str(&json).unwrap();
979            assert_eq!(back, kind);
980            assert!(kind.is_known());
981        }
982    }
983
984    #[test]
985    fn a_kind_from_a_later_minor_version_deserializes_instead_of_failing() {
986        // The bug this variant exists for: before it, a `contextgraph/1.1` frame
987        // carrying a kind added in 1.1 made a 1.0 host fail deserialization
988        // outright — which contradicts the protocol's own promise of no flag day
989        // inside a major family.
990        let back: FrameKind = serde_json::from_str("\"trajectory\"").unwrap();
991        assert_eq!(back, FrameKind::Unknown("trajectory".into()));
992        assert!(!back.is_known());
993    }
994
995    #[test]
996    fn an_unknown_kind_re_serializes_byte_identically() {
997        // Why `Unknown(String)` and not `#[serde(other)]`: a relaying host must
998        // hand on exactly what it received. Discarding the string would make a
999        // 1.0 host silently rewrite a 1.1 frame it was merely passing through —
1000        // corruption dressed up as forward compatibility.
1001        let json = "\"trajectory\"";
1002        let kind: FrameKind = serde_json::from_str(json).unwrap();
1003        assert_eq!(serde_json::to_string(&kind).unwrap(), json);
1004    }
1005
1006    #[test]
1007    fn a_whole_frame_with_an_unknown_kind_survives_a_round_trip() {
1008        let wire = r#"{"id":"f1","kind":"trajectory","title":"Run 12","content":"…","score":0.5,"token_cost":1}"#;
1009        let frame: ContextFrame = serde_json::from_str(wire).unwrap();
1010        assert_eq!(frame.kind, FrameKind::Unknown("trajectory".into()));
1011        // Everything else still works: a host can budget, order, and cite a
1012        // frame whose kind it cannot specialize.
1013        assert!(frame.has_valid_score());
1014        let back: ContextFrame =
1015            serde_json::from_str(&serde_json::to_string(&frame).unwrap()).unwrap();
1016        assert_eq!(back, frame);
1017    }
1018
1019    #[test]
1020    fn an_unknown_kind_never_collides_with_a_known_one() {
1021        assert_eq!(FrameKind::from_wire("doc"), FrameKind::Doc);
1022        assert_ne!(FrameKind::Unknown("doc".into()), FrameKind::Doc);
1023        for known in FrameKind::KNOWN {
1024            assert!(FrameKind::from_wire(*known).is_known(), "{known}");
1025        }
1026        assert_eq!(FrameKind::KNOWN.len(), 7);
1027    }
1028}