Skip to main content

antares_format/
lib.rs

1//! Open Antares format (`.ant`) — v0.1.
2//!
3//! A self-contained, compressed, streamable container for exchanging a
4//! selection of an Antares world model: schema types, vertices, edges,
5//! observations, evidence (structured AND unstructured together),
6//! beliefs, and vector docs (vectors must ride: the server does not
7//! persist vector indexes).
8//!
9//! ## Container
10//!
11//! One zstd-compressed stream of NDJSON records:
12//!
13//! ```text
14//! {"kind":"manifest", ...}      exactly one, first line
15//! {"kind":"schema_type", "data":{...}}
16//! {"kind":"vertex",      "data":{...}}
17//! {"kind":"edge",        "data":{...}}
18//! {"kind":"observation", "data":{...}}
19//! {"kind":"evidence",    "data":{...}}
20//! {"kind":"belief",      "data":{...}}
21//! {"kind":"vector",      "data":{...}}
22//! {"kind":"trailer", "counts":{...}, "sha256":"..."}   exactly one, last line
23//! ```
24//!
25//! - `data` payloads are the serde JSON of the corresponding
26//!   `ant-types` records (the same property encoding the wire and store
27//!   use).
28//!
29//! ## Property values (v0.3)
30//!
31//! v0.2 carried property values as bare untagged JSON scalars:
32//! `null | bool | number | string | object`. That set cannot express the
33//! SQL types — DECIMAL, DATE, TIME, TIMESTAMP, UUID and BLOB are all
34//! JSON strings, so a reader could not tell a date from a string that
35//! looks like one, and the type was lost on the first round-trip.
36//!
37//! v0.3 keeps those five shapes EXACTLY as they were and adds a tagged
38//! envelope for the typed values:
39//!
40//! ```text
41//! {"$ant":"decimal",   "v":"12345678901234567.89"}  exact, never f64
42//! {"$ant":"date",      "v":"2024-03-01"}
43//! {"$ant":"time",      "v":"12:30:45.123456"}
44//! {"$ant":"timestamp", "v":"2024-03-01T12:00:00+02:00"}   offset kept
45//! {"$ant":"uuid",      "v":"6ba7b810-..."}
46//! {"$ant":"bytes",     "v":"<base64>"}
47//! {"$ant":"int32",     "v":-2147483648}
48//! {"$ant":"int16",     "v":-32768}
49//! {"$ant":"array",     "v":[ <property values> ]}
50//! ```
51//!
52//! An object is an envelope ONLY when it has exactly the two keys
53//! `$ant` and `v` and `$ant` names a known type; anything else is an
54//! ordinary JSON document value. So a producer's own document with a
55//! `$ant` field still round-trips as that document.
56//!
57//! This is a MINOR bump because it is additive under the compatibility
58//! policy below: a v0.2 reader reads a v0.3 file without error, and any
59//! value it already understood is byte-identical. What it loses is the
60//! type — an envelope decodes as a plain JSON object rather than as a
61//! decimal — which is precisely what "the file is ahead of this reader"
62//! is there to signal.
63//! - The trailer's `sha256` is over every preceding UNCOMPRESSED line
64//!   including newlines (manifest through the last record), so
65//!   truncation and tampering are detectable without a second pass.
66//! - File identification: the zstd magic plus a first record with
67//!   `kind == "manifest"` and a supported `format` version.
68//! - Records of unknown `kind` are skipped by readers (forward
69//!   compatibility); additive fields inside `data` follow serde
70//!   defaults.
71//!
72//! ## Selection semantics (writer-side contract)
73//!
74//! A `.ant` file carries whatever selection the exporter chose (whole
75//! scope, a seed set + traversal, a 50-row digest). The manifest
76//! records the selection descriptor verbatim so the consumer knows what
77//! the file claims to contain; **evidence closure** is the exporter's
78//! obligation: every `evidence_id` referenced by an exported
79//! observation/edge should have its evidence record included.
80
81use std::io::{BufRead, BufReader, Read, Write};
82
83use serde::{Deserialize, Serialize};
84use sha2::{Digest, Sha256};
85
86use ant_types::{Belief, Evidence, Observation, SchemaType, Vertex};
87
88pub const FORMAT_VERSION: &str = "0.3";
89pub const EXTENSION: &str = "ant";
90
91/// The `.ant` format version this crate reads and writes, as a
92/// `MAJOR.MINOR` string. Crate version and format version are
93/// formally independent: the crate at any semver may support format
94/// `0.3.x`. Alias of [`FORMAT_VERSION`], named for README/consumer
95/// use.
96pub const SUPPORTED_FORMAT_VERSION: &str = FORMAT_VERSION;
97
98/// Major version this reader implements. See [`FormatVersion`].
99pub const FORMAT_MAJOR: u32 = 0;
100/// Minor version this reader implements.
101pub const FORMAT_MINOR: u32 = 3;
102
103/// A parsed `MAJOR.MINOR` format version.
104///
105/// # Compatibility policy
106///
107/// The version gate used to be string equality, which meant a v0.1
108/// reader refused a v0.2 file even when the only change was an added
109/// record kind it already knew how to skip. That makes every additive
110/// change a breaking one, which defeats the point of having a minor
111/// version at all. The rule is now:
112///
113/// * **Same major, any minor → readable.** Minor bumps are
114///   additive-only by contract: new record kinds, new optional fields.
115///   An older reader skips what it does not recognise (see
116///   [`AntReader::next_record`]) and still verifies the trailer, so it
117///   gets a truthful subset rather than an error.
118/// * **Unknown record kinds are skipped, not fatal** — within the same
119///   major. They are still hashed, so integrity still holds.
120/// * **A different major → rejected, explicitly.** A major bump is
121///   reserved for changes an old reader would silently MISREAD:
122///   changed field meanings, a different container framing, a
123///   removed or repurposed kind. Refusing is the only safe answer.
124/// * **A newer minor is not an error, but it is a known unknown.**
125///   [`AntReader::minor_ahead`] reports it so a caller can warn that
126///   the file may carry records this build did not surface.
127///
128/// Writers must therefore never change the meaning of an existing
129/// field within a major. If a change cannot be expressed additively,
130/// it needs a major bump.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
132pub struct FormatVersion {
133    pub major: u32,
134    pub minor: u32,
135}
136
137impl FormatVersion {
138    /// The version this build reads and writes.
139    pub const CURRENT: FormatVersion = FormatVersion {
140        major: FORMAT_MAJOR,
141        minor: FORMAT_MINOR,
142    };
143
144    /// Parse `"MAJOR.MINOR"`. A bare `"1"` is treated as `1.0`.
145    pub fn parse(s: &str) -> Option<Self> {
146        let mut it = s.trim().splitn(2, '.');
147        let major = it.next()?.parse().ok()?;
148        let minor = match it.next() {
149            None => 0,
150            Some(m) => m.parse().ok()?,
151        };
152        Some(FormatVersion { major, minor })
153    }
154
155    /// Can this build read a file written at `self`?
156    pub fn readable_by_current(&self) -> bool {
157        self.major == FORMAT_MAJOR
158    }
159}
160
161impl std::fmt::Display for FormatVersion {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        write!(f, "{}.{}", self.major, self.minor)
164    }
165}
166
167#[derive(Debug, thiserror::Error)]
168pub enum AntError {
169    #[error("io: {0}")]
170    Io(#[from] std::io::Error),
171    #[error("json on line {line}: {err}")]
172    Json { line: u64, err: String },
173    #[error("not an .ant stream: {0}")]
174    NotAnt(String),
175    /// Refused by the compatibility policy. The message says WHICH rule
176    /// refused it — "version mismatch" alone leaves the reader guessing
177    /// whether to upgrade, re-export, or file a bug.
178    #[error("{0}")]
179    Version(String),
180    #[error("integrity: {0}")]
181    Integrity(String),
182}
183
184/// Edge payload: `ant_types::Edge` is graph-plane; serialize as-is.
185pub use ant_types::Edge;
186
187/// A vector document as exported (mirrors the vector store's doc).
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct VectorRecord {
191    pub record_type: String,
192    pub record_id: String,
193    pub label: String,
194    pub field: String,
195    pub vector: Vec<f32>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub text_preview: Option<String>,
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub evidence_ids: Vec<String>,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct Manifest {
205    /// Always "antares" — belt for the zstd-magic braces.
206    pub format: String,
207    /// Semver of this container layout.
208    pub version: String,
209    pub tenant_id: u64,
210    pub project_id: u64,
211    /// Free-form description of what was selected (whole scope, seed
212    /// query, digest params...). Recorded verbatim, not interpreted.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub selection: Option<serde_json::Value>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
217    /// Producer identifier (server version, tool).
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub producer: Option<String>,
220}
221
222#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct Counts {
225    pub schema_types: u64,
226    pub vertices: u64,
227    pub edges: u64,
228    pub observations: u64,
229    pub evidence: u64,
230    pub beliefs: u64,
231    pub vectors: u64,
232    /// Added in v0.2. `#[serde(default)]` on the struct means a v0.1
233    /// trailer still deserializes with these at zero.
234    #[serde(default)]
235    pub vertex_tombstones: u64,
236    #[serde(default)]
237    pub edge_tombstones: u64,
238}
239
240/// A deletion, carried so a re-import can propagate it.
241///
242/// Import is otherwise additive: without this, deleting a vertex at the
243/// source and re-exporting leaves the deleted record alive at the
244/// destination forever, and the two stores silently diverge.
245///
246/// # Which planes can be tombstoned
247///
248/// Vertices and edges only. Those are the mutable graph planes — a
249/// vertex is a current-state record and deleting one is a normal
250/// operation.
251///
252/// Observations are append-only by design: an observation is a claim
253/// that something was seen at a time, and un-saying it would break the
254/// audit trail the format exists to carry. Evidence and beliefs are
255/// likewise not tombstoned here — evidence is the justification other
256/// records cite (deleting it would strand them, and the closure checker
257/// would rightly call the file broken), and beliefs are derived state
258/// that a re-materialisation regenerates. If retraction is ever needed
259/// on those planes it should be a RETRACTION record carrying a reason,
260/// not a delete — a different feature with different semantics.
261///
262/// # Conflict rules
263///
264/// * Tombstone for an id that does not exist locally → **no-op**, not
265///   an error. Imports are meant to converge from any starting point,
266///   and a file may legitimately carry a deletion the destination never
267///   saw the creation of.
268/// * A live record NEWER than the tombstone → **the record wins, the
269///   delete is ignored**. `deleted_at` is compared against the live
270///   record's last-write time; a stale tombstone must not resurrect a
271///   deletion that a later write already undid. This is last-write-wins
272///   on the same clock the rest of the store already uses.
273/// * Ties (equal timestamps) → the **tombstone wins**, so a delete is
274///   not lost to clock granularity.
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct Tombstone {
278    /// Id of the deleted record, in its own plane's namespace.
279    pub id: String,
280    /// When the deletion happened at the source. Drives the
281    /// last-write-wins comparison above.
282    pub deleted_at: chrono::DateTime<chrono::Utc>,
283    /// Who deleted it, when the source knows. Advisory — carried for
284    /// the audit trail, never used to decide the conflict.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub author: Option<ant_types::AuthorStamp>,
287}
288
289/// One record in the stream.
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291#[serde(tag = "kind", rename_all = "snake_case")]
292pub enum AntRecord {
293    Manifest(Manifest),
294    SchemaType {
295        data: SchemaType,
296    },
297    Vertex {
298        data: Vertex,
299    },
300    Edge {
301        data: Edge,
302    },
303    Observation {
304        data: Observation,
305    },
306    Evidence {
307        data: Evidence,
308    },
309    Belief {
310        data: Belief,
311    },
312    Vector {
313        data: VectorRecord,
314    },
315    /// Deletion of a vertex. Cascades to its edges on import, exactly
316    /// as a live delete does.
317    VertexTombstone {
318        data: Tombstone,
319    },
320    /// Deletion of an edge.
321    EdgeTombstone {
322        data: Tombstone,
323    },
324    Trailer {
325        counts: Counts,
326        sha256: String,
327    },
328}
329
330/// Streaming `.ant` writer: records in, zstd-framed NDJSON out.
331/// Call [`AntWriter::finish`] to emit the trailer and flush.
332pub struct AntWriter<W: Write> {
333    enc: zstd::stream::write::Encoder<'static, W>,
334    hasher: Sha256,
335    counts: Counts,
336    finished: bool,
337}
338
339impl<W: Write> AntWriter<W> {
340    /// Compression level 0 = zstd default (currently 3). Levels up to
341    /// 19 trade speed for size; text-heavy evidence compresses 5-10x
342    /// at the default already.
343    pub fn new(out: W, manifest: Manifest, level: i32) -> Result<Self, AntError> {
344        let enc = zstd::stream::write::Encoder::new(out, level)?;
345        let mut w = Self {
346            enc,
347            hasher: Sha256::new(),
348            counts: Counts::default(),
349            finished: false,
350        };
351        w.write_record(&AntRecord::Manifest(manifest))?;
352        Ok(w)
353    }
354
355    fn write_record(&mut self, rec: &AntRecord) -> Result<(), AntError> {
356        let mut line = serde_json::to_string(rec).map_err(|e| AntError::Json {
357            line: 0,
358            err: e.to_string(),
359        })?;
360        line.push('\n');
361        self.hasher.update(line.as_bytes());
362        self.enc.write_all(line.as_bytes())?;
363        Ok(())
364    }
365
366    pub fn write(&mut self, rec: AntRecord) -> Result<(), AntError> {
367        match &rec {
368            AntRecord::Manifest(_) => {
369                return Err(AntError::NotAnt("manifest may only appear first".into()))
370            }
371            AntRecord::Trailer { .. } => {
372                return Err(AntError::NotAnt("trailer is written by finish()".into()))
373            }
374            AntRecord::SchemaType { .. } => self.counts.schema_types += 1,
375            AntRecord::Vertex { .. } => self.counts.vertices += 1,
376            AntRecord::Edge { .. } => self.counts.edges += 1,
377            AntRecord::Observation { .. } => self.counts.observations += 1,
378            AntRecord::Evidence { .. } => self.counts.evidence += 1,
379            AntRecord::Belief { .. } => self.counts.beliefs += 1,
380            AntRecord::Vector { .. } => self.counts.vectors += 1,
381            AntRecord::VertexTombstone { .. } => self.counts.vertex_tombstones += 1,
382            AntRecord::EdgeTombstone { .. } => self.counts.edge_tombstones += 1,
383        }
384        self.write_record(&rec)
385    }
386
387    /// Records written so far (excluding manifest/trailer).
388    pub fn counts(&self) -> &Counts {
389        &self.counts
390    }
391
392    /// Emit the trailer (counts + sha256 of everything before it) and
393    /// finish the zstd frame. Returns the inner writer.
394    pub fn finish(mut self) -> Result<W, AntError> {
395        let digest = format!("{:x}", self.hasher.clone().finalize());
396        let trailer = AntRecord::Trailer {
397            counts: self.counts.clone(),
398            sha256: digest,
399        };
400        let mut line = serde_json::to_string(&trailer).map_err(|e| AntError::Json {
401            line: 0,
402            err: e.to_string(),
403        })?;
404        line.push('\n');
405        self.enc.write_all(line.as_bytes())?;
406        self.finished = true;
407        Ok(self.enc.finish()?)
408    }
409}
410
411/// Streaming `.ant` reader. Yields records after validating the
412/// manifest; [`AntReader::finish`] (or reading through the trailer)
413/// verifies counts + hash.
414pub struct AntReader<R: Read> {
415    lines: std::io::Lines<BufReader<zstd::stream::read::Decoder<'static, BufReader<R>>>>,
416    pub manifest: Manifest,
417    hasher: Sha256,
418    counts: Counts,
419    line_no: u64,
420    /// Set once the trailer was seen and verified.
421    pub verified: bool,
422    /// Version parsed from the manifest. Same major as this build, or
423    /// `new` would have refused the file.
424    pub version: FormatVersion,
425    /// The file was written by a NEWER minor than this build. Readable
426    /// by policy (minors are additive-only), but it may carry record
427    /// kinds this build skipped — a caller that reports completeness to
428    /// a user should say so.
429    pub minor_ahead: bool,
430}
431
432impl<R: Read> AntReader<R> {
433    pub fn new(input: R) -> Result<Self, AntError> {
434        let dec = zstd::stream::read::Decoder::new(input)
435            .map_err(|e| AntError::NotAnt(format!("zstd: {e}")))?;
436        let mut lines = BufReader::new(dec).lines();
437        let first = lines
438            .next()
439            .ok_or_else(|| AntError::NotAnt("empty stream".into()))??;
440        let rec: AntRecord = serde_json::from_str(&first).map_err(|e| AntError::Json {
441            line: 1,
442            err: e.to_string(),
443        })?;
444        let AntRecord::Manifest(manifest) = rec else {
445            return Err(AntError::NotAnt("first record is not a manifest".into()));
446        };
447        if manifest.format != "antares" {
448            return Err(AntError::NotAnt(format!("format `{}`", manifest.format)));
449        }
450        // Compatibility policy (see `FormatVersion`): same major reads,
451        // different major is refused with the reason spelled out.
452        let file_version = FormatVersion::parse(&manifest.version).ok_or_else(|| {
453            AntError::Version(format!(
454                "manifest version `{}` is not MAJOR.MINOR; this reader implements {}",
455                manifest.version,
456                FormatVersion::CURRENT
457            ))
458        })?;
459        if !file_version.readable_by_current() {
460            return Err(AntError::Version(format!(
461                "file is format v{file_version}, this reader implements v{}. \
462                 Major versions are not compatible: a major bump means field \
463                 meanings or the container framing changed, so reading it here \
464                 would silently misinterpret records. Upgrade the reader to a \
465                 v{}.x build, or re-export the file at v{}.",
466                FormatVersion::CURRENT,
467                file_version.major,
468                FORMAT_MAJOR,
469            )));
470        }
471        let minor_ahead = file_version.minor > FORMAT_MINOR;
472        let mut hasher = Sha256::new();
473        hasher.update(first.as_bytes());
474        hasher.update(b"\n");
475        Ok(Self {
476            lines,
477            manifest,
478            hasher,
479            counts: Counts::default(),
480            line_no: 1,
481            verified: false,
482            version: file_version,
483            minor_ahead,
484        })
485    }
486
487    /// Next data record; `None` after a VERIFIED trailer. Unknown-kind
488    /// lines are skipped (forward compatibility) but still hashed.
489    pub fn next_record(&mut self) -> Result<Option<AntRecord>, AntError> {
490        loop {
491            let Some(line) = self.lines.next() else {
492                return Err(AntError::Integrity(
493                    "stream ended without a trailer (truncated?)".into(),
494                ));
495            };
496            let line = line?;
497            self.line_no += 1;
498            // Trailer hash covers everything BEFORE the trailer line.
499            let pre_trailer_digest = format!("{:x}", self.hasher.clone().finalize());
500            self.hasher.update(line.as_bytes());
501            self.hasher.update(b"\n");
502            match serde_json::from_str::<AntRecord>(&line) {
503                Ok(AntRecord::Manifest(_)) => {
504                    return Err(AntError::NotAnt("duplicate manifest".into()))
505                }
506                Ok(AntRecord::Trailer { counts, sha256 }) => {
507                    if sha256 != pre_trailer_digest {
508                        return Err(AntError::Integrity(format!(
509                            "sha256 mismatch: trailer {sha256}, computed {pre_trailer_digest}"
510                        )));
511                    }
512                    if counts != self.counts {
513                        return Err(AntError::Integrity(format!(
514                            "counts mismatch: trailer {counts:?}, read {:?}",
515                            self.counts
516                        )));
517                    }
518                    self.verified = true;
519                    return Ok(None);
520                }
521                Ok(rec) => {
522                    match &rec {
523                        AntRecord::SchemaType { .. } => self.counts.schema_types += 1,
524                        AntRecord::Vertex { .. } => self.counts.vertices += 1,
525                        AntRecord::Edge { .. } => self.counts.edges += 1,
526                        AntRecord::Observation { .. } => self.counts.observations += 1,
527                        AntRecord::Evidence { .. } => self.counts.evidence += 1,
528                        AntRecord::Belief { .. } => self.counts.beliefs += 1,
529                        AntRecord::Vector { .. } => self.counts.vectors += 1,
530                        AntRecord::VertexTombstone { .. } => self.counts.vertex_tombstones += 1,
531                        AntRecord::EdgeTombstone { .. } => self.counts.edge_tombstones += 1,
532                        AntRecord::Manifest(_) | AntRecord::Trailer { .. } => unreachable!(),
533                    }
534                    return Ok(Some(rec));
535                }
536                Err(e) => {
537                    // Unknown kind => forward-compat skip. Anything
538                    // else malformed is a hard error.
539                    let probe: Result<serde_json::Value, _> = serde_json::from_str(&line);
540                    match probe {
541                        Ok(v) if v.get("kind").and_then(|k| k.as_str()).is_some() => continue,
542                        _ => {
543                            return Err(AntError::Json {
544                                line: self.line_no,
545                                err: e.to_string(),
546                            })
547                        }
548                    }
549                }
550            }
551        }
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use ant_types::{ObservationId, ProjectId, TenantId, TypeName, VertexId};
559    use std::collections::BTreeMap;
560
561    fn manifest() -> Manifest {
562        Manifest {
563            format: "antares".into(),
564            version: FORMAT_VERSION.into(),
565            tenant_id: 1,
566            project_id: 1,
567            selection: Some(serde_json::json!({"kind": "whole_scope"})),
568            created_at: None,
569            producer: Some("antares-format tests".into()),
570        }
571    }
572
573    fn sample_vertex() -> Vertex {
574        let mut props = BTreeMap::new();
575        props.insert("amount".into(), ant_types::PropertyValue::Long(42));
576        props.insert(
577            "doc".into(),
578            ant_types::PropertyValue::Json(serde_json::json!({"nested": [1, 2]})),
579        );
580        Vertex {
581            id: VertexId("d1".into()),
582            name: "Deal".into(),
583            label: TypeName("Antares.Deal".into()),
584            properties: props,
585        }
586    }
587
588    fn sample_obs() -> Observation {
589        Observation {
590            id: ObservationId("o1".into()),
591            tenant_id: TenantId(1),
592            project_id: ProjectId(1),
593            source_event_id: None,
594            source_uri: None,
595            subject_id: Some(VertexId("d1".into())),
596            predicate: "stage_change".into(),
597            object_id: None,
598            object_value: Some(serde_json::json!("proposal")),
599            observed_at: "2026-08-09T00:00:00Z".parse().unwrap(),
600            extracted_at: "2026-08-09T00:00:01Z".parse().unwrap(),
601            confidence: Some(0.9),
602            evidence_ids: vec![],
603            extractor_version: Some("test/1".into()),
604            metadata: serde_json::Value::Null,
605            author: None,
606        }
607    }
608
609    fn write_sample() -> Vec<u8> {
610        let mut w = AntWriter::new(Vec::new(), manifest(), 0).unwrap();
611        w.write(AntRecord::Vertex {
612            data: sample_vertex(),
613        })
614        .unwrap();
615        w.write(AntRecord::Observation { data: sample_obs() })
616            .unwrap();
617        w.write(AntRecord::Vector {
618            data: VectorRecord {
619                record_type: "evidence".into(),
620                record_id: "e1".into(),
621                label: "Antares.Chunk".into(),
622                field: "content".into(),
623                vector: vec![0.1, 0.2, 0.3],
624                text_preview: None,
625                evidence_ids: vec![],
626            },
627        })
628        .unwrap();
629        w.finish().unwrap()
630    }
631
632    #[test]
633    fn round_trip_verifies_and_preserves_records() {
634        let bytes = write_sample();
635        let mut r = AntReader::new(&bytes[..]).unwrap();
636        assert_eq!(r.manifest.project_id, 1);
637        let mut got = Vec::new();
638        while let Some(rec) = r.next_record().unwrap() {
639            got.push(rec);
640        }
641        assert!(r.verified, "trailer hash + counts verified");
642        assert_eq!(got.len(), 3);
643        assert_eq!(
644            got[0],
645            AntRecord::Vertex {
646                data: sample_vertex()
647            },
648            "typed properties (incl. Json variant) survive the round trip"
649        );
650        assert_eq!(got[1], AntRecord::Observation { data: sample_obs() });
651    }
652
653    #[test]
654    fn tampering_and_truncation_are_detected() {
655        let bytes = write_sample();
656        // Tamper: flip a byte inside the compressed payload -> zstd or
657        // hash layer must reject it.
658        let mut bad = bytes.clone();
659        let mid = bad.len() / 2;
660        bad[mid] ^= 0xff;
661        let corrupted = (|| -> Result<(), AntError> {
662            let mut r = AntReader::new(&bad[..])?;
663            while r.next_record()?.is_some() {}
664            Ok(())
665        })()
666        .is_err();
667        assert!(corrupted, "bit-flip must not verify");
668
669        // Truncate: drop the tail -> must error, never silently succeed.
670        let cut = &bytes[..bytes.len() - 8];
671        let truncated = (|| -> Result<(), AntError> {
672            let mut r = AntReader::new(cut)?;
673            while r.next_record()?.is_some() {}
674            Ok(())
675        })()
676        .is_err();
677        assert!(truncated, "truncation must surface");
678    }
679
680    #[test]
681    fn unknown_kinds_are_skipped_for_forward_compat() {
682        // Hand-build a stream with an unknown record kind between valid
683        // ones, with a correct trailer hash.
684        use sha2::{Digest, Sha256};
685        let m = serde_json::to_string(&AntRecord::Manifest(manifest())).unwrap();
686        let v = serde_json::to_string(&AntRecord::Vertex {
687            data: sample_vertex(),
688        })
689        .unwrap();
690        let unknown = r#"{"kind":"hologram","data":{"future":true}}"#;
691        let mut hasher = Sha256::new();
692        for line in [&m, &v, &unknown.to_string()] {
693            hasher.update(line.as_bytes());
694            hasher.update(b"\n");
695        }
696        let trailer = AntRecord::Trailer {
697            counts: Counts {
698                vertices: 1,
699                ..Default::default()
700            },
701            sha256: format!("{:x}", hasher.finalize()),
702        };
703        let t = serde_json::to_string(&trailer).unwrap();
704        let raw = format!("{m}\n{v}\n{unknown}\n{t}\n");
705        let compressed = zstd::stream::encode_all(raw.as_bytes(), 0).unwrap();
706
707        let mut r = AntReader::new(&compressed[..]).unwrap();
708        let mut kinds = Vec::new();
709        while let Some(rec) = r.next_record().unwrap() {
710            kinds.push(matches!(rec, AntRecord::Vertex { .. }));
711        }
712        assert!(r.verified);
713        assert_eq!(kinds, vec![true], "unknown kind skipped, vertex kept");
714    }
715
716    #[test]
717    fn wrong_version_and_non_ant_input_rejected() {
718        let mut bad_manifest = manifest();
719        bad_manifest.version = "9.9".into();
720        let w = AntWriter::new(Vec::new(), bad_manifest, 0).unwrap();
721        let bytes = w.finish().unwrap();
722        assert!(matches!(
723            AntReader::new(&bytes[..]),
724            Err(AntError::Version(_))
725        ));
726        assert!(AntReader::new(&b"not zstd at all"[..]).is_err());
727    }
728}