The version record model¶
Architecture
Normative spec
This page documents .ai/specs/core-engine/2026-07-02-version-record-model.md ("Spec: Version Record Model & Value Serialization"), Status: Draft. It is implemented by core-engine/src/model/mod.rs (structs, Value, codec) and core-engine/src/temporal/node_reads.rs (the bitemporal winner rule), with byte-exact coverage in core-engine/tests/model_golden.rs and cross-checked against a brute-force reference in core-engine/tests/global_scan_diff.rs.
NodeVersion and EdgeVersion are the values stored in the node_versions and edge_versions column families: fixed structs, serialized with rmp-serde behind a single version byte, that give every fact three clocks, mandatory provenance, and an append-only lifecycle.
Overview & purpose¶
Keys give the storage engine ordering (see Storage engine & key layout); these records give it truth. Every version of every node and edge is one NodeVersion or EdgeVersion value, and the record model pins four things that the rest of the engine depends on:
- Temporal fields with a defined shape, so end-boundary validation (
contains(t)) has something concrete to validate. - A canonical tombstone representation: a tombstone is an ordinary version with
tombstone: true, never a special key form. - A home for provenance: every version carries a mandatory
Provenancestruct, which claim verification and citation rendering read directly. - A serialization format that cannot silently corrupt old data: a leading value-version byte plus additive-only field evolution.
All four had to be settled before the first write path (PR-014) could land, because the write path, the read path, and the tombstone cascade all construct and interpret the same bytes.
Design¶
Why three time fields, not one. A single "last write wins" value can only answer "what is true now." Telha's product promise, per the tritemporal model, is to answer what was true, as we knew it, on any date. That needs valid time (when it was true in the world) and transaction time (when Telha recorded or believed it) as two independent axes, plus a third scalar, source-record time, that pins each fact to the date its source document asserts so citations point at the document as written. The engine calls the combination of the two interval axes "bitemporal" only when talking about the winner rule; the record itself is tritemporal (three stamped fields).
Why append-only. A correction or deletion never mutates or removes a row. update_node writes a new version and, in the same WriteBatch, re-puts the prior version with its tx_time.end capped at the new transaction time (core-engine/src/temporal/node_ops.rs, write_next_version). The historical row's key never changes and no row is ever deleted. This is what makes backward snapshots byte-identical by construction (the tombstone-cascade module calls this invariant I2) and what makes the winner for any (validTime, txTime) pair deterministic and stable: it cannot change retroactively when new data arrives, because the old bytes are still there, unaltered, at their original coordinates.
Why half-open intervals. Interval { start, end } means [start, end): start is inclusive, end is exclusive, and end == u64::MAX (OPEN_END) means "still open." Half-open composes cleanly with end-boundary validation (t >= start && t < end) and avoids the ±1µs fencepost bugs that closed intervals invite when one version's end must line up exactly with the next version's start. The spec's alternatives-considered section (§9) records closed intervals as rejected for exactly this reason.
Data model¶
NodeVersion¶
The value stored in the node_versions CF (core-engine/src/model/mod.rs):
pub struct NodeVersion {
pub logical_id: Uuid, // duplicated from the key
pub labels: Vec<String>, // sorted + deduped at write time
pub properties: BTreeMap<String, Value>, // BTreeMap: deterministic golden files
pub valid_time: Interval, // real-world truth window
pub tx_time: Interval, // record/knowledge window
pub source_record_time: Option<Ts>, // Ts = u64, µs since epoch
pub tombstone: bool, // ordinary version, never a special key
pub provenance: Provenance, // mandatory
}
EdgeVersion¶
The value stored in the edge_versions CF:
pub struct EdgeVersion {
pub edge_logical_id: Uuid,
pub from: Uuid,
pub to: Uuid,
pub edge_type: String, // key holds the xxh3 hash; value holds the string
pub properties: BTreeMap<String, Value>,
pub valid_time: Interval,
pub tx_time: Interval,
pub source_record_time: Option<Ts>,
pub tombstone: bool,
pub cascade_of: Option<Uuid>, // node-tombstone id, when created by a cascade
pub provenance: Provenance,
}
edge_type is stored twice on purpose: the key carries an 8-byte xxh3 hash for compact ordering, and the value carries the string for forensics and export. verify_edge_type_hash recomputes EdgeTypeHash::of(&edge.edge_type) and compares it against the hash embedded in the key; a mismatch is rejected on write and surfaces as DecodeError::HashMismatch on any forensic read that checks it.
Interval¶
pub struct Interval {
pub start: Ts, // inclusive
pub end: Ts, // exclusive; OPEN_END (u64::MAX) = still current
}
| Method | Behavior |
|---|---|
Interval::new(start, end) | Rejects start >= end with ModelError::InvalidInterval |
Interval::open(start) | Builds { start, end: OPEN_END } |
contains(t) | t >= self.start && t < self.end |
is_open() | self.end == OPEN_END |
The invariant start < end is enforced at construction, not just documented: there is no way to build an Interval value that violates it through the public constructors.
The Value enum¶
Nine variants, matching the spec's Value = Null|Bool|Int(i64)|Float(f64)|Str|Bytes|Array|Object|Datetime(u64 µs) exactly:
pub enum Value {
Null,
Bool(bool),
Int(i64),
Float(f64),
Str(String),
Bytes(Vec<u8>),
Array(Vec<Value>),
Object(BTreeMap<String, Value>),
Datetime(u64), // microseconds since Unix epoch
}
Datetime is kept distinct from Int (rather than folding dates into integers) specifically so schema inference can lattice it as its own type. Object uses a BTreeMap, not a HashMap, for the same reason properties does: deterministic key ordering makes the MessagePack encoding reproducible, which is what lets the golden-file tests pin exact bytes.
No Decimal type in v1
Spec §13 (Open Questions, OQ-1) flags that properties has no Decimal variant for financial data. The stance recorded there: string-encode decimals at the app layer for v1, and revisit only if a schema-inference lattice extension creates demand. The Value enum in the code matches this: there is no Decimal or fixed-point variant.
Provenance & spans¶
pub struct Provenance {
pub origin: Origin, // Api | Ingestion{source_id, job_id} | System | Cascade
pub actor: Option<String>,
pub request_id: Option<Uuid>,
pub spans: Vec<SpanRef>, // #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clarification: Option<Uuid>, // #[serde(default, skip_serializing_if = "Option::is_none")]
}
pub struct SpanRef {
pub source_id: Uuid,
pub source_version_tx: u64, // pins the canonical text of the source version
pub start: u64, // byte offset, inclusive
pub end: u64, // byte offset, exclusive
}
Provenance is mandatory on every version, node or edge. origin is one of four variants:
| Variant | Meaning |
|---|---|
Api | Direct REST/gRPC write |
Ingestion { source_id, job_id } | Produced by an ingestion worker |
System | Engine-internal write (e.g. schema versioning) |
Cascade | Created by a tombstone cascade |
spans and clarification are both later, additive fields (see As-built notes) and both use skip_serializing_if so a Provenance value with neither populated serializes to exactly the same bytes as it did before either field existed.
Wire format¶
Both structs serialize the same way, via a private codec::encode / codec::decode pair in core-engine/src/model/mod.rs:
pub const VALUE_VERSION: u8 = 0x01;
pub mod codec {
pub fn encode_node(record: &NodeVersion) -> Vec<u8>; // -> [0x01][msgpack]
pub fn decode_node(bytes: &[u8]) -> Result<NodeVersion, DecodeError>;
pub fn encode_edge(record: &EdgeVersion) -> Vec<u8>;
pub fn decode_edge(bytes: &[u8]) -> Result<EdgeVersion, DecodeError>;
}
Encoding uses rmp_serde::to_vec_named, i.e. map-keyed, not positional/tuple encoding. That choice is what makes additive evolution safe: a decoder built against an older struct definition simply ignores map keys it doesn't recognize, and a decoder built against a newer struct definition reads an older payload's missing keys as absent (defaulted via serde attributes where the field supports it). Decode errors are typed and the codec never panics:
DecodeError variant | Cause |
|---|---|
UnknownVersionByte(u8) | The leading byte isn't one this build knows how to read |
Malformed(String) | Empty buffer, or the MessagePack payload doesn't match the record shape |
HashMismatch { expected, got } | An edge's edge_type string doesn't hash to the key's EdgeTypeHash |
Removing or retyping a field requires a new version byte
Unknown map keys are ignored on read, so adding a field is always safe without a version bump. Removing or retyping a field is not: the spec (§3, §11) requires bumping VALUE_VERSION and shipping a dual reader that dispatches on the leading byte before any such change ships. Old bytes must remain readable forever; background rewrite to the new format is optional, never required.
Golden fixtures¶
core-engine/tests/model_golden.rs commits byte-exact fixtures under core-engine/tests/golden/:
| Fixture | Proves |
|---|---|
node_version_v1.bin | A NodeVersion covering every Value variant (including nested Object/Array/Bytes/Datetime/Null) encodes to these exact, committed bytes |
edge_version_v1.bin | An EdgeVersion, including a tombstoned edge with cascade_of set, encodes to these exact bytes |
node_version_bytes_are_stable and edge_version_bytes_are_stable both re-decode the committed bytes and assert round-trip equality, so the fixture proves two things at once: the exact bytes haven't drifted, and those bytes are still readable. encoding_is_deterministic_across_calls asserts encoding the same value twice produces identical bytes (load-bearing given BTreeMap's ordering guarantee). Regenerating a fixture after an intentional format change is a deliberate, named action:
The model module's own unit tests add property-based coverage on top of the golden files: node_roundtrip_property (in core-engine/src/model/mod.rs) round-trips randomized records (including deeply nested Value trees) via proptest, and unknown_fields_ignored_on_read constructs a payload with a synthetic extra field (added_in_some_future_release) via #[serde(flatten)] and asserts it still decodes to the known struct, exercising the additive- evolution guarantee directly rather than by inspection.
Algorithms & invariants¶
The bitemporal winner rule¶
The winner rule is implemented once, in NodeReads::get_as_of (core-engine/src/temporal/node_reads.rs), and re-derived independently by the global temporal scan's validate step (core-engine/src/query/global_scan.rs) so that paginated scan results agree with point reads. Both read every candidate version for the entity and keep the one satisfying the truth table:
let mut winner: Option<NodeVersion> = None;
for row in self.engine.scan_prefix(cf::NODE_VERSIONS, &range)? {
let version = codec::decode_node(&value)?;
if !(version.valid_time.contains(valid_t) && version.tx_time.contains(tx_t)) {
continue; // does not cover this coordinate at all
}
// Latest recorded assertion about this valid instant wins.
let newer = winner.as_ref().is_none_or(|w| version.tx_time.start > w.tx_time.start);
if newer {
winner = Some(version);
}
}
// A tombstone winning means the node is deleted at this coordinate.
Ok(winner.filter(|v| !v.tombstone))
Spelled out precisely:
- Filter: a version is a candidate only if
valid_time.contains(valid_t)andtx_time.contains(tx_t)both hold. A version whose valid window covers the coordinate but whose transaction window doesn't (or vice versa) is excluded entirely, not treated as a weaker match. - Compare: among candidates, the one with the strictly greatest
tx_time.startwins. This is a total order in practice because transaction-time starts are minted by a single monotonicTxClockper write and writes to one logical id are serialized (write_lockinNodeOps), so no two candidate versions for the same entity can share atx_time.start. - Tombstone check: the comparison above runs before the tombstone check. If the winning candidate has
tombstone == true, the read returnsNone, i.e. the entity is absent at that coordinate, even though a row technically won the comparison. A tombstone is not filtered out earlier; it competes for the win like any other version and, if it wins, resolves to absence.
Winner rule invariants
- Candidacy requires both interval containments; passing only one is not a partial match.
- The winner is the candidate with the highest
tx_time.start, never the highestvalid_time.startand never insertion order. - A winning tombstone reads as absent, not as an error and not as "no data" distinct from "never existed", callers cannot tell a tombstoned entity from one that never had a version at this coordinate through
get_as_ofalone;get_historyexists for that. - The rule is coordinate-local: it is re-evaluated independently for every
(valid_t, tx_t)pair queried. There is no cached "current winner" state;get_currentis defined asget_as_of(now, now).
Append-only correction semantics¶
NodeOps::write_next_version (core-engine/src/temporal/node_ops.rs) is the only path that appends a version to an existing entity, and it never mutates history in place:
- Read the current open version (
tx_time.is_open()), call itprior. - Build the new version, inheriting
prior.valid_timeunless the caller supplies a different one (this is how retroactive corrections change the valid window). - In one
WriteBatch: re-putpriorat its original key, withtx_time.endcapped at the new transaction time, and put the new version at a new key withtx_timeopen.
Both puts land in the same atomic batch, so a reader never observes a state with two open versions, or zero. The n_updates_produce_n_plus_one_immutable_versions test (core-engine/src/temporal/node_ops.rs) pins the resulting shape: N updates leave N+1 stored rows, exactly one of them open, and sorting all versions by tx_time.start shows each closed version's tx_time.end equal to the next version's tx_time.start, i.e. the closure chain has no gaps and no overlaps.
Tombstones follow the same append-only rule: core-engine/src/temporal/cascade.rs describes the tombstone cascade as "puts only... nothing is ever deleted and no existing row is ever rewritten." A tombstone is written as a new version with tombstone: true; it does not remove or alter the versions that came before it.
Version selection across the valid x tx plane¶
flowchart TB
A["Scan all versions of entity for (validTime, txTime) range"] --> B{"valid_time.contains(vT)\nAND\ntx_time.contains(tT)?"}
B -- no --> A
B -- yes --> C["Candidate"]
C --> D{"Highest tx_time.start\nseen so far?"}
D -- no --> A
D -- yes --> E["New winner"]
E --> A
A -- "scan exhausted" --> F{"Winner exists?"}
F -- no --> G["Ok(None), no version covers (vT, tT)"]
F -- yes --> H{"winner.tombstone?"}
H -- yes --> I["Ok(None), reads as absent"]
H -- no --> J["Ok(Some(winner))"] A correction written, then queried at two tx_time coordinates¶
This mirrors bitemporal_truth_table in core-engine/src/temporal/node_reads.rs, which scripts exactly this sequence and asserts every branch below:
sequenceDiagram
autonumber
participant C as Client
participant N as NodeOps
participant R as NodeReads
C->>N: create_node(name="A", validTime=[100, ∞))
N-->>C: v1, tx_time=[t1, ∞)
C->>N: update_node(name="B"), correction, same valid window
N->>N: close v1: tx_time=[t1, t2)
N-->>C: v2, tx_time=[t2, ∞)
C->>R: get_as_of(validTime=150, txTime=t1)
R-->>C: "A" (only v1 is open at t1)
C->>R: get_as_of(validTime=150, txTime=t2)
R-->>C: B (v2 now covers t2, v1's window ends at t2, exclusive)
Note over C,R: Same valid-time question, two tx-time coordinates,<br/>two different (both correct) answers. Configuration¶
There is none. Spec §8 states plainly: "Record shape is not configurable; changes require a new value-version byte plus a spec amendment and dual- reader support." Nothing in core-engine/src/model/mod.rs reads from Config; the only "knob" is the VALUE_VERSION constant itself, which is a compile-time decision, not a runtime one.
As-built notes¶
Spec §14 (Changelog) records one deviation from the v0.1 draft, already reflected in the current code:
- v0.2 (2026-07-05):
Provenancegainsclarification: Option<Uuid>(PR-060, per the temporal-clarification spec §5). It is set on a pending best-guess version at ingestion time, and on the corrected version that a bound answer later produces. The field follows the exact pattern PR-032 established forspans: additive,#[serde(default, skip_serializing_if = "Option::is_none")], so stored bytes and golden fixtures are byte-stable when the field is absent, and noVALUE_VERSIONbump was needed. Bothspans: Vec<SpanRef>andclarification: Option<Uuid>are visible onProvenancein the current code with exactly these serde attributes.
On testing approach: the spec's success metrics (§12) call for "golden-file suite green," a "property test decode(encode(x))==x over randomized records," a "HashMismatch rejection test," and "a v0x02 dual-reader dry-run test proving the evolution mechanism before it is ever needed." The first three are implemented and passing as described above (model_golden.rs, node_roundtrip_property, edge_roundtrip_and_hash_check).
Could not verify against the code: the fourth metric, a v0x02 dual-reader dry-run, has no corresponding test in core-engine/src/model/mod.rs or core-engine/tests/model_golden.rs as of this writing; VALUE_VERSION is still 0x01 and codec::decode only ever dispatches on that single value. This isn't a contradiction (the spec frames it as proving the mechanism "before it is ever needed," and a second version byte isn't needed yet) but it means the dual-reader path is currently unexercised by any test in the repository. The spec's own Status line is also worth noting as-is: it reads Draft, not Ratified or Final, even though the model it describes is fully implemented and in active use by the write and read paths.