🎧 Listen to this article

Mutability as a First-Class Concept: The Lattice Phase System

Most programming languages treat mutability as a binary annotation. You write const or let, final or var, and the compiler enforces it statically. Rust goes further with its borrow checker, enforcing exclusive mutable access at compile time. JavaScript offers Object.freeze(), a runtime operation that's shallow by default and provides no mechanism for observation or validation. These are all useful tools, but they share a common limitation: mutability is something you declare, not something you work with.

In Lattice, I've been building something different. Mutability — what Lattice calls phase — is a first-class runtime property that can be queried, constrained, validated, coordinated across variables, observed reactively, and even tracked historically. Over the last several releases (v0.2.3 through v0.2.6), this system has grown from simple freeze/thaw semantics into a full lifecycle framework. This post walks through that progression and the design decisions behind it.

The Metaphor: Crystallization

Lattice is built around the metaphor of crystallization. Values begin in a fluid state (mutable) and can be frozen into a crystal state (immutable). The thaw() operation creates a mutable copy of a crystal value, and clone() performs a deep copy regardless of phase. This vocabulary isn't just cosmetic — it shapes how you think about data lifecycle in a Lattice program.

flux temperature = 72.5       // fluid: mutable
temperature = 68.0             // allowed

freeze(temperature)            // now crystal: immutable
// temperature = 70.0          // ERROR: cannot mutate crystal value

flux copy = thaw(temperature)  // new fluid copy
copy = 70.0                    // allowed

The flux keyword declares a fluid (mutable) binding. The fix keyword declares a crystal (immutable) binding. And let infers phase from context — fluid if the value is fluid, crystal if crystal. This alone isn't novel. What makes Lattice's approach interesting is everything that builds on top of it.

Phase Constraints: Mutability in Your Type Signatures

The first major addition (v0.2.3) was phase constraints on function parameters. In most languages, a function that receives data has no way to express whether it expects mutable or immutable input. You might document it, or rely on convention, but the language doesn't help. In Lattice, you can annotate parameters with their expected phase:

fn mutate(data: flux Map) {
    data.set("modified", true)
}

fn inspect(data: fix Map) {
    print(data.get("name"))
}

The runtime checks phase at call time. Pass a crystal value to mutate() and you get an error. Pass a fluid value to inspect() and it works fine — fluid is compatible with fix because it can be read. The constraint is about what the function needs, not what the caller has.

The shorthand syntax uses ~ for flux and * for fix:

fn process(data: ~Map) { ... }  // needs mutable
fn display(data: *Map) { ... }  // needs immutable

Phase-Dependent Dispatch

Phase constraints enable something more powerful: dispatch based on runtime phase. You can define multiple implementations of the same function with different phase signatures, and the runtime selects the best match:

fn serialize(data: ~Map) {
    // mutable path: can add metadata before serializing
    data.set("serialized_at", time_now())
    print(json_stringify(data))
}

fn serialize(data: *Map) {
    // immutable path: serialize directly, no side effects
    print(json_stringify(data))
}

flux config = Map::new()
config["host"] = "localhost"
serialize(config)      // calls flux overload, adds timestamp

freeze(config)
serialize(config)      // calls fix overload, no mutation

The overload resolution uses a scoring system. An exact phase match (fluid argument to flux parameter) scores highest. A compatible match (fluid to unphased) scores lower. An incompatible match (crystal to flux) is rejected entirely. When multiple overloads exist, the best-scoring one wins.

This is genuinely useful in practice. A caching layer might have one implementation that updates a cache (requires mutable data) and another that reads through (works with immutable data). A serialization function might add metadata to mutable structures but serialize immutable ones directly. The caller doesn't need to know — the runtime dispatches based on what the data actually is.

Crystallization Contracts: Validation at the Phase Boundary

The next question was: when data freezes, how do you ensure it's in a valid state? In real systems, immutable data often represents finalized configuration, committed transactions, or published records. You want to validate before that transition happens.

Version 0.2.5 introduced crystallization contracts — validation closures attached to freeze() with the where keyword:

flux config = Map::new()
config["host"] = "localhost"
config["port"] = 8080
config["workers"] = 4

freeze(config) where |v| {
    if !v.has("host") { throw("config missing 'host'") }
    if !v.has("port") { throw("config missing 'port'") }
    if v["workers"] < 1 { throw("need at least 1 worker") }
}

The contract receives a deep clone of the value (so the validation can't accidentally mutate the original), runs the closure, and if the closure throws, the freeze is aborted and the value remains fluid. If validation passes, the value transitions to crystal.

This maps cleanly to real-world patterns. Database ORMs validate before persisting. Configuration systems validate before applying. Form submissions validate before accepting. The difference is that in Lattice, this validation is attached to the phase transition itself, not to a separate method you have to remember to call.

Contracts compose naturally with the rest of the phase system. You can use them with phase bonds (discussed next) or with phase-dependent dispatch. A function that accepts fix Map knows its argument passed whatever contract was attached at freeze time.

Phase Bonds: Coordinated Freezing

Individual freeze/thaw operations work well for isolated values, but real programs have related data that should transition together. A web request's headers, body, and metadata should probably all be immutable before you send it. A transaction's debit and credit entries should freeze atomically.

Phase bonds (also v0.2.5) let you declare these relationships:

flux header = Map::new()
flux body = Map::new()
flux footer = Map::new()

header["content-type"] = "text/html"
body["content"] = "<h1>Hello</h1>"
footer["timestamp"] = time_now()

bond(header, body, footer)

freeze(header)              // cascades to body AND footer
print(phase_of(body))       // "crystal"
print(phase_of(footer))     // "crystal"

The bond(target, ...deps) call links dependencies to a target. When the target freezes, all its dependencies freeze too. Bonds are also transitive — if A is bonded to B and B is bonded to C, freezing A cascades through B to C.

flux a = 1
flux b = 2
flux c = 3

bond(a, b)    // b depends on a
bond(b, c)    // c depends on b

freeze(a)     // freezes a → b → c
print(phase_of(c))  // "crystal"

You can remove bonds with unbond() before the freeze happens:

bond(header, body, footer)
unbond(header, footer)    // footer no longer cascades

freeze(header)            // freezes header and body, NOT footer
print(phase_of(footer))   // "fluid"

Bonds solve a coordination problem that most languages leave to discipline. In a typical codebase, you'd need to remember to freeze all related values, or wrap them in a container and freeze that. Bonds make the relationship explicit and enforced.

Phase Reactions: Observing State Transitions

With constraints, contracts, and bonds, you can control how and when phase transitions happen. But sometimes you also need to know that they happened. Logging, cache invalidation, UI updates, audit trails — these are all responses to state changes.

Version 0.2.6 adds phase reactions: callbacks that fire automatically when a variable's phase changes.

flux data = [1, 2, 3]

react(data, |phase, val| {
    print("data is now " + phase + ": " + to_string(val))
})

freeze(data)   // prints: "data is now crystal: [1, 2, 3]"
thaw(data)     // prints: "data is now fluid: [1, 2, 3]"

The callback receives two arguments: the new phase name (as a string — "crystal", "fluid") and a deep clone of the current value. Multiple callbacks can be registered on the same variable, and they fire in registration order:

flux counter = 0

react(counter, |phase, val| {
    print("logger: counter is now " + phase)
})

react(counter, |phase, val| {
    if phase == "crystal" {
        print("audit: counter finalized at " + to_string(val))
    }
})

counter = 42
freeze(counter)
// prints:
//   logger: counter is now crystal
//   audit: counter finalized at 42

Reactions also fire during bond cascades. If variable B is bonded to A and has a reaction registered, freezing A will cascade to B and trigger B's reaction:

flux primary = Map::new()
flux replica = Map::new()

bond(primary, replica)

react(replica, |phase, val| {
    print("replica transitioned to " + phase)
})

freeze(primary)
// prints: "replica transitioned to crystal"

This is a powerful combination. Bonds handle the coordination of transitions, and reactions handle the observation. Together they let you build systems where phase changes propagate and trigger side effects in a predictable, declarative way.

Use unreact() to remove all reactions from a variable:

react(data, |phase, val| { print("fired") })
unreact(data)
freeze(data)  // no output — reaction was removed

If a reaction callback throws an error, it propagates as a reaction error, giving you a clean way to handle failures in the observation chain.

Temporal Values: Phase History and Time Travel

The last piece of the phase system (also v0.2.5) is temporal values — the ability to track a variable's phase transitions and value changes over time.

flux counter = 0
track("counter")

counter = 10
counter = 20
freeze(counter)

let history = phases("counter")
// [{phase: "fluid", value: 0},
//  {phase: "fluid", value: 10},
//  {phase: "fluid", value: 20},
//  {phase: "crystal", value: 20}]

The track() function enables recording for a named variable. Every assignment and phase transition creates a snapshot. The phases() function returns the full history as an array of maps, and rewind() lets you retrieve past values by offset:

flux x = 100
track("x")
x = 200
x = 300

print(rewind("x", 0))  // 300 (current)
print(rewind("x", 1))  // 200 (one step back)
print(rewind("x", 2))  // 100 (two steps back)

Temporal values serve primarily as a debugging and auditing tool. When something goes wrong with a frozen value, you can inspect its history to see what mutations happened before the freeze. When testing phase-dependent dispatch, you can verify that the right transitions occurred. In production systems, you can use temporal tracking for audit logs or undo functionality.

The Bigger Picture: Why This Matters

Most programming languages treat mutability as a compiler concern — something to check at build time and forget about. Lattice treats it as a runtime property with the same richness as types or values. This opens up patterns that are difficult or impossible in other languages:

Gradual freezing. Data starts fluid, accumulates state through a pipeline, and freezes when it's complete. Contracts validate at the boundary. Bonds ensure related data transitions together. This maps naturally to request processing, form building, transaction assembly, and configuration loading.

Observable state transitions. Reactions let you attach behavior to phase changes without coupling the code that freezes with the code that responds. A module can register a reaction on shared data without knowing who or when the freeze will happen.

Phase-aware APIs. Functions can express their mutability requirements in their signatures and dispatch based on the caller's data. Libraries can offer mutable and immutable code paths transparently.

Auditability. Temporal tracking provides a built-in mechanism for understanding how data evolved, without external logging infrastructure.

None of these features require abandoning the simple mental model. At its core, Lattice still has fluid and crystal — mutable and immutable. Everything else is opt-in machinery for programs that need more control.

Comparison with Other Approaches

It's worth comparing this to how other languages handle mutability:

Feature Rust JavaScript Lattice
Mutability declaration let / let mut const / let fix / flux / let
Enforcement Compile-time Runtime (shallow) Runtime (deep)
Phase transitions N/A Object.freeze() freeze() / thaw() / clone()
Validation on freeze N/A N/A Crystallization contracts
Coordinated freezing N/A N/A Phase bonds
Transition observation N/A N/A Phase reactions
Phase-dependent dispatch N/A N/A Overload resolution by phase
History tracking N/A N/A Temporal values

Rust's borrow checker is more powerful for preventing data races at compile time — Lattice doesn't attempt that. JavaScript's Object.freeze() is more pragmatic but also more limited — it's shallow, provides no observation, and offers no coordination. Lattice occupies a different point in the design space: mutability as a domain concept rather than a compiler constraint.

Implementation Notes

The phase system is implemented in C as part of Lattice's tree-walking interpreter. Phase tags are stored directly on values (VTAG_FLUID, VTAG_CRYSTAL, VTAG_UNPHASED), so phase checks are single comparisons. Bonds are stored as a dynamic array of BondEntry structs on the evaluator, each mapping a target variable name to its dependencies. Reactions use a similar structure — ReactionEntry maps a variable name to an array of callback closures. Temporal tracking stores HistorySnapshot arrays containing phase names and deep-cloned values.

The deep cloning is important throughout. Contract validation receives a clone so it can't mutate the original. Reaction callbacks receive clones so observers can't interfere with each other. Temporal snapshots are clones so history is independent of current state. This means the phase system has allocation costs proportional to value size, but it also means the invariants are strong — no spooky action at a distance.

Freeze cascading through bonds is recursive, and reactions fire during cascading, so a single freeze() call can trigger an arbitrary chain of transitions and callbacks. Error propagation is straightforward: if any reaction throws, the error surfaces immediately with context about which reaction failed.

What's Next

The phase system is reaching a natural plateau in terms of core features. There are a few directions I'm considering:

Partial freezing already exists in a basic form — you can freeze individual struct fields or map keys while leaving the container mutable. Expanding this to support more granular control (freeze all fields matching a pattern, freeze a subtree) could be useful for large data structures.

Phase-aware pattern matching lets you match on phase in match expressions using ~ and * qualifiers. This is already implemented but could be extended with more complex phase patterns.

Compile-time phase inference is a longer-term goal. If the interpreter can prove that a value is always crystal by a certain point, it could skip runtime checks. This would bring some of Rust's static guarantees to Lattice without requiring explicit lifetime annotations.

For now, the phase system provides a cohesive set of tools for working with mutability as a first-class concept. Whether you're building a configuration loader that validates before committing, a pipeline that coordinates related state transitions, or a reactive system that responds to phase changes, Lattice gives you the vocabulary and the enforcement to do it declaratively.


Lattice is open source and available at lattice-lang.org. The language compiles and runs on macOS and Linux with no dependencies beyond a C11 compiler. You can try it in your browser via the playground, or clone the repo and run make && ./clat to start the REPL.