The Delta Protocol

The delta protocol is how Adama keeps connected clients in sync with document state. Instead of sending complete state snapshots on every change, it computes and transmits only what changed -- per viewer, accounting for privacy. This is the mechanism that makes real-time work at scale, and understanding it helps you design schemas that sync efficiently and debug the cases where things go sideways.

How Deltas Work Conceptually

When a client connects to a document, a synchronization relationship begins:

  1. Initial Sync: The client receives a complete JSON snapshot of their personalized view
  2. Change Detection: When document state changes, the runtime detects what's different
  3. Delta Computation: For each viewer, Adama computes the minimal delta representing their view's changes
  4. Transmission: Deltas go to clients over WebSocket connections
  5. Client Merge: Clients apply deltas to their local state

This cycle repeats for the lifetime of the connection. It's simple in concept; the magic is in making it efficient.

The State Lifecycle

Document Change
     |
     v
+-------------------+
| Compute Deltas    |---- For each connected viewer
+-------------------+
     |
     v
+-------------------+
| Apply Privacy     |---- Filter based on viewer policies
+-------------------+
     |
     v
+-------------------+
| Serialize JSON    |---- Create minimal delta message
+-------------------+
     |
     v
+-------------------+
| Send to Client    |---- Over WebSocket
+-------------------+
     |
     v
+-------------------+
| Client Merges     |---- Apply delta to local state
+-------------------+

Why This Matters

Consider a chat application with 10,000 messages. Without deltas, every new message would require sending all 10,000 messages to every client. With deltas:

Approach Data per new message
Full sync 10,000 messages (~1MB)
Delta sync 1 message (~100 bytes)

The difference is enormous. Multiply by a hundred connected clients and frequent updates, and you start to see why delta-based sync isn't optional -- it's the only approach that works.

Delta Message Format

Adama uses an extended JSON Merge Patch format (based on RFC 7386 ) with custom handling for arrays.

Simple Field Updates

For scalar fields, the delta contains only the changed values:

public int score = 0;
public string status = "waiting";
public bool active = true;

Initial state sent to client:

{"score": 0, "status": "waiting", "active": true}

After score = 42; status = "playing";:

{"score": 42, "status": "playing"}

Notice that active isn't in the delta because it didn't change. The client merges this delta with their existing state -- overwrite the fields that are present, leave everything else alone.

Nested Object Updates

For records and nested structures, deltas follow the same pattern:

record Player {
  public string name;
  public int health;
  public int mana;
}

public Player player;

Initial state:

{"player": {"name": "Alice", "health": 100, "mana": 50}}

After player.health = 75;:

{"player": {"health": 75}}

The delta includes only the path to the changed field. The client merges {"health": 75} into their existing player object. Name and mana are untouched.

Array Handling with @o

Standard JSON Merge Patch can't express array modifications well. Adama extends the format with @o (ordering):

record Item {
  public int id;
  public string name;
}

table<Item> items;

Initial state (3 items):

{
  "items": {
    "@o": ["1", "2", "3"],
    "1": {"id": 1, "name": "Sword"},
    "2": {"id": 2, "name": "Shield"},
    "3": {"id": 3, "name": "Potion"}
  }
}

The @o array specifies which elements exist and their order. Each element is keyed by its string ID.

After adding item 4:

{
  "items": {
    "@o": ["1", "2", "3", "4"],
    "4": {"id": 4, "name": "Helmet"}
  }
}

The delta includes the new @o array and only the new item. Existing items (1, 2, 3) aren't re-sent.

After deleting item 2:

{
  "items": {
    "@o": ["1", "3", "4"]
  }
}

Only the ordering changes. The client removes any item not in @o from their local state.

After reordering (reverse order):

{
  "items": {
    "@o": ["4", "3", "1"]
  }
}

Just the @o array changes; item data stays the same.

After updating item 3's name:

{
  "items": {
    "3": {"name": "Health Potion"}
  }
}

When an item updates without order change, @o isn't included. Only the changed data ships.

The @s Sequence Number

Each delta includes a sequence number for ordering:

{
  "@s": 42,
  "score": 100
}

The @s field is an incrementing counter that helps clients:

  • Detect missed deltas
  • Ensure deltas are applied in order
  • Handle reconnection and resync

Null for Deletion

To remove a field (as opposed to updating it), the value is set to null:

public maybe<string> nickname;

After setting nickname:

{"nickname": "TheChampion"}

After clearing nickname:

{"nickname": null}

The client removes the nickname field from their local state.

Privacy Filtering in Deltas

Deltas are computed separately for each viewer based on privacy policies. This is one of the things I'm most pleased with in the design -- privacy isn't bolted on, it's woven into the delta computation itself.

Per-Viewer Views

Consider a card game where each player sees only their own cards:

record Card {
  public int id;
  private principal owner;
  viewer_is<owner> int value;
}

table<Card> cards;

When card 1 (owned by Alice, value 7) and card 2 (owned by Bob, value 3) exist:

Alice's view:

{
  "cards": {
    "@o": ["1", "2"],
    "1": {"id": 1, "value": 7},
    "2": {"id": 2}
  }
}

Bob's view:

{
  "cards": {
    "@o": ["1", "2"],
    "2": {"id": 2, "value": 3},
    "1": {"id": 1}
  }
}

Both see both cards, but only their own card's value. The delta system handles all of this automatically.

Delta Privacy Implications

When Alice's card value changes from 7 to 9:

Delta to Alice:

{"cards": {"1": {"value": 9}}}

Delta to Bob:

{}

Bob receives no delta because from his perspective, nothing changed. His view of card 1 never included the value. This is exactly right -- and it means the system leaks zero information about state changes that a viewer can't see.

Record-Level Privacy

With require policies, entire records can be invisible:

record Secret {
  public int id;
  private principal owner;
  public string data;

  policy is_owner { return @who == owner; }
  require is_owner;
}

table<Secret> secrets;

If there are secrets for Alice, Bob, and Carol:

Alice's view:

{
  "secrets": {
    "@o": ["1"],
    "1": {"id": 1, "data": "Alice's secret"}
  }
}

Alice only sees her own secret. Bob and Carol's secrets don't exist in her view at all -- not hidden, not redacted, simply absent.

Policy Changes

When a policy condition changes, deltas reflect the new visibility:

record Message {
  public int id;
  public string content;
  public bool published;

  policy is_visible { return published; }
  require is_visible;
}

table<Message> messages;

Before publishing (user sees nothing):

{"messages": {"@o": []}}

After published = true:

{
  "messages": {
    "@o": ["1"],
    "1": {"id": 1, "content": "Hello world", "published": true}
  }
}

The message "appears" to the viewer when it becomes visible. From the client's perspective, it's as if the record was just created.

Efficient State Sync

The delta protocol is designed for efficiency at multiple levels.

Batching Updates

All changes within a single transaction are combined into one delta:

message GameUpdate {
}

public int score = 0;
public int level = 1;
public int lives = 3;
public string status = "waiting";

channel updateGame(GameUpdate msg) {
  score += 10;
  level++;
  lives = 3;
  status = "playing";
}

This produces one delta, not four:

{
  "@s": 15,
  "score": 110,
  "level": 5,
  "lives": 3,
  "status": "playing"
}

Differential Computation

The runtime maintains the previous view for each client and compares to compute minimal deltas:

Previous View: {"x": 1, "y": 2, "z": 3}
Current View:  {"x": 1, "y": 5, "z": 3}
Delta:         {"y": 5}

Only actual differences are transmitted. If you set a field to its current value, no delta is generated for that field.

Structural Sharing

Complex nested structures share unchanged portions:

record Config {
  public Settings general;
  public Settings advanced;
}

record Settings {
  public int value1;
  public int value2;
  public int value3;
}

If only config.general.value1 changes, the delta is:

{"config": {"general": {"value1": 42}}}

The advanced settings and other general values aren't included.

Connection-Level Optimization

Each connection maintains its own sync state:

  • View state: The client's current view variables
  • Previous snapshot: What the client last saw
  • Pending deltas: Changes not yet acknowledged

This allows the server to compute exactly what each client needs without redundant work.

Client-Side Delta Application

Clients must correctly apply deltas to maintain consistent state. Here's the algorithm.

Basic Merge Algorithm

function applyDelta(current, delta) {
  for (const key in delta) {
    if (key === '@s') continue;  // Sequence number, not data

    const value = delta[key];

    if (value === null) {
      // Deletion
      delete current[key];
    } else if (typeof value === 'object' && !Array.isArray(value)) {
      // Nested object - check for array (@o)
      if ('@o' in value) {
        current[key] = applyArrayDelta(current[key] || {}, value);
      } else {
        // Regular object merge
        current[key] = current[key] || {};
        applyDelta(current[key], value);
      }
    } else {
      // Scalar replacement
      current[key] = value;
    }
  }
  return current;
}

Array Delta Application

function applyArrayDelta(current, delta) {
  // Apply updates to existing/new items
  for (const key in delta) {
    if (key === '@o') continue;
    if (delta[key] === null) {
      delete current[key];
    } else {
      current[key] = current[key] || {};
      applyDelta(current[key], delta[key]);
    }
  }

  // Apply ordering if present
  if ('@o' in delta) {
    const newOrder = delta['@o'];
    const ordered = [];

    // Remove items not in new order
    for (const key in current) {
      if (key !== '@o' && !newOrder.includes(key)) {
        delete current[key];
      }
    }

    current['@o'] = newOrder;
  }

  return current;
}

Converting to Arrays

For UI rendering, convert the object format to arrays:

function toArray(objWithOrder) {
  const order = objWithOrder['@o'] || [];
  return order.map(id => objWithOrder[id]).filter(Boolean);
}

// Usage
const itemsObject = state.items;
const itemsArray = toArray(itemsObject);  // Ready for rendering

Handling Reconnection

When a client reconnects, they may have missed deltas:

  1. Client sends their last known sequence number
  2. Server checks if it can send incremental deltas
  3. If not (too many missed), server sends full resync
  4. Client replaces state with new snapshot
function handleMessage(message) {
  if (message.type === 'delta') {
    if (message['@s'] === expectedSequence) {
      applyDelta(state, message);
      expectedSequence++;
    } else {
      // Missed deltas, request resync
      requestResync();
    }
  } else if (message.type === 'sync') {
    // Full state replacement
    state = message.state;
    expectedSequence = message['@s'] + 1;
  }
}

Debugging Delta Issues

When state sync goes wrong, systematic debugging is your friend. I've chased enough of these bugs to have a process.

Common Symptoms

Symptom Likely Cause
Client shows stale data Delta not applied, missed delta
Data appears then disappears Privacy policy flapping
Array order wrong @o not being processed
Fields missing Null delta not handled
Duplicate entries ID collision, merge error

Enable Delta Logging

Log deltas on the client to see exactly what's coming in:

connection.onDelta = (delta) => {
  console.log('Delta received:', JSON.stringify(delta, null, 2));
  applyDelta(state, delta);
};

This is always my first step. See the raw deltas, understand what the server thinks it's sending.

Verify Server-Side View

Use the CLI or API to inspect what the server thinks a viewer should see:

# Get current view for a specific viewer
adama document view --space myspace --key mydoc --who "user:alice"

Compare this with what the client has locally. If they match, your delta application is fine and the bug is elsewhere. If they don't match, you've got a merge issue.

Check Privacy Policies

If data isn't visible when expected, verify the privacy policy:

record Secret {
  private principal owner;
  public string debug_owner;  // Temporarily expose for debugging

  policy can_see {
    // Add logging or simplify to isolate issue
    return @who == owner;
  }
}

Inspect Formula Dependencies

If derived values aren't updating, check formula dependencies:

public int a = 1;
public int b = 2;
public formula sum = a + b;

// Is 'sum' in the client's subscribed view?
// Is the formula being invalidated when a or b changes?

Sequence Number Gaps

If clients fall out of sync, check for sequence gaps:

let lastSequence = -1;

connection.onDelta = (delta) => {
  const seq = delta['@s'];
  if (lastSequence !== -1 && seq !== lastSequence + 1) {
    console.warn(`Sequence gap: expected ${lastSequence + 1}, got ${seq}`);
  }
  lastSequence = seq;
};

Network Issues

Delta problems can stem from network issues:

  • High latency: Deltas arrive late, UI feels sluggish
  • Packet loss: Deltas missed, requires resync
  • Connection drops: Full resync needed on reconnect

Monitor connection health:

connection.onDisconnect = () => {
  console.log('Connection lost, will resync on reconnect');
};

connection.onReconnect = () => {
  console.log('Reconnected, awaiting resync');
};

Advanced Delta Patterns

Optimistic Updates

Clients can apply changes optimistically before server confirmation:

function sendMessage(content) {
  // Optimistic local update
  const tempId = 'temp-' + Date.now();
  localState.messages[tempId] = {content, pending: true};

  // Send to server
  connection.send('addMessage', {content});
}

connection.onDelta = (delta) => {
  applyDelta(state, delta);

  // Remove temp messages when real ones arrive
  for (const id in state.messages) {
    if (id.startsWith('temp-') && !state.messages[id].pending) {
      delete state.messages[id];
    }
  }
};

Delta Compression

For high-frequency updates, consider client-side delta compression:

let pendingDelta = null;
let applyTimeout = null;

connection.onDelta = (delta) => {
  // Merge into pending
  pendingDelta = mergeDelta(pendingDelta || {}, delta);

  // Debounce application
  clearTimeout(applyTimeout);
  applyTimeout = setTimeout(() => {
    applyDelta(state, pendingDelta);
    pendingDelta = null;
    render();
  }, 16);  // ~60fps
};

View State Optimization

Minimize view state changes to reduce delta computation:

// Wasteful: Updates view on every scroll pixel
window.onscroll = () => {
  connection.updateView({scrollY: window.scrollY});
};

// Better: Debounce and quantize
let lastReportedScroll = 0;
window.onscroll = debounce(() => {
  const quantized = Math.floor(window.scrollY / 100) * 100;
  if (quantized !== lastReportedScroll) {
    connection.updateView({scrollY: quantized});
    lastReportedScroll = quantized;
  }
}, 100);

The delta protocol is designed to be invisible in normal operation -- clients see their state update in real time and never think about it. But when you're building complex applications, or when something's not working the way you expect, understanding these internals is what lets you reason about the problem instead of just staring at it.