Data without context is just numbers. Knowing that a product costs fifteen dollars is useful. Knowing that it costs fifteen dollars, that this is version twelve, that it was last updated three days ago, and that it has never been deleted -- that is an audit trail.
Session 081 implemented version metadata access for FLIN entities: the ability to read .id, .version_number, .created_at, .updated_at, and .deleted_at directly from any entity instance, including historical versions returned by .history. The implementation took thirty minutes and added sixty-three lines of code. The impact was disproportionate: it transformed FLIN's temporal model from "we store versions" to "we provide complete lifecycle transparency."
What Metadata Is Available
Every entity instance in FLIN carries five metadata fields, automatically populated by the runtime:
flinuser = User.find(id)
user.id // Unique entity identifier (integer)
user.version_number // Sequential version counter (integer)
user.created_at // Timestamp of initial creation (integer, ms)
user.updated_at // Timestamp of last modification (integer, ms)
user.deleted_at // Timestamp of soft deletion (optional integer)These are not user-defined fields. They exist on every entity regardless of its schema. An entity with a single name: text field has six accessible properties: name, id, version_number, created_at, updated_at, and deleted_at.
The deleted_at field is special: it is always optional. For active entities, it returns none. For soft-deleted entities, it returns the deletion timestamp. This distinction is baked into the type system -- deleted_at has type Optional<Int> even when accessed on a non-optional entity reference.
What Came Before: The Invisible Metadata
Before Session 081, every entity in FLIN already carried metadata -- the EntityInstance struct had id, version, created_at, updated_at, and deleted_at fields from the earliest sessions. The VM used these fields internally for temporal operations: the @ operator checked version numbers, the save operation incremented timestamps, and soft delete set deleted_at.
But developers could not access any of it. The metadata was invisible. If you wanted to display a version number in a template, you could not. If you wanted to show when an entity was last updated, you could not. The data was there, inside the runtime, but there was no syntax to reach it.
This is a common pattern in language development: internal state that the runtime needs but the developer cannot touch. Session 081 tore down that wall.
The Implementation: Extending GetField
FLIN has two opcodes for field access: GetField (static, when the property name is known at compile time) and GetFieldDyn (dynamic, for computed property names). Both needed to be extended to recognize metadata fields.
The change was surgical. Before checking user-defined fields, the VM now checks for metadata field names:
rustObjectData::Entity(e) => {
match name.as_str() {
"id" => Value::Int(e.id as i64),
"version" | "version_number" => Value::Int(e.version as i64),
"created_at" => Value::Int(e.created_at),
"updated_at" => Value::Int(e.updated_at),
"deleted_at" => e.deleted_at.map(Value::Int).unwrap_or(Value::None),
_ => e.fields.get(&name).cloned().unwrap_or(Value::None),
}
}This code appears in both GetField and GetFieldDyn handlers -- a duplication that is necessary because of how the VM dispatches these opcodes, but the logic is identical.
The Precedence Decision
Metadata fields take precedence over user-defined fields. If a developer creates an entity with a field named id, the built-in metadata id wins:
flinentity Problematic {
id: text // User-defined 'id' field
name: text
}
item = Problematic { id: "custom-id", name: "test" }
save item
print(item.id) // Returns the entity ID (integer), NOT "custom-id"This was a deliberate design choice. System metadata must be reliably accessible. If user-defined fields could shadow metadata, developers would have no way to access the entity's actual ID, version, or timestamps. The trade-off -- that field names id, version, version_number, created_at, updated_at, and deleted_at are effectively reserved -- is acceptable because these names are precisely the ones developers would want for metadata anyway.
Type Checking
The type checker was extended to recognize metadata fields on entity types:
rustFlinType::Entity(entity_name) => {
match property {
"id" | "version" | "version_number"
| "created_at" | "updated_at" => {
return Ok(if optional {
FlinType::Optional(Box::new(FlinType::Int))
} else {
FlinType::Int
});
}
"deleted_at" => {
return Ok(FlinType::Optional(Box::new(FlinType::Int)));
}
_ => {} // Fall through to user-defined field checking
}
}Two nuances in this type checking logic:
All metadata fields return integers. Timestamps are stored as milliseconds since epoch (i64). Version numbers and IDs are also integers. This simplicity means temporal arithmetic works naturally: user.updated_at + 7.days is just integer addition.
deleted_at is always optional. Even when accessed on a non-optional entity reference, deleted_at returns Optional<Int>. This is because most entities are not deleted, so the field is None by default. The type system reflects this reality -- you must handle the None case:
flin{if user.deleted_at}
<p>Deleted at: {user.deleted_at}</p>
{else}
<p>Active</p>
{/if}Metadata on Historical Versions
The real power of metadata access emerges when combined with .history. Each version in the history list carries its own metadata, reflecting the state at the time that version was created:
flin{for ver in product.history}
<div class="audit-entry">
<p>Version #{ver.version_number}</p>
<p>Entity ID: {ver.id}</p>
<p>Created at: {ver.created_at}</p>
<p>Updated at: {ver.updated_at}</p>
<p>Price: ${ver.price}</p>
</div>
{/for}This loop renders a complete audit trail with no additional code. Each ver in the loop is a full entity instance, reconstructed from the EntityVersion struct stored in the version history. The reconstruction populates metadata from the version's stored values:
rust// EntityInstance structure
pub struct EntityInstance {
pub entity_type: String,
pub id: u64, // Accessible via .id
pub fields: HashMap<String, Value>,
pub version: u64, // Accessible via .version_number
pub created_at: i64, // Accessible via .created_at
pub updated_at: i64, // Accessible via .updated_at
pub deleted_at: Option<i64>, // Accessible via .deleted_at
}When the VM constructs a historical entity from an EntityVersion, it maps the version's timestamp to created_at and updated_at, and the version's version number to the version field. The result is that historical entities behave identically to current entities -- the same field access syntax works for both.
Building Audit Trails
Before Session 081, you could access entity history:
flinhistory = product.history
count = history.countAfter Session 081, you could build complete audit trails:
flin<div class="audit-log">
<h2>Document History</h2>
<table>
<thead>
<tr>
<th>Version</th>
<th>Date</th>
<th>Title</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{for ver in document.history}
<tr>
<td>v{ver.version_number}</td>
<td>{ver.updated_at}</td>
<td>{ver.title}</td>
<td>{ver.status}</td>
</tr>
{/for}
</tbody>
</table>
</div>In a traditional web framework, this audit trail would require:
- A separate
document_historytable with foreign keys. - Database triggers or application middleware to populate it on every change.
- A dedicated API endpoint to query it.
- A frontend component to display it.
- Migration scripts to set it up.
- Tests to verify the trigger/middleware behavior.
In FLIN, it is a {for} loop over .history with metadata access. The infrastructure does not exist as separate code -- it is the language runtime itself.
Real-World Application Patterns
E-commerce Product Updates
flin<div class="product-info">
<h1>{product.name}</h1>
<p class="price">${product.price}</p>
<p class="meta">
Version {product.version_number}
-- Last updated {product.updated_at}
</p>
</div>Customers can see "This product was last updated 3 days ago (version 12)" without any additional tracking code.
User Profile Changes
flin<div class="profile-history">
<h3>Profile Change History</h3>
{for ver in user.history.last(5)}
<div class="change-entry">
<span class="version">v{ver.version_number}</span>
<span class="date">{ver.updated_at}</span>
<span class="name">{ver.name}</span>
<span class="email">{ver.email}</span>
</div>
{/for}
</div>Compliance Reporting
flin// Generate compliance report data
all_changes = document.history
.where_field("created_at", ">", last_month)
.order_by("created_at", "desc")
<div class="compliance-report">
<h2>Changes in Last 30 Days</h2>
<p>Total versions: {all_changes.count}</p>
{for change in all_changes}
<div class="report-entry">
<p>Version {change.version_number} at {change.created_at}</p>
<p>Status: {change.status}</p>
</div>
{/for}
</div>The Underlying Data Structure
Understanding why metadata access is efficient requires understanding how entities are stored. The EntityInstance struct carries metadata alongside user-defined fields:
rustpub struct EntityInstance {
pub entity_type: String,
pub id: u64,
pub fields: HashMap<String, Value>,
pub version: u64,
pub created_at: i64,
pub updated_at: i64,
pub deleted_at: Option<i64>,
}The fields HashMap contains user-defined data (name, price, email). Metadata lives outside the HashMap as direct struct fields. This means metadata access is a direct field read -- O(1) with no hash lookup -- while user-defined field access requires a HashMap lookup.
Historical versions use a slimmer struct:
rustpub struct EntityVersion {
pub version: u64,
pub timestamp: i64,
pub fields: HashMap<String, Value>,
}When the VM reconstructs a historical entity for .history iteration, it maps the EntityVersion fields onto an EntityInstance, populating the metadata from the version's stored values. This reconstruction happens once per version access and produces a complete entity that supports the same field access syntax as a current entity.
The Alias: .version vs .version_number
A small but user-friendly detail: the version number is accessible through two names. Both entity.version and entity.version_number return the same value. The shorter form is convenient for quick access. The longer form is more explicit and reads better in templates where clarity matters.
flin// Both are equivalent
product.version // 5
product.version_number // 5This was implemented as a simple pattern match in the VM's field access handler -- the "version" | "version_number" arm handles both names and returns the same e.version field. The type checker similarly accepts both names with identical return types.
The dual-name approach follows a principle we applied throughout FLIN: when two names are equally intuitive and there is no ambiguity, support both. Developers should not need to remember whether it is .version or .version_number -- they can use whichever feels natural in context.
Progress Impact
Session 081 completed three tasks: - TEMP4-13: Access version.id - TEMP4-14: Access version.created_at - TEMP4-15: Access version.version_number
TEMP-4 went from sixteen out of twenty-two (seventy-three percent) to nineteen out of twenty-two (eighty-six percent). Overall temporal progress: one hundred and two out of one hundred sixty (sixty-three point eight percent).
The implementation was small -- sixty-three net lines of code across three files. But the feature it enabled -- complete lifecycle transparency for every entity in every FLIN application -- is foundational. Audit trails, compliance reports, version displays, and activity logs all become trivial once metadata is a first-class property.
Why Metadata Is Not Just "Nice to Have"
In every application we have built at ZeroSuite, metadata access has been a requirement, not a feature. Consider the patterns:
Support tickets. When a user reports "my data looks wrong," the support team needs to know: Which version is this? When was it last updated? Has it been deleted and restored? Without metadata, answering these questions requires database access and SQL queries. With FLIN's metadata, the support team can see the answer directly in the application UI.
Concurrency. When two users edit the same entity, the version number enables optimistic concurrency control. Before saving, the application can check whether the version number has changed since the entity was loaded. If it has, another user made a change, and the save can be rejected or merged.
flin// Optimistic concurrency pattern
loaded_version = product.version_number
// ... user makes changes ...
current_version = Product.find(product.id).version_number
{if loaded_version != current_version}
<p>This record was modified by another user. Please refresh.</p>
{/if}Caching. The updated_at timestamp enables cache invalidation. A client can store a product's data along with its updated_at value and only re-fetch when the server's updated_at is newer.
Activity feeds. Combining .history with metadata creates activity feeds without a separate events table:
flin// Recent activity across all documents
{for doc in Document.all}
{if doc.updated_at > last_week}
<div class="activity-item">
<p>{doc.title} updated (v{doc.version_number})</p>
<span class="date">{doc.updated_at}</span>
</div>
{/if}
{/for}None of these patterns require additional infrastructure in FLIN. The metadata is always there, always accurate, and always accessible through the same property syntax used for user-defined fields.
Thirty minutes of implementation. Zero new opcodes. Zero new runtime concepts. Just exposing what was already there, making the implicit explicit, and letting developers use it.
This is Part 7 of the "How We Built FLIN" temporal model series, documenting the version metadata system that enables zero-configuration audit trails.
Series Navigation: - [046] Every Entity Remembers Everything: The Temporal Model - [047] Version History and Time Travel Queries - [048] Temporal Integration: From Bugs to 100% Test Coverage - [049] Destroy and Restore: Soft Deletes Done Right - [050] Temporal Filtering and Ordering - [051] Temporal Comparison Helpers - [052] Version Metadata Access (you are here) - [053] Time Arithmetic: Adding Days, Comparing Dates - [054] Tracking Accuracy and Validation - [055] The Temporal Model Complete: What No Other Language Has