Skip to main content

vole_document/field/
external.rs

1//! External (non-document) context — Phase 20.4, ADR-0050 / ADR-0052.
2//!
3//! The C4b contract asks for **corpus/external lineage** (a dataset family id,
4//! a member id, and a head/current marker). Those facts are *not* in the
5//! document bytes: a single PDF cannot derive which corpus family it belongs to
6//! or whether it is its family's head. Admitting them is therefore a separate,
7//! explicitly-typed layer supplied by the caller — never inferred, never a
8//! default, and never on the exactness path.
9//!
10//! ## Separation
11//!
12//! An [`ExternalContext`] is stored **beside** the document-derived field: one
13//! self-describing record at `<store-root>/external/<field-id>` (see
14//! [`crate::field::FieldStore::put_external_context`]). It is *not* in the seed
15//! DAG, the observation index, the field manifest, the descriptor blob, or the
16//! exactness authority. Removing it is one `unlink` and touches nothing else, so
17//! every document-derived observation and `materialize(descriptor) ==
18//! original_bytes` are byte-identical whether or not a context is attached.
19//!
20//! ## Honest provenance
21//!
22//! An answer drawn from this layer carries [`crate::field::provenance::Basis::ExternalMetadata`]:
23//! `exact` is always false, no seed node is read, and no integrity scope of the
24//! source is claimed. A field with **no** attached context is a typed decline,
25//! never a guess ([`crate::ErrorClass::UnsupportedFeature`]).
26
27use crate::error::{Error, Result};
28use crate::field::provenance::json_escape;
29
30/// The record magic (`VOLECTX1`), distinguishing it from any other store blob.
31const EXTERNAL_MAGIC: &[u8; 8] = b"VOLECTX1";
32/// The record format version.
33const EXTERNAL_FORMAT_VERSION: u8 = 1;
34
35/// Where an externally-supplied fact came from. A small **closed** vocabulary,
36/// not a free string, so an origin can be compared and rejected rather than
37/// accepted blindly.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum ExternalOrigin {
40    /// Supplied by the evaluation harness/campaign (the C4b court's corpus
41    /// metadata).
42    #[default]
43    Harness,
44    /// Supplied by a human operator.
45    Operator,
46    /// Supplied by an upstream catalog/registry.
47    Catalog,
48}
49
50impl ExternalOrigin {
51    /// Stable lower-case name (used in JSON and receipts).
52    pub const fn name(self) -> &'static str {
53        match self {
54            ExternalOrigin::Harness => "harness",
55            ExternalOrigin::Operator => "operator",
56            ExternalOrigin::Catalog => "catalog",
57        }
58    }
59
60    /// The stable on-disk code.
61    const fn code(self) -> u8 {
62        match self {
63            ExternalOrigin::Harness => 0,
64            ExternalOrigin::Operator => 1,
65            ExternalOrigin::Catalog => 2,
66        }
67    }
68
69    /// Parse the on-disk code.
70    fn from_code(b: u8) -> Result<Self> {
71        match b {
72            0 => Ok(ExternalOrigin::Harness),
73            1 => Ok(ExternalOrigin::Operator),
74            2 => Ok(ExternalOrigin::Catalog),
75            other => Err(Error::new(
76                crate::ErrorClass::UnsupportedVersion,
77                format!("external-context origin code {other} is not supported"),
78            )),
79        }
80    }
81
82    /// Parse a CLI spelling.
83    pub fn parse(s: &str) -> Result<Self> {
84        match s {
85            "harness" => Ok(ExternalOrigin::Harness),
86            "operator" => Ok(ExternalOrigin::Operator),
87            "catalog" => Ok(ExternalOrigin::Catalog),
88            other => Err(Error::usage(format!(
89                "--origin expects harness|operator|catalog, got {other:?}"
90            ))),
91        }
92    }
93}
94
95/// The typed external lineage tuple (the C4b `family_id` / `member_id` /
96/// `is_head`, plus the dataset's revision-family id when it differs).
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct ExternalLineage {
99    /// The corpus/dataset family id (the C4b `family_id`), or `None` when the
100    /// dataset declares no family.
101    pub family: Option<String>,
102    /// The member id within the family (the C4b `member_id`).
103    pub member: Option<String>,
104    /// Whether this member is its family's head/current revision.
105    pub head: bool,
106    /// The dataset's revision-family id, when it is distinct from `family`.
107    pub revision_family: Option<String>,
108}
109
110/// A typed, externally-supplied context record, stored beside the field.
111///
112/// Every field is optional except the origin and the (advisory) source label, so
113/// a caller can attach only the facts it actually holds rather than inventing
114/// the rest.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ExternalContext {
117    /// The dataset the document belongs to (e.g. `real100-v1`).
118    pub dataset_id: Option<String>,
119    /// The external lineage tuple.
120    pub lineage: ExternalLineage,
121    /// Where the facts came from.
122    pub origin: ExternalOrigin,
123    /// A human-readable provenance label (e.g. the manifest path or row).
124    pub source: String,
125}
126
127fn put_opt_str(out: &mut Vec<u8>, s: Option<&str>) {
128    match s {
129        Some(v) => {
130            out.push(1);
131            out.extend_from_slice(&(v.len() as u32).to_le_bytes());
132            out.extend_from_slice(v.as_bytes());
133        }
134        None => out.push(0),
135    }
136}
137
138fn get_opt_str(bytes: &[u8], at: &mut usize) -> Result<Option<String>> {
139    let present = *bytes
140        .get(*at)
141        .ok_or_else(|| Error::usage("truncated external-context record"))?;
142    *at += 1;
143    if present == 0 {
144        return Ok(None);
145    }
146    let len_bytes = bytes
147        .get(*at..*at + 4)
148        .ok_or_else(|| Error::usage("truncated external-context record"))?;
149    let len = u32::from_le_bytes(len_bytes.try_into().unwrap()) as usize;
150    *at += 4;
151    let end = at
152        .checked_add(len)
153        .ok_or_else(|| Error::usage("external-context string overflow"))?;
154    let slice = bytes
155        .get(*at..end)
156        .ok_or_else(|| Error::usage("truncated external-context string"))?;
157    let s = core::str::from_utf8(slice)
158        .map_err(|_| Error::usage("external-context string is not UTF-8"))?
159        .to_string();
160    *at = end;
161    Ok(Some(s))
162}
163
164fn opt_json(v: Option<&str>) -> String {
165    match v {
166        Some(s) => format!("\"{}\"", json_escape(s)),
167        None => "null".to_string(),
168    }
169}
170
171impl ExternalContext {
172    /// Canonical, self-describing encoding.
173    pub fn encode_canonical(&self) -> Vec<u8> {
174        let mut out = Vec::with_capacity(128);
175        out.extend_from_slice(EXTERNAL_MAGIC);
176        out.push(EXTERNAL_FORMAT_VERSION);
177        out.push(self.origin.code());
178        put_opt_str(&mut out, self.dataset_id.as_deref());
179        put_opt_str(&mut out, self.lineage.family.as_deref());
180        put_opt_str(&mut out, self.lineage.member.as_deref());
181        out.push(u8::from(self.lineage.head));
182        put_opt_str(&mut out, self.lineage.revision_family.as_deref());
183        put_opt_str(&mut out, Some(self.source.as_str()));
184        out
185    }
186
187    /// Parse a canonical external-context record, failing closed on any
188    /// malformation rather than answering from a partially-understood record.
189    pub fn decode_canonical(bytes: &[u8]) -> Result<ExternalContext> {
190        if bytes.len() < 10 || &bytes[0..8] != EXTERNAL_MAGIC {
191            return Err(Error::unsupported_version(
192                "external-context record: bad magic",
193            ));
194        }
195        if bytes[8] != EXTERNAL_FORMAT_VERSION {
196            return Err(Error::unsupported_version(format!(
197                "external-context record version {} is not supported",
198                bytes[8]
199            )));
200        }
201        let origin = ExternalOrigin::from_code(bytes[9])?;
202        let mut at = 10;
203        let dataset_id = get_opt_str(bytes, &mut at)?;
204        let family = get_opt_str(bytes, &mut at)?;
205        let member = get_opt_str(bytes, &mut at)?;
206        let head = match bytes.get(at) {
207            Some(0) => false,
208            Some(1) => true,
209            Some(_) => return Err(Error::usage("external-context head flag is not boolean")),
210            None => return Err(Error::usage("truncated external-context record")),
211        };
212        at += 1;
213        let revision_family = get_opt_str(bytes, &mut at)?;
214        let source = get_opt_str(bytes, &mut at)?.unwrap_or_default();
215        if at != bytes.len() {
216            return Err(Error::usage("external-context record has trailing bytes"));
217        }
218        Ok(ExternalContext {
219            dataset_id,
220            lineage: ExternalLineage {
221                family,
222                member,
223                head,
224                revision_family,
225            },
226            origin,
227            source,
228        })
229    }
230
231    /// The typed answer body. The lineage tuple is named exactly as the C4b
232    /// contract names it (`family_id` / `member_id` / `is_head`), so an answer
233    /// can be compared to the baseline's tuple without a projection.
234    pub fn answer_json(&self) -> String {
235        format!(
236            concat!(
237                "{{",
238                "\"family_id\":{},",
239                "\"member_id\":{},",
240                "\"is_head\":{},",
241                "\"dataset_id\":{},",
242                "\"revision_family\":{},",
243                "\"origin\":\"{}\",",
244                "\"source\":\"{}\"",
245                "}}"
246            ),
247            opt_json(self.lineage.family.as_deref()),
248            opt_json(self.lineage.member.as_deref()),
249            self.lineage.head,
250            opt_json(self.dataset_id.as_deref()),
251            opt_json(self.lineage.revision_family.as_deref()),
252            self.origin.name(),
253            json_escape(&self.source),
254        )
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn sample() -> ExternalContext {
263        ExternalContext {
264            dataset_id: Some("real100-v1".to_string()),
265            lineage: ExternalLineage {
266                family: Some("rev-nist-fips-140".to_string()),
267                member: Some("nist-pdf-0017".to_string()),
268                head: true,
269                revision_family: None,
270            },
271            origin: ExternalOrigin::Harness,
272            source: "real100-v1/manifest.tsv".to_string(),
273        }
274    }
275
276    #[test]
277    fn round_trips_canonically() {
278        let ctx = sample();
279        let bytes = ctx.encode_canonical();
280        assert_eq!(ExternalContext::decode_canonical(&bytes).unwrap(), ctx);
281        // Deterministic: encoding twice is byte-identical.
282        assert_eq!(ctx.encode_canonical(), bytes);
283    }
284
285    #[test]
286    fn round_trips_with_absent_fields() {
287        let ctx = ExternalContext {
288            dataset_id: None,
289            lineage: ExternalLineage::default(),
290            origin: ExternalOrigin::Operator,
291            source: String::new(),
292        };
293        let bytes = ctx.encode_canonical();
294        assert_eq!(ExternalContext::decode_canonical(&bytes).unwrap(), ctx);
295    }
296
297    #[test]
298    fn rejects_bad_magic_version_and_trailing_bytes() {
299        let mut bytes = sample().encode_canonical();
300        bytes[0] ^= 0xff;
301        assert_eq!(
302            ExternalContext::decode_canonical(&bytes)
303                .unwrap_err()
304                .class(),
305            crate::ErrorClass::UnsupportedVersion
306        );
307        let mut bytes = sample().encode_canonical();
308        bytes[8] = 9;
309        assert_eq!(
310            ExternalContext::decode_canonical(&bytes)
311                .unwrap_err()
312                .class(),
313            crate::ErrorClass::UnsupportedVersion
314        );
315        let mut bytes = sample().encode_canonical();
316        bytes.push(0);
317        assert_eq!(
318            ExternalContext::decode_canonical(&bytes)
319                .unwrap_err()
320                .class(),
321            crate::ErrorClass::Usage
322        );
323        assert!(ExternalContext::decode_canonical(b"short").is_err());
324    }
325
326    #[test]
327    fn origin_parses_typed_and_rejects_unknown() {
328        assert_eq!(ExternalOrigin::parse("harness").unwrap().name(), "harness");
329        assert_eq!(
330            ExternalOrigin::parse("operator").unwrap().name(),
331            "operator"
332        );
333        assert_eq!(ExternalOrigin::parse("catalog").unwrap().name(), "catalog");
334        assert_eq!(
335            ExternalOrigin::parse("guess").unwrap_err().class(),
336            crate::ErrorClass::Usage
337        );
338    }
339
340    #[test]
341    fn answer_json_names_the_c4b_tuple() {
342        let json = sample().answer_json();
343        assert!(
344            json.contains("\"family_id\":\"rev-nist-fips-140\""),
345            "{json}"
346        );
347        assert!(json.contains("\"member_id\":\"nist-pdf-0017\""), "{json}");
348        assert!(json.contains("\"is_head\":true"), "{json}");
349        assert!(json.contains("\"origin\":\"harness\""), "{json}");
350    }
351}