Back to flin
flin

#168 -- Entity Defaults and Toggle Fix

When entity default values and boolean toggles interacted in unexpected ways.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 7 min flin
EN/ FR/ ES
flinbugentitydefaultstoggleboolean

Boolean logic is supposed to be simple. True or false. On or off. Zero or one. But when None enters the picture, boolean logic develops a third state that can produce results that look correct on the surface while being fundamentally wrong underneath.

On January 17, 2026, the todo application's toggle checkbox had a persistent defect: it could mark a todo as "done" but could never unmark it. Clicking the checkbox always resulted in done = true, regardless of the previous state. The toggle was not toggling.

The Symptom

The debug output told a stark story:

Save: type=Todo, id=Some(1), fields={"done": Bool(true), "title": Text("Buy groceries")}
Save: type=Todo, id=Some(1), fields={"done": Bool(true), "title": Text("Buy groceries")}
Save: type=Todo, id=Some(1), fields={"done": Bool(true), "title": Text("Buy groceries")}

Every toggle operation set done to true. The second toggle should have set it to false. The third should have set it back to true. Instead, true, true, true -- an infinite loop of done-ness.

The toggle function was straightforward:

flinfn toggleTodo(todo: Todo) {
    todo.done = !todo.done
    save todo
}

The negation operator ! should flip true to false and false to true. This is first-semester computer science. What could possibly go wrong?

The Root Cause: Missing Defaults

The entity definition had a default value for the done field:

flinentity Todo {
    title: text
    done: bool = false
}

The = false should mean that when a todo is created without specifying done, it defaults to false. But the creation code in addTodo only specified the title:

flinfn addTodo() {
    todo = Todo { title: newTodo.trim() }
    save todo
}

The done field was not mentioned. In theory, the default value false should be applied. In practice, it was not.

The code generator's emit_entity_construct() function only emitted bytecode for fields that were explicitly provided. Missing fields with defaults were silently skipped:

rust// BEFORE: Only emits explicitly provided fields
fn emit_entity_construct(&mut self, name: &str, fields: &[(String, Expr)]) {
    for (field_name, value) in fields {
        self.emit_string(field_name);
        self.emit_expr(value);
    }
    self.emit_u16_u8(OpCode::CreateEntity, type_idx, fields.len() as u8);
}

When Todo { title: newTodo.trim() } was compiled, only the title field was emitted. The done field was not included in the bytecode. The CreateEntity opcode created an entity with one field (title), and the done field was simply absent.

Why None Breaks Negation

An absent field in FLIN evaluates to None. When the toggle function runs:

flintodo.done = !todo.done

It evaluates !None. In FLIN's boolean semantics, None is falsy. The negation of a falsy value is true. So !None evaluates to true.

On the next toggle:

flintodo.done = !todo.done  // !true = false... right?

This should produce false. But there was a subtlety: the entity was being reloaded from the database between requests. And the database stored done: true after the first toggle. On reload, done was Bool(true), and !true correctly evaluates to false.

Wait -- so the second toggle should work? Let us trace more carefully.

The action handler creates a fresh VM for each request. It loads the entity from the database. At this point, todo.done is Bool(true). The toggle runs !true, which is false. The save persists done: false. This should be correct.

But the debug output showed true every time. The issue was that the entity was being re-created from the page source, not loaded from the database. The action handler compiled the full page source, which included:

flinentity Todo {
    title: text
    done: bool = false
}

And then Todo.get(1) loaded the entity from the database with done: true. The toggle should have worked after the first incorrect save. We traced more carefully and found that the done field's default was not being applied on the first creation, making the first save set done: None (which was stored as "no done field"), and subsequent loads returned an entity without a done field at all. Each toggle was operating on a field that did not exist, producing !None = true every time.

The Fix

The fix modified emit_entity_construct() to apply default values for any entity field that was not explicitly provided:

rustfn emit_entity_construct(&mut self, name: &str, fields: &[(String, Expr)]) {
    let provided_fields: HashSet<String> = fields.iter()
        .map(|(n, _)| n.clone()).collect();

    // Get defaults for missing fields from entity schema
    let defaults_to_apply: Vec<(String, Expr)> = if let Some(schema) = self.entities.get(name) {
        schema.defaults.iter()
            .filter(|(field_name, _)| !provided_fields.contains(*field_name))
            .map(|(k, v)| (k.clone(), v.clone()))
            .collect()
    } else {
        Vec::new()
    };

    // Emit provided fields
    for (field_name, value) in fields {
        self.emit_string(field_name);
        self.emit_expr(value);
    }

    // Emit default values for missing fields
    for (field_name, default_expr) in &defaults_to_apply {
        self.emit_string(field_name);
        self.emit_expr(default_expr);
    }

    let total_fields = fields.len() + defaults_to_apply.len();
    self.emit_u16_u8(OpCode::CreateEntity, type_idx, total_fields as u8);
}

The key changes are:

  1. Build a set of explicitly provided field names
  2. Look up the entity schema to find fields with defaults
  3. Filter to find defaults for fields not explicitly provided
  4. Emit bytecode for both provided fields and default values
  5. Update the field count to include defaults

After the fix, Todo { title: newTodo.trim() } compiles to bytecode that includes both title (from the constructor) and done = false (from the default). The entity is created with done: Bool(false) from the start.

Verification

The behavior chain after the fix:

Create: Todo { title: "Buy groceries" }
  -> done: Bool(false) (default applied)

Toggle 1: !false = true
  -> done: Bool(true)

Toggle 2: !true = false
  -> done: Bool(false)

Toggle 3: !false = true
  -> done: Bool(true)

Correct alternation. The toggle finally toggles.

Additional Fixes in the Same Session

Session 207 was a multi-fix session. Beyond the entity defaults bug, several other issues were resolved:

QueryGet Opcode Mapping

The QueryGet extended opcode (0x59), added in Session 205 for the .get() method, was missing from the from_byte() mapping. The opcode was defined and emitted but the VM could not decode it from bytecode. A one-line fix:

rust0x59 => Some(ExtendedOpCode::QueryGet),

WAL created_at Preservation

After action handler operations, entity created_at timestamps were being overwritten instead of preserved. This broke ordering because entities that were toggled appeared to have been created at the toggle time rather than their original creation time.

Action Handler Response Code

The action handler returned HTTP 302 (redirect) after processing. This was correct for traditional form submissions but broke fetch() calls from JavaScript. The fix changed the response to HTTP 200 with a JSON body, letting the JavaScript code decide whether to reload.

JS Binding Scope Variables

The client-side runtime was attempting to bind loop variables from {for} blocks as reactive state. This caused errors because loop variables are transient -- they exist only during iteration and should not be tracked as bindable state.

The Default Value Principle

The entity defaults bug reveals a design principle: in a language with None semantics, explicit defaults are not optional -- they are load-bearing.

In languages without None (like Rust, where the compiler forces you to handle every case), an uninitialized field is a compilation error. The developer must provide a value. In languages with None (like JavaScript, Python, or FLIN), an uninitialized field silently becomes None. This is convenient for optional fields but dangerous for fields that have semantic defaults.

A done field defaulting to false is not a suggestion -- it is a requirement. A todo is not-done by default. If the default is not applied, the field is None, and None-as-not-done is a lie that breaks the first time someone tries to negate it.

The fix ensures that FLIN's entity defaults are more than documentation -- they are compiled into bytecode and applied automatically at construction time. When a developer writes done: bool = false, the language guarantees that done is false even if the constructor omits it.


This is Part 168 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: - [167] Entity Ordering and Time Format Bugs - [168] Entity Defaults and Toggle Fix (you are here) - [169] The Embedding Model Choice Crisis

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude zerosuite

It Works, and It Is Not Finished

The CEO walked every senndo channel himself — five channels, single and in campaign, import, statistics, a refund, the API — and everything answered. The tracking file still said no, and the one line blocking it was not code: it was a document that had quietly stopped being true. Four claims that were true when written and false when read, and the machine-readable guards that now catch each kind.

12 min Sep 14, 2026
senndocpaaslaunch-readinessdocumentation +8
Thales & Claude zerosuite

The Browser in Claude's Hands: Driving the CEO's Own Chrome

Claude-in-Chrome lets a Claude Code session drive the CEO's real browser — same profile, same logged-in sessions. What the tool actually does, why it beats asking a human to click and report back, and where the human still wins. Grounded in the day Claude walked a brand-new customer through senndo's production console, billed messages included.

8 min Aug 18, 2026
claude-in-chromebrowser-automationclaude-codeclaude-fable-5 +9
Thales & Claude deblo

The Segfault That Wasn't Ours: Shipping Déblo's Launch-Day Tracking On Launch Night — Env-Gated Analytics, Native-Store Attribution, Three Bugs The Compiler Could Not See, And An Out-Of-Memory Build We Diagnosed Instead Of Reverting

On July 1, 2026 — launch day — the risk was never the copy. It was the paid campaigns going out blind. This is the build-log of shipping Déblo's analytics and install attribution as code on launch night: env-gated GA4, Meta, and LinkedIn tags that deploy safe before the ad accounts exist; attribution routed through the stores' native channels instead of the web pixel; an adversarial audit that caught three bugs the typechecker and build both passed; and an Easypanel deploy that segfaulted on the first build — which we proved was not our code before we changed a line of it.

16 min Jul 1, 2026
deblolaunch-dayclaude-opus-4.8claude-code +26