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:

TypeUnderlyingMinted byPurpose
ObjectId16 bytesDerived (byte-borrow of a RowId)Stable identifier for an instance of an ObjectType
LinkId16 bytesCurrently unminted in production (type defined for future wired edges)Stable identifier for a typed, directional edge (LinkType instance)
EventId16 bytesCurrently unminted in production (type defined for the causal-event graph)Stable identifier for a time-anchored occurrence (EventType instance)
RowId16 bytesalloc_row_id() (store/mod.rs:4300) — 8-byte big-endian AtomicU64 counter, zero-paddedInternal 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 randomRowId increases 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 replaywal_encode_record (store/mod.rs:6578) does not serialise row_id, so a row reconstructed purely from WAL during crash recovery gets a fresh counter value. References to a specific RowId from 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

PrimitivePurposeExamples
ObjectTypeA thing with stable identity. Survives property changes via bi-temporal rows.Profile, Subscriber, Account, Device
EventTypeA time-anchored occurrence. Bi-temporal by construction.CallEvent, Post, Transaction, LoginAttempt
LinkTypeA 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.

ColumnTypeMeaning
idRowIdStable identifier for this materialised row version (16-byte counter — see Identifier types)
object_typeArc<str>The type this row belongs to (e.g. "Person")
valid_fromi64 ns UTCWhen the fact became true in the real world
valid_toi64 ns UTCWhen the fact stopped being true (i64::MAX = currently true)
system_fromi64 ns UTCWhen the database first recorded this version of the fact
system_toi64 ns UTCWhen this version was superseded (i64::MAX = current version)
provProvenanceRef32-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_idOption<TenantId>Organisation / agency scope for cross-tenant isolation
dataRowDataProperty 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 typeEncodingValidation rule
IPv4uint32 big-endianRange check
IPv6uint128Range check
MsisdnE.164 uint64Length, leading digit
Imeiuint64Luhn check
IbanRearranged string + mod-97ISO 13616 check digits
Aadhaaruint64Verhoeff check
GeoPointS2 cell uint64Lat/lon range
Hash_SHA25632 bytesLength check
EmailNormalized lowercased bytesRFC 5322 minimal
Vin17 bytesISO 3779 check digit
Mmsiuint32 (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(), no TRIM(), 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_lot locksPlMutex and PlRwLock type aliases. parking_lot does not poison on panic, which eliminates the recover_poison() pattern.
  • Per-type interior locking — writes take &self, not &mut self. Each object type owns its own RwLock<TableState>. A write to Profile does not block a write to Post.
  • 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