There is a particular class of bug that makes you question reality. Everything looks correct. The code compiles. The server responds. No error messages appear anywhere. Yet something fundamental is broken, and the only evidence is an absence -- the thing you expected to happen simply does not happen.
On February 3, 2026, we encountered one of these bugs in FLIN. Entity creation inside functions had silently stopped working. Users could click "Add Task" in the todo application and nothing would happen. No error. No crash. No warning. The button clicked, the request fired, the server responded with {"type":"ok"}, and the task list remained unchanged. The silence was the symptom.
The Symptom Matrix
The first clue that something architectural was wrong came from the pattern of what worked versus what did not:
| Operation | Status | Notes |
|---|---|---|
saveEdit(task) -- editing existing entities | Working | Fields updated correctly |
toggleTask(task) -- modifying entity fields | Working | Boolean toggle persisted |
deleteTask(task) -- deleting entities | Working | Entities removed properly |
addTask() -- creating new entities | Broken | Nothing happened at all |
Edit, toggle, delete -- all working. Only creation was broken. And creation was the one operation that required constructing a brand new entity object from scratch.
Understanding the Execution Pipeline
To understand why this bug was so insidious, you need to understand how FLIN executes functions during action requests. When a user clicks a button that triggers a server-side function, the request flows through a specialized execution path.
The FLIN VM has two primary execution modes. The main execute() method runs the full bytecode program from start to finish. But when handling action requests -- button clicks, form submissions -- the VM uses a different method called execute_until_return. This method runs a single function's bytecode and stops when that function returns.
The distinction matters because execute_until_return maintains its own opcode dispatch table. It is, in effect, a mini-VM within the VM, handling only the opcodes that can appear inside function bodies. And herein lies the problem: if an opcode is not in that dispatch table, it does not get executed. It falls through to a default case that silently advances the instruction pointer without doing anything meaningful.
The Missing Handler
In Session 269, we had added several opcode handlers to execute_until_return to support the new action system:
// Opcodes added in Session 269
OpCode::SetField => { /* update entity field */ }
OpCode::Save => { /* persist entity to database */ }
OpCode::Delete => { /* remove entity */ }
OpCode::Swap => { /* stack manipulation */ }
OpCode::Halt => { /* stop execution */ }
OpCode::JumpFar => { /* long-distance jump */ }Every opcode needed for editing, deleting, and saving entities was present. But CreateEntity -- the opcode that constructs a new entity instance -- was not in the list. It had been overlooked because all our testing focused on saveEdit(task), which modifies existing entities using SetField. We never tested addTask() with the new action system.
How Silent Failure Works
The bytecode for creating and saving a new task looks like this:
ip=1742: CreateEntity (0x77) + u16 type_idx + u8 field_count = 4 bytes
ip=1746: StoreLocal (0x21) + u8 slot = 2 bytes
ip=1748: LoadLocal (0x20) + u8 slot = 2 bytes
ip=1750: Save (0x90) = 1 byte
ip=1751: LoadNone + ReturnThe intended flow is straightforward: create the entity, store it in a local variable, load it back onto the stack, save it to the database, then return. But here is what actually happened when the VM encountered this bytecode:
ip=1742: Sees CreateEntity (0x77)
-> NO HANDLER in execute_until_return
-> Falls through to default case
-> Does NOT advance IP by the correct 4 bytes!
ip=1743: Reads next byte as if it were an opcode
-> Gets garbage (it is actually a type index byte)
-> Continues misinterpreting bytes as opcodes
ip=???: Eventually hits something that causes an early return
-> NEVER REACHES Save at ip=1750The entity was never created. The Save opcode was never reached. The function returned None instead of the saved entity. And because the action handler received None, it responded with {"type":"ok"} instead of {"type":"reload"} -- telling the browser that nothing changed.
No error. No crash. Just silence.
The Debugging Process
Finding this bug required three complementary debugging techniques.
Technique 1: Bytecode Offset Tracking
We added debug prints to the compiler to trace bytecode generation:
eprintln!("[DEBUG emit_var_decl] offset_before_expr={}", self.chunk.current_offset());
// ... emit the expression ...
eprintln!("[DEBUG emit_var_decl] offset_after_expr={}", self.chunk.current_offset());This confirmed that the CreateEntity opcode was being emitted correctly by the compiler. The bytecode was valid. The problem was downstream.
Technique 2: VM Execution Tracing
We added opcode-by-opcode logging inside execute_until_return:
if iterations <= 20 {
eprintln!("[DEBUG vm] iter={} ip={} opcode={:?}", iterations, self.ip, opcode);
}This revealed that after encountering CreateEntity, the VM was reading garbage bytes as opcodes. The instruction pointer was wandering through memory like a lost traveler, interpreting data bytes as instructions.
Technique 3: Entity Operations Counter
The decisive test was checking whether the Save opcode was ever reached:
eprintln!("[DEBUG] Entity ops count after call: {}", vm.get_entity_ops_count());When the count was zero after a function that should have saved an entity, we knew that the entire entity creation and persistence path was being skipped. That narrowed the search to the opcode dispatch table.
The Fix
The fix itself was substantial but mechanical -- adding the CreateEntity handler to execute_until_return:
OpCode::CreateEntity => {
let type_idx = self.read_u16(code);
let field_count = self.read_u8(code) as usize;
let entity_type = self.get_identifier(chunk, type_idx)?;// Auto-register entity schema if not already registered if !self.database.has_entity_type(&entity_type) { use crate::database::EntitySchema; let schema = EntitySchema::new(&entity_type); let _ = self.database.register_entity(schema); }
let mut entity = EntityInstance::new(entity_type.clone());
// Pop field values and names from the stack for _ in 0..field_count { let value = self.pop()?; let name = self.pop()?; if let Value::Object(id) = name { if let Ok(s) = self.get_string(id) { entity.fields.insert(s.to_string(), value); } } }
let id = self.alloc(HeapObject::new_entity(entity)); self.push(Value::Object(id))?; } ```
The handler reads the type index and field count from the bytecode, constructs a new entity instance, pops field values off the stack, and pushes the new entity object. It also includes field validation if any validators are registered, ensuring that @required decorators are enforced even during function execution.
The Second Bug: Fields Optional by Default
Fixing CreateEntity immediately revealed a second bug. The Person entity's add operation was still failing:
[FlinDB] Save failed in execute_until_return:
Required field 'nickname' is missing for entity 'Person'The problem was that fields without default values were being treated as required by default:
// BEFORE: Wrong assumption
let field_def = if has_default {
FieldDef::optional(...) // has default -> optional
} else {
FieldDef::new(...) // no default -> REQUIRED (wrong!)
};In FLIN's design philosophy, fields should be optional by default. Only fields explicitly decorated with @required should be mandatory. We fixed this by adding an is_required flag to the bytecode:
// Emitter: encode whether @required is present
let is_required = field.validations.iter()
.any(|v| matches!(v, FieldValidation::Required));
self.chunk.code.push(if is_required { 1 } else { 0 });// VM: read the flag and create the appropriate field definition let is_required = self.read_u8(code) != 0; let field_def = if is_required { FieldDef::new(...) // explicit @required } else { FieldDef::optional(...) // default: optional }; ```
The Prevention Checklist
This bug taught us a systematic rule: when adding opcodes to execute_until_return, always check if related opcodes are needed.
| If you add... | Also check for... |
|---|---|
Save | CreateEntity, Delete, Destroy |
SetField | GetField, CreateEntity |
StoreLocal | LoadLocal |
CreateList | CreateMap, CreateEntity |
We documented the complete list of opcodes that must exist in execute_until_return -- spanning entities, variables, constants, stack operations, control flow, arithmetic, comparison, logic, data structures, and function calls. Over 30 opcodes in total. Missing any one of them can cause the same class of silent failure.
The Verification Protocol
After the fix, we established a verification protocol for any change to the action system:
# 1. Kill existing server
pkill -f "target/debug/flin"# 2. Build fresh binary cargo build --bin flin
# 3. Start dev server ./target/debug/flin dev ../flin-public-repo/examples/todo
# 4. Test entity creation via curl curl -s -X POST "http://127.0.0.1:3000/_action" \ -H "Content-Type: application/json" \ -H "Referer: http://127.0.0.1:3000/tasks" \ -d '{"_action":"addTask","_args":"[]","_state":"{\"newTitle\":\"TEST TASK\"}"}'
# 5. Expected: {"type":"reload"} # 6. Verify in WAL log grep "TEST TASK" .flindb/wal.log ```
The WAL (Write-Ahead Log) is the ultimate source of truth. If the data does not appear there, the save did not happen, regardless of what the HTTP response says.
Lessons for Language Designers
This bug illustrates several principles that apply to any virtual machine or interpreter implementation.
First, silent failures are the most dangerous class of bug. An error message, however cryptic, at least tells you something went wrong. Silent failures give you nothing. The CreateEntity opcode returning None caused Save to operate on nothing, which produced no error -- it just did nothing. The entire chain was technically "working" in the sense that no operation failed. They simply operated on empty data.
Second, parallel dispatch tables must stay synchronized. Any time you have two code paths that handle the same set of operations -- in our case, execute() and execute_until_return -- adding something to one and forgetting the other is an inevitability. The only defense is a checklist or, better yet, a shared dispatch mechanism.
Third, test the full flow, not just the components. We had extensive tests for entity creation. We had tests for the action system. But we did not have a test that created an entity inside a function called by the action system. The gap between unit tests and integration tests is where bugs like this live.
The CreateEntity opcode was missing for perhaps a day before we noticed. In that day, every user who tried to add a new entity through the UI encountered the same silent nothing. It took thirty minutes to fix once we identified the problem, but those thirty minutes of investigation were a masterclass in systematic debugging -- tracing bytecode, logging execution, and measuring side effects to find an absence masquerading as correctness.
---
This is Part 156 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: - Previous arc: FLIN Standard Library and Ecosystem - [156] The CreateEntity Opcode That Went Missing (you are here) - [157] The For-Loop Iteration Bug