FLIN's temporal model is one of its most distinctive features. Every entity automatically maintains a version history, and developers can navigate that history using the @ operator. user @ -1 retrieves the previous version. user @ -3 goes back three versions. The entire history is preserved in the database, accessible with a single character of syntax.
On January 7, 2026, we had this feature fully implemented -- or so we thought. The @ operator was parsed, compiled, and handled by the VM. The database stored version history correctly. The integration tests had been written. And yet user @ -1 always returned None, no matter how many versions existed.
The temporal model was complete in every component but failed in the gap between them.
The Two-World Problem
FLIN's architecture has a split personality when it comes to entities. An entity exists simultaneously in two places: as a Value::Object on the VM's heap, and as a row in the ZeroCore database. These two representations must stay synchronized, but they have different lifecycles.
When you create an entity in FLIN:
user = User { name: "Alice" }The VM creates an EntityInstance on its heap with id = 0, version = 0, and the fields you specified. The entity exists only in VM memory. It has no database presence.
When you save it:
save userThe VM sends the entity to the ZeroCore database, which assigns it an ID (say, 1), creates a version record (version 1), and stores the fields. The database now has a complete, versioned entity.
The bug was in what happened next: the VM's copy of the entity was not updated with the information the database assigned. After the save, the VM still held version = 0 even though the database had version = 1.
How the Version Mismatch Broke Temporal Access
The temporal access operator @ works by computing a target version from the entity's current version and the requested offset:
target_version = entity.version + offsetFor user @ -1 (one version back):
// What should happen:
target_version = 2 + (-1) = 1 // Find version 1// What actually happened: target_version = 0 + (-1) = 0 // Uses saturating subtraction: 0 // Database has no version 0 // Returns None ```
The VM thought the entity was at version 0 (because it never synced with the database). The database had versions 1 and 2. The temporal access calculated target_version = 0, which did not exist. Every temporal query returned None.
The Debugging Trail
Step 1: Manual Test
We created a minimal test case:
entity User { name: text }user = User { name: "Alice" } save user
user.name = "Bob" save user
previous = user @ -1
Expected output: "Current: Bob, Previous: Alice." Actual output: "Current: Bob, Previous: " (empty, because previous was None).
Step 2: Tracing the Save Path
We added debug output to the Save opcode:
eprintln!("[Save] BEFORE: entity_id={:?}, version={}",
entity.id, entity.version);
// ... execute save ...
eprintln!("[Save] AFTER: id={}, version={}",
saved_id, entity.version);The output was damning:
[Save] BEFORE: entity_id=None, version=1
[Save] Saved with ID: 1
[Save] AFTER: id=1, version=1 <- VM still shows version=1 (not synced)[Save] BEFORE: entity_id=Some(1), version=1 <- Should be 1 from DB [Save] Saved with ID: 1 [Save] AFTER: id=1, version=1 <- Still 1, should be 2 ```
Wait -- the version showed 1 in both cases. But further investigation revealed this was the initial value from EntityInstance::new, not from the database. The version was being set to 1 by default in the constructor, not synced from the actual database version.
Step 3: The Missing Sync
Looking at the Save opcode implementation, the problem was clear:
// BEFORE the fix
match self.database.save(&type_name, entity_id, entity.fields.clone()) {
Ok(saved_id) => {
// Only updated the ID, not the version!
if let Ok(obj) = self.get_object_mut(obj_id) {
if let ObjectData::Entity(e) = &mut obj.data {
e.id = saved_id; // ID updated
// version NOT updated
// created_at NOT updated
// updated_at NOT updated
}
}
}
Err(_e) => { /* error handling */ }
}After saving, the VM updated the entity's id field but nothing else. The version, created_at, updated_at, and deleted_at fields were never synced from the database.
The Fix: Database as Source of Truth
The fix followed a simple principle: after any mutation, fetch the entity back from the database and sync all temporal fields.
match self.database.save(&type_name, entity_id, entity.fields.clone()) {
Ok(saved_id) => {
// Fetch the updated entity from the database
if let Ok(Some(saved_entity)) = self.database.find(&type_name, saved_id) {
if let Ok(obj) = self.get_object_mut(obj_id) {
if let ObjectData::Entity(e) = &mut obj.data {
e.id = saved_id;
e.version = saved_entity.version;
e.created_at = saved_entity.created_at;
e.updated_at = saved_entity.updated_at;
e.deleted_at = saved_entity.deleted_at;
}
}
}
}
Err(_e) => { /* error handling */ }
}The key addition is the self.database.find() call after the save. This retrieves the entity as the database sees it -- with the correct version number, timestamps, and all other metadata that the database manages.
The Trace After the Fix
[Save] BEFORE: entity_id=None, version=0
[Save] Saved with ID: 1
[Save] Fetched: id=1, version=1 <- DB has version 1
[Save] AFTER: id=1, version=1 <- VM synced to version 1[Save] BEFORE: entity_id=Some(1), version=1 [Save] Saved with ID: 1 [Save] Fetched: id=1, version=2 <- DB has version 2 [Save] AFTER: id=1, version=2 <- VM synced to version 2
[AtVersion] entity_id=1, current_version=2, offset=-1 [AtVersion] target_version=1 <- Correct calculation! [AtVersion] found=true <- Version 1 exists! ```
With the version correctly synced, temporal access worked perfectly. user @ -1 computed target_version = 2 - 1 = 1, found version 1 in the database, and returned the entity with name = "Alice".
A Second Bug: Schema Registration
While debugging the version tracking issue, we discovered a prerequisite bug. When creating an entity for the first time, the ZeroCore database did not know about the entity type:
[Save] Database error: UnknownEntityType("User")The CreateEntity opcode constructed entity instances in the VM, but never registered the entity schema with the database. The fix was to auto-register schemas on first entity creation:
// In the CreateEntity opcode handler
if !self.database.has_entity_type(&entity_type) {
let schema = EntitySchema::new(&entity_type);
let _ = self.database.register_entity(schema);
}This auto-registration follows FLIN's design philosophy: developers should not need to declare schemas explicitly. The entity definition in FLIN code (entity User { name: text }) is sufficient; the runtime handles the rest.
Why Not Just Increment in the VM?
A simpler approach would have been to increment the version counter in the VM instead of fetching it from the database:
// Simpler but WRONG approach
e.version += 1;We rejected this for several reasons:
Concurrent access. In production, multiple VM instances might save the same entity. If each VM maintains its own version counter, they will diverge. The database is the only point of truth that can coordinate concurrent modifications.
Global version counter. ZeroCore maintains a global_version that increments across all entity types. This enables consistent snapshots -- "show me the database as it was at global version 47." A VM-local counter cannot participate in this global ordering.
Timestamp accuracy. Only the database knows the exact created_at and updated_at timestamps, because they are set at the moment of persistence, not at the moment of construction.
Audit trail consistency. The version numbers in the history records must match the version numbers in the entity. If the VM maintains its own counter, mismatches are inevitable.
The fetch-after-save pattern adds one database query per save operation. This costs less than a millisecond in practice and guarantees consistency between the VM and the database.
Test Results
The fix resolved both the schema registration and version tracking issues:
entity User { name: text }user = User { name: "Alice" } save user // version 1
user.name = "Alice Smith" save user // version 2
previous = user @ -1 // { name: "Alice" } original = user @ -2 // None (no version 0) current = user @ 0 // { name: "Alice Smith" } ```
All 1,010 library tests passed with no regressions. Temporal integration tests improved from 3/27 to 4/27, with the remaining failures due to the HTML whitespace issue addressed in Session 074.
The Sync Principle
This bug crystallized a design principle that now governs all entity operations in FLIN: after any database mutation, sync the VM's entity representation with the database's version.
The database is the source of truth for: - Entity IDs - Version numbers - Timestamps (created_at, updated_at, deleted_at) - Soft delete status - Valid-time boundaries (bitemporal)
The VM is the source of truth for: - Field values currently being manipulated - Uncommitted changes - Computed expressions
This split responsibility means that the VM holds the "working copy" while the database holds the "committed version." The save operation is the commit point, and the sync-after-save ensures the VM's working copy reflects the committed state.
The pattern mirrors database transaction semantics: after a commit, any subsequent read should see the committed values. Without the sync, the VM was operating on stale metadata -- seeing version 0 when the database had version 2. The temporal model, which depends entirely on correct version numbers, was the most visible casualty of this staleness.
Twenty lines of code. Two critical bugs fixed. The entire temporal access system -- which is arguably FLIN's most innovative feature -- went from broken to working. The version tracking fix was not glamorous, but it was the bridge that connected FLIN's temporal theory to its temporal practice.
---
This is Part 161 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [160] When the VM Deadlocked on Entity Creation - [161] The Temporal Version Tracking Bug (you are here) - [162] The Database Persistence Fix That Took 3 Sessions