scrml.dev v0.7.1
Articles › The how-it-works

Mutability contracts

authored by claude, rubber stamped by Bryan MacLee

TL;DR: in mainstream stacks, "who can write to this value" is folklore. In scrml it's a compile error. Five different layers of write-authority are tracked statically: derived cells refuse direct writes, server-authority cells reject client writes, schema columns gate on protect=, channel state is auto-shared by structural placement, and one-shot linear values are consumed exactly once. None of this requires a runtime convention.

Every codebase I've worked in had the same conversation at least once: someone wrote to a value they shouldn't have written to, the bug surfaced three pull requests later, and the fix was a comment. Sometimes a comment plus a lint rule. Sometimes a comment plus a lint rule plus a runtime assertion. The value itself was still writable. The "you can't write to this" lived in social convention.

The convention works fine if the team is small and the codebase is young. It collapses on cross-team handoff, on agent-assisted refactors, on grep-driven debugging. "Where was this set" stops being a question with a finite answer. The whole codebase is the answer.

scrml's bet is that write-authority is a type-system question, not a convention question. Five layers; each one a compile-time refusal; none of them opt-in.

Layer 1: derived cells refuse direct writes

A derived cell is a binding whose value is a function of other reactive bindings. The compiler tracks the deps, recomputes on dep change, caches the result.

<count> = 0
const <doubled> = @count * 2

// later, in a handler:
@doubled = 42         // compile error: E-DERIVED-WRITE (§6.6.8)

That's not a lint. It's not a deprecation warning. It's a compile error. The grammar accepts the write site syntactically; the resolver knows doubled was declared with const and is a Shape-3 cell per the state-shape model (SPEC §6.6); the assign-into-derived is rejected before codegen runs.

The follow-on rule is also a compile error: mutating the value through its reference doesn't work either. A derived array isn't a mutable bucket; calling .push() on it doesn't compile.

<items> = [1, 2, 3]
const <doubled> = @items.map(x => x * 2)

@doubled.push(8)      // also a compile error

The honest reason for the second rule: even if the JS engine would technically let the push happen, the next dep-fire would recompute @doubled from scratch and clobber the pushed value. Catching it at compile time is structural — you can't silently lose work to a race against a dep recomputation.

Mutate the upstream cell instead. The compiler knows where to look: @items = [...@items, 4] in a handler; the derived cell recomputes automatically; the new value propagates to consumers. The write site stays where it belongs — on the cell that owns the value.

Layer 2: server-authority cells reject client writes

The state authority model (SPEC §52) gives any cell a write-authority tier. The default is "wherever it's declared," but the server modifier promotes a cell to server-only authority. Client code reading it is fine. Client code writing it is a compile error.

<currentUser server> = not        // server holds authority

// in a client handler:
@currentUser = newUser              // compile error: client cannot
                                    // write a server-authority cell

This is the same idea as the derived rule, applied across the network boundary. The compiler knows which functions land on the server (because it placed them there) and which fragments land in the client bundle (because it placed those too). A write site to a server-authority cell from inside a client-bundle fragment is structurally impossible.

What's missing from this picture in mainstream stacks: there is no compile-time placement step. The same function runs in either tier (or the developer is responsible for not putting database code in the client bundle by remembering not to). The check that exists is "did the build succeed" not "is this value's authority respected."

Layer 3: schema columns gate on protect=

The <schema> block describes the database. A <db> block binds a connection. The protect= attribute on <db> takes a column list and restricts those columns to declared write paths.

<schema>
  users {
    id:            integer primary key
    username:      text not null
    passwordHash:  text not null
    isAdmin:       boolean default false
  }
</>

<db src="./app.db" protect="passwordHash, isAdmin"/>

With those columns in protect=, any UPDATE or INSERT inside a regular ?{} block that touches them is a compile error. To write them you need an explicit channel (the canonical pattern is a server-only handler that's been audited and named).

Two observations worth naming:

  • Column typos surface as diagnostics, not silent ignores. protect="passwrd" (typo) doesn't quietly mean "protect nothing." The compiler reads the schema, knows the column doesn't exist, fires an error with a quick-fix offering passwordHash.
  • The check runs against the live schema. Not against a hand-authored schema file that might be out of sync. The compiler pulls column lists from the database the <db> block points at; the <schema> block is reconciled against that.

Layer 4: channel state is auto-shared by structural placement

A <channel> block (SPEC §38) declares state that's auto-synchronized across connected clients in real time. There is no @shared modifier — cells inside the channel body are shared because they're structurally inside the channel. Cells outside aren't.

<program>

  <channel name="cursors">
    <positions> = []       // synced to every connected client
  </>

  <localDraft> = ""        // NOT synced; local to this client

</>

The placement is the contract. A reader looking at the file knows @positions is shared because of where it sits, not because they remembered the team's "always add @shared on cross-client state" rule. The same write site ( @positions = […] ) does fundamentally different things depending on the enclosing scope; the compiler picks the right code path because it can see the scope.

A channel cell that someone tries to write from outside the channel's authority surface — e.g., a server-only mutation from a regular client handler — is rejected the same way the server-authority case above is rejected. Auto-share is not auto-write-anywhere.

Layer 5: linear types — one-shot consumption tracked

The lin keyword (SPEC §35) marks a value as linear — consumed exactly once. The compiler tracks consumption through control flow, function calls, closures, and async boundaries. A linear value used twice is a compile error. A linear value never used is also a compile error.

lin <token> = mintAccessToken(@user)

useToken(@token)
useToken(@token)        // compile error: lin value used twice

This is the layer most languages don't ship. It's what makes "you opened a file, you have to close it exactly once" a type-system contract rather than a runtime convention. The lin surface in scrml is for the cases where the value is a real one-shot resource — an upload token, a one-time payment intent, a database transaction handle — and accidentally double-consuming it is a real-world bug class.

What five-layer authority buys

Three concrete payoffs:

  • "Where was this set" has a finite answer. For any cell, the write sites are statically enumerable. A reader (human or LLM) can grep for all the writers without missing one to a closure-captured mutation or a dynamic property assignment.
  • Refactors are safer. Moving a value from "client-only" to "server-authority" by adding the server modifier — every write site that's now invalid becomes a compile error. You see the surface area immediately. No "I'll find them as the bugs come in."
  • LLM-assisted coding stops being a write-amplifier. A coding agent that "helpfully" mutates a derived cell, a server-only state, or a protected column hits a compile error at PR time. The diagnostic is in front of the human reviewer, not in production logs.

What it doesn't buy

The honest version, two points:

  • It doesn't replace runtime authorization. A protect= column is structurally write-gated, but the gate is "compile-time refusal of unauthorised writes" — not "runtime check that the current user is allowed." Authorization is a separate concern, layered on <auth> + per-route role chunking.
  • Linear types are not a memory model. The lin surface is about exactly-once consumption, not about ownership transfer in the Rust sense. JavaScript's garbage collector still handles memory. scrml's lin is a smaller, more targeted contract: you said you'd consume this once; the compiler holds you to it.

The shape of the bet

Every one of these layers is a refusal. The grammar accepts a write site that the resolver then rejects. That asymmetry is intentional — the grammar's job is to express what programmers want to express; the resolver's job is to refuse the things that don't typecheck against the authority model. Both are working together to make "who can write this" a question you can answer with the cursor, not a question you can only answer with the test suite.

The bet underneath all five layers is the same: most production bugs are mutability bugs. Race conditions, partial updates, write sites in the wrong tier, optimistic concurrency that wasn't actually optimistic, mutations that should have gone through a queue. The mainstream answer is libraries on top of conventions on top of social rules. scrml's answer is the type system, the placement model, and the schema talking to each other. The compiler does the audit, every build.

None of these layers are unique to scrml in isolation. Linear types existed in Linear Haskell. Authority modifiers exist in capability languages. Per-column write gating exists in row-level-security policies. Auto-shared state exists in CRDT libraries. What's specific to scrml is that all five compose, all five are compile-time, and all five run on the same AST.