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}