Store Locks

Loft gives you two separate ways to protect a value from accidental changes:

1. Compile-time const: mark a variable or parameter with const and the compiler refuses to compile the writes that word forbids. The check happens before the program even runs — zero runtime cost.

2. Runtime lock: the #lock attribute on a reference lets you lock a store at runtime so that any write attempt panics immediately, even across function boundaries. This is useful for debugging: turn it on when you suspect an unexpected mutation.

struct Counter {
  value: integer
}

The two places const can go

A name has a slot, and the slot holds a value. Those are two different things to freeze, so const has two positions and they mean opposite halves:

Write both — const x: const T — and the name is fully immutable.

A plain number or boolean has no interior separate from its slot, so for those the two positions mean the same thing: both freeze it completely.

const before the TYPE is what a function uses to promise "I will not modify this". Every write through the parameter is a compile error: appending to it, writing an element, writing a field, or writing a field of a field.

fn read_value(self: const Counter) -> integer {
  self.value
}

A parameter without const can write, and the write reaches the caller.

fn increment(self: Counter) {
  self.value += 1
}
fn main() {

const local variables

const before the name is the common case for a local: a configuration value or a lookup table that should never be pointed somewhere else deep inside a long function.

  const limit = 100;
  assert(limit == 100, "const integer is readable");

For a whole number the two positions agree, so this one is frozen outright: limit = 200 and limit += 1 are both compile errors.

A const local holding a collection is the builder shape — the slot is write-once, the contents keep growing.

  const totals = [1, 2, 3];
  totals += [4];
  totals[0] = 9;
  assert(len(totals) == 4, "a const local still appends: {len(totals)}");
  assert(totals[0] == 9, "a const local still takes an element write");

The other position freezes the contents instead, and leaves the slot free.

  frozen: const vector<integer> = [1, 2, 3];
  frozen = [7, 8];
  assert(len(frozen) == 2, "a value-const local still re-points: {len(frozen)}");

frozen += [9] would not compile: the value is read-only.

The same split applies to a struct.

  const cfg = Counter {value: 42 };
  assert(cfg.value == 42, "const reference is readable");
  assert(read_value(cfg) == 42, "const passed to const param");

Runtime store locks with #lock

const is checked once, when the program is compiled. A lock is checked on every write, while the program runs — so it catches a write the compiler cannot see coming, wherever in the program it happens.

#lock is an attribute on any reference variable: a struct, a vector, any value that lives in a store. A freshly created one starts unlocked.

  d = Counter {value: 5 };
  assert(!d#lock, "new store starts unlocked");
  d#lock = true;
  assert(d#lock, "store is locked after assignment");

Reading a locked store is still fine — only writes are blocked.

  assert(read_value(d) == 5, "locked store is still readable");

A write is not. d.value = 6 panics here, and so does increment(d) — the guard is on the store, so it fires wherever the write is written. That is the point of the lock: the program stops at the write instead of carrying a value someone changed behind your back.

Set it back to false to lift the guard.

  d#lock = false;
  increment(d);
  assert(d.value == 6, "unlocking lets the write through again");

When to use each approach

get_store_lock() is the function form of the #lock attribute. Both return the same boolean.

  e = Counter {value: 99 };
  assert(get_store_lock(e) == e#lock, "function form matches attribute before lock");
  e#lock = true;
  assert(get_store_lock(e) == e#lock, "function form matches attribute after lock");
}