Data Model
The ontology is the schema. You do not write CREATE TABLE. You declare ObjectType, EventType, LinkType, and ActionType. The storage layout, planner, SDKs, and protocol compatibility doors all derive from those declarations — there is no separate schema definition per surface.
Identifier types
Every entity, link, and event in the store has a stable, typed identifier. Four wrapper newtypes exist at the core level:
| Type | Underlying | Minted by | Purpose |
|---|---|---|---|
ObjectId | 16 bytes | Derived (byte-borrow of a RowId) | Stable identifier for an instance of an ObjectType |
LinkId | 16 bytes | Currently unminted in production (type defined for future wired edges) | Stable identifier for a typed, directional edge (LinkType instance) |
EventId | 16 bytes | Currently unminted in production (type defined for the causal-event graph) | Stable identifier for a time-anchored occurrence (EventType instance) |
RowId | 16 bytes | alloc_row_id() (store/mod.rs:4300) — 8-byte big-endian AtomicU64 counter, zero-padded | Internal identifier for one materialised storage row (one bi-temporal version) |
These are distinct Rust newtypes — ObjectId cannot be passed where a LinkId is expected. Note the implementation reality: the wire format renders as a 36-char UUID-style string (8-4-4-4-12 hex) for tooling compatibility, but production RowIds are server-minted monotonic counters (alloc_row_id, store/mod.rs:4300), not random UUID v4 values — version/variant nibbles are deliberately left zero so the counter stays injective (a v4 mask would collide every 4096 rows). ObjectId is a non-copy byte-borrow of the underlying RowId, so it shares the counter layout. Implications users should know:
- Sequential, not random —
RowIdincreases monotonically with insert order (counter big-endian in bytes 0–7). - Server-minted, not client-generatable — clients receive IDs from the server; supplying your own is not supported on the INSERT path.
- Stable within a process lifetime and across disk-spill — cold-spill rows encode
row.id(spill_encode_row,store/mod.rs:6720) so they survive restart. - Re-minted on WAL replay —
wal_encode_record(store/mod.rs:6578) does not serialiserow_id, so a row reconstructed purely from WAL during crash recovery gets a fresh counter value. References to a specificRowIdfrom outside the store (e.g. in a downstream system) are not guaranteed to match post-recovery.
The original type-level doc-comment in crates/relata-core/src/id.rs calls these "UUID-v4 wrappers"; that description is overdue for a correction (tracked separately) — the implementation has been counter-based since the v4-mask collision fix at store/mod.rs:4304-4309.
The three ontology primitives
| Primitive | Purpose | Examples |
|---|---|---|
| ObjectType | A thing with stable identity. Survives property changes via bi-temporal rows. | Profile, Subscriber, Account, Device |
| EventType | A time-anchored occurrence. Bi-temporal by construction. | CallEvent, Post, Transaction, LoginAttempt |
| LinkType | A typed, directional edge between two instances. The substrate of the graph plane. | CALLED, MENTIONS, OWNS, TRANSFERRED_TO |
Declare them in TOML or Rust. The ontology is schema-as-code, versioned in git, branched with a HEAD pointer — schema changes go through the same review process as application code.
// Example ObjectType declaration
ObjectType:
name = "Profile"
[properties]
handle = { type = "String", identity = "Handle" }
msisdn = { type = "Identity", canonical = "Msisdn" }
bio_text = { type = "String", indexed = "bm25" }
avatar = { type = "BlobRef" }
verified = { type = "Bool" }
location = { type = "GeoPoint" }Schema features
State-machine constraints on PropertySpec — a property can declare a finite state machine over its allowed values. Transitions that violate the declared machine are rejected at write time by the planner.
Computed columns — a property can be declared as a deterministic expression over other properties of the same type. The value is computed at read time and cached; writes do not store it.
Bi-temporal row shape
Every row, regardless of primitive, carries the same structural columns. There is no opt-out.
| Column | Type | Meaning |
|---|---|---|
id | RowId | Stable identifier for this materialised row version (16-byte counter — see Identifier types) |
object_type | Arc<str> | The type this row belongs to (e.g. "Person") |
valid_from | i64 ns UTC | When the fact became true in the real world |
valid_to | i64 ns UTC | When the fact stopped being true (i64::MAX = currently true) |
system_from | i64 ns UTC | When the database first recorded this version of the fact |
system_to | i64 ns UTC | When this version was superseded (i64::MAX = current version) |
prov | ProvenanceRef | 32-byte hash pointer to the creating PROV-O assertion; the full ProvAssertion lives in the relata-prov crate and may be attached inline at insert |
tenant_id | Option<TenantId> | Organisation / agency scope for cross-tenant isolation |
data | RowData | Property bag — field name → CanonicalValue |
- Valid time answers "when was this true in the world?" A subscription ran from 2024-03-01 to 2024-09-15.
- System time answers "when did the database know about it?" The subscription row was ingested 2024-03-02 and a correction was recorded 2024-09-20.
A SELECT without AS OF reads the current valid-time slice at current system time. See Bi-Temporal for query semantics.
Timestamps
All timestamps are i64 nanoseconds UTC. The parser accepts:
- Decimal nanoseconds:
1762828800000000000 - UTC ISO-8601:
2025-11-11T00:00:00Z - UTC ISO-8601 with fractional seconds:
2025-11-11T00:00:00.500Z
Non-UTC offsets (
+05:30,-08:00) are rejected at parse time. Convert to UTC before writing. The engine will not silently normalize.
Canonical types
Canonical types are deterministic, validated binary encodings of real-world identifiers. They make cross-source fusion cheap: the same phone number, encoded the same way in every row, joins without normalization at query time.
| Canonical type | Encoding | Validation rule |
|---|---|---|
IPv4 | uint32 big-endian | Range check |
IPv6 | uint128 | Range check |
Msisdn | E.164 uint64 | Length, leading digit |
Imei | uint64 | Luhn check |
Iban | Rearranged string + mod-97 | ISO 13616 check digits |
Aadhaar | uint64 | Verhoeff check |
GeoPoint | S2 cell uint64 | Lat/lon range |
Hash_SHA256 | 32 bytes | Length check |
Email | Normalized lowercased bytes | RFC 5322 minimal |
Vin | 17 bytes | ISO 3779 check digit |
Mmsi | uint32 (9 digits) | ITU mid-range check |
~76 Tier-1 canonical types ship today. The often-quoted ~170 is the target catalogue size, not the shipped count. The authoritative enum is
crates/relata-canonical/src/lib.rs.
Why canonical types matter:
- 5–10× smaller storage — a phone number is 8 bytes, not a 15-character string.
- SIMD-friendly joins — fixed-width integers vectorize; variable-length strings do not.
- Cross-source matching — the same phone number written by three different feeds has one byte representation. No
LOWER(), noTRIM(), no normalization functions in the join predicate. - Bloom filter effectiveness — identical byte representations compress to the same bloom entry.
Identity — the umbrella type
Identity wraps CanonicalKind + bytes. Any property on any primitive can declare its type as Identity, and that value automatically participates in the universal IdentityIndex.
pub struct Identity {
pub kind: CanonicalKind, // Msisdn, Iban, Email, Imei, Hash_SHA256, ...
pub bytes: Vec<u8>, // canonical-encoded payload
}A property declared Identity does two things: it stores the typed bytes on the row, and it writes an entry into the IdentityIndex materialized view that maps (kind, bytes) → (object_id, source_table, source_column, observed_at). One index, every observation, every source.
This is the substrate for RESOLVE_IDENTITY, IDENTITY_CLUSTER, and SAME_IDENTITY SQL operators.
Locking model
parking_lotlocks —PlMutexandPlRwLocktype aliases. parking_lot does not poison on panic, which eliminates therecover_poison()pattern.- Per-type interior locking — writes take
&self, not&mut self. Each object type owns its ownRwLock<TableState>. A write toProfiledoes not block a write toPost. - Branch-level writer lease — at most one active writer per branch, enforced by the coordinator. Readers are always lock-free against the writer.
See also
- Architecture Overview — the five planes and design invariants
- Storage Engine — how rows land on disk
- Identity — the umbrella type and cross-source fusion in depth
- Bi-Temporal — valid time vs. system time query semantics