Polymorphic Synchronization

CLEAR's synchronization model separates the shape of the data from the strategy used to protect it.

A function can say:

That is Polymorphic Synchronization.

Instead of baking Mutex<T>, RwLock<T>, Versioned<T>, or AtomicPtr<T> into every API boundary, CLEAR keeps the function parameter data-shaped and puts synchronization strategy at the declaration site.

See Sharing Capabilities for the ownership and capability model this builds on.

See Tense Compositions for how capabilities compose with failable and temporal types.

The Core Idea

The same function body can work across multiple storage strategies:

STRUCT Counter { value: Int64 }

FN tick!(MUTABLE c: SHARED Counter) RETURNS !Void ->
  WITH POLYMORPHIC c AS x {
    x.value = x.value + 1;
  }
  RETURN;
END

The binding passed to tick! chooses the implementation:

MUTABLE local_c     = Counter{ value: 0 } @local;
MUTABLE locked_c    = Counter{ value: 0 } @shared:locked;
MUTABLE wlocked_c   = Counter{ value: 0 } @shared:writeLocked;
MUTABLE versioned_c = Counter{ value: 0 } @shared:versioned;
MUTABLE atomic_c    = Counter{ value: 0 } @indirect:atomic;

tick!(local_c)     OR DIE;
tick!(locked_c)    OR DIE;
tick!(wlocked_c)   OR DIE;
tick!(versioned_c) OR DIE;
tick!(atomic_c)    OR DIE;

The compiler lowers each call to the strategy that matches the actual binding:

Binding strategyLowering shapeCost model
Plain T, @local, @multiowned, @shared, @indirectDirect pointer accessNo synchronization
@locked, @shared:lockedMutex acquire / releaseBlocking
@writeLocked, @shared:writeLockedWrite-lock acquire / releaseBlocking
@versioned, @shared:versionedSnapshot update with bounded retryContention / retry
@indirect:atomicAtomic pointer update with bounded retryContention / retry

The source stays data-shaped. The compiled code is strategy-shaped.

Why It Matters

In Rust, changing Arc<RwLock<T>> to an MVCC cell or actor usually changes the API shape and the function signatures above it. In Go, changing from a mutex to a channel or shared-nothing layout tends to move synchronization policy through the program by convention.

In CLEAR, the common refactor is declaration-site:

MUTABLE sessions = SessionMap{ ... } @shared:locked;

# Later, after profiling:
MUTABLE sessions = SessionMap{ ... } @shared:versioned;

The functions that only pass the value through do not change. The functions that actually cross a synchronization gate use WITH, so the blocking, retrying, and failure points remain visible.

The practical benefits are:

REQUIRES Families

For public contracts, a function can constrain which synchronization families it accepts with REQUIRES.

FN bump!(MUTABLE c: Counter)
  RETURNS !Void
  REQUIRES c: SNAPSHOTTED
->
  WITH SNAPSHOT c AS MUTABLE x {
    x.value = x.value + 1;
  }
  RETURN;
END

Current families:

FamilyAdmitsUse when
LOCKED@locked, @writeLocked, shared locked formsThe body can block while holding a lock
SNAPSHOTTED@versioned, @indirect:atomicThe body can run as a retryable snapshot transaction
VERSIONED@versioned onlyMVCC behavior is required
ATOMIC@indirect:atomic for structsAtomic pointer behavior is required
LOCALPlain T, @local, @multiownedThe body should lower to direct local access

LOCKED and SNAPSHOTTED are polymorphic families because each admits more than one storage axis. VERSIONED and ATOMIC are narrow families used when the exact strategy matters.

ACTOR, BUFFERED, and distributed actor-style capabilities are design targets, but they are not part of the current public synchronization polymorphism surface.

WITH POLYMORPHIC

Use WITH POLYMORPHIC when the body must work for more than one possible storage strategy.

FN addFee!(MUTABLE acct: SHARED Account)
  RETURNS !Void
  REQUIRES acct: LOCKED
->
  WITH POLYMORPHIC acct AS a {
    a.balance = a.balance - 1.0;
  }
  RETURN;
END

The compiler enforces the shape:

This rule makes polymorphism visible at the synchronization boundary. If a function can dispatch across strategies, the WITH block says so.

Snapshot-Style Non-Polymorphic Synchronization

Snapshot-style strategies use WITH SNAPSHOT.

FN updateConfig!(MUTABLE cfg: Config@shared:versioned) RETURNS !Void ->
  WITH SNAPSHOT cfg AS MUTABLE c {
    c.port = c.port + 1;
  }
  RETURN;
END

The above accepts only MVCC.

But SNAPSHOTTED polymorphic syncronization can accept both MVCC and atomic-pointer cells:

FN updateConfig!(MUTABLE cfg: SHARED Config)
  RETURNS !Void
  REQUIRES cfg: SNAPSHOTTED
->
  WITH POLYMORPHIC cfg AS MUTABLE c {
    c.port = c.port + 1;
  }
  RETURN;
END
MUTABLE mvcc_cfg   = Config{ port: 8080 } @versioned;
MUTABLE atomic_cfg = Config{ port: 8080 } @indirect:atomic;

updateConfig!(mvcc_cfg) OR RAISE;    # may surface MvccConflict
updateConfig!(atomic_cfg) OR RAISE;  # may surface AtomicConflict

The body must be safe to retry. Fallible work inside a retryable body is rejected because retrying the transaction would re-run the fallible operation. Move parsing, I/O, and other fallible work outside the WITH SNAPSHOT or universal WITH POLYMORPHIC body.

Error Projection

Polymorphic synchronization does not mean every caller sees every possible error.

The compiler projects the error set at each call site:

Actual bindingPossible synchronization error
@locked, @writeLockedLockTimeout
@versionedMvccConflict
@indirect:atomicAtomicConflict

Example:

FN tick!(MUTABLE c: Counter)
  RETURNS !Void
  REQUIRES c: SNAPSHOTTED
->
  WITH SNAPSHOT c AS MUTABLE x {
    x.value = x.value + 1;
  }
  RETURN;
END

MUTABLE mvcc_c = Counter{ value: 0 } @versioned;
MUTABLE atomic_c = Counter{ value: 0 } @indirect:atomic;

tick!(mvcc_c);    # caller sees MvccConflict
tick!(atomic_c);  # caller sees AtomicConflict

Forwarding preserves the same narrowing. If a wrapper function accepts only VERSIONED and forwards to a broader SNAPSHOTTED function, the inner call still projects to MvccConflict, not the full snapshotted union.

SYNC POLICY

Most retryable synchronization failures can be handled by a program-level policy:

SYNC POLICY START
  ON LockTimeout RETRY(3) THEN RAISE
  ON MvccConflict RAISE
  ON AtomicConflict RAISE
END

Precedence is:

  1. Per-WITH ON ... handler.
  2. Program SYNC POLICY START ... END.
  3. Baked-in system default.

A per-WITH handler is the most local override:

WITH
  SNAPSHOT cfg AS MUTABLE c {
    c.port = c.port + 1;
  }
  ON MvccConflict RETRY(2) THEN RAISE

SYNC POLICY is intentionally limited:

Deadlock and LockCycle are inline-only. If the compiler cannot prove the lock graph is safe, the WITH site must make the risk explicit:

WITH
  POLYMORPHIC POSSIBLE_DEADLOCK a AS x {
    someLockingFunc(x, b);
  }
  ON Deadlock RAISE

The opt-out belongs beside the code that can produce the cycle.

CLEAR automatically sorts locks, so you can only encounter possible deadlock when calling a locking function inside the critical section.

WITH
  EXCLUSIVE a AS x,
  EXCLUSIVE b AS y {
    someFunc(x, y);
  }

This can never deadlock.

Note

In STRICT mode, all possible synchronization failures must be handled inline.

Multi-Object Transactions

Multiple locked or versioned cells can be synchronized together when the family supports that consistency model:

FN transfer!(MUTABLE a: SHARED Account, MUTABLE b: SHARED Account, amount: Float64)
  RETURNS !Void
  REQUIRES a, b: LOCKED | VERSIONED
->
  IF amount <= 0 -> RAISE Input, TransactionFailure, "Invalid Amount, must be positive";

  WITH
    POLYMORPHIC a AS acctA,
    POLYMORPHIC b AS acctB {
      IF acctA.balance < amount -> RAISE Input, TransactionFailure, "Balance too low";

      acctA.balance -= amount;
      acctB.balance += amount;
    }
  RETURN;
END

Atomic-pointer cells do not support multi-object consistency. A multi-binding WITH that admits ATOMIC is rejected. Narrow the contract to LOCKED / VERSIONED, or split the operation into single-cell updates if that is the correct behavior.

Relationship to WITH MATCH

WITH MATCH is the older explicit per-family dispatch form:

WITH c AS x MATCH
  WHEN VERSIONED -> { result = x.value; }
  WHEN LOCKED    -> { result = x.value; }
END

Use WITH POLYMORPHIC when the body is genuinely the same across compatible families. Use WITH MATCH when the implementation must differ by family.

Current Coverage

The current implementation is covered by:

There is not a dedicated checked-in fuzz corpus for polymorphic sync today. The relevant protection is mostly unit specs plus targeted transpile tests.

Mental Model

Think of a synchronized value as three separate things:

Most functions should care about the type. A few functions care about the gate. Very few functions should care about the exact strategy.

Polymorphic Synchronization is what lets those three concerns stay separate.

Source: docs/polymorphic-synchronization.md