Skip to main content

antares_format/
lib.rs

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