Conditional Writes Across Hubs

workspace.GetMeshNodeStream(path).Update(current => modified) is the only mesh-node mutation API, and it is safe against concurrent writers for the fields it writes. This page is about the fields it decides not to write, which is a different question and has bitten twice.

The mechanic

Where the write lands decides how the lambda is evaluated:

the node path taken when the lambda runs what travels
owned by this hub UpdateOwn on the owner's serialised write path the node
not owned by this hub IMeshNodeStreamCache.Update β†’ UpdateRemote on this hub's mirror an RFC 7396 merge patch

For the second row the lambda's output is diffed against the caller's base β€” ComputeMergePatchDiff(currentNode, updatedNode) in MeshNodeStreamHandle β€” and only the members that actually changed are sent. The owner then merges that patch.

That is exactly what makes concurrent writers safe: two candidates each removing their own key from a map produce two disjoint patches, and both land. It is also what makes the failure below possible.

🚨 The failure: an absent member is not "leave it as I found it"

Under RFC 7396 a member the patch does not carry is left untouched on the owner. From the writer's side that looks identical to "I decided not to change it" β€” but the two mean different things the moment anyone else writes that member.

So a lambda shaped like this is unsafe on a non-owned node:

// ❌ the condition is evaluated on THIS hub's mirror; the patch is applied later, on the owner
stream.Update(node =>
{
    var state = node.ContentAs<MyState>(options);
    var itIsMine = state.ClaimedBy == me && state.Status is Status.Planning;
    return node with { Content = state with
    {
        Registrations = state.Registrations.Remove(me),      // own key β€” merge-safe
        ClaimedBy = itIsMine ? null : state.ClaimedBy,       // 🚨 a CONDITION, not a value
    }};
});

When the mirror has not yet seen a claim the owner already granted, itIsMine is false, the patch carries no claimedBy at all, and the grant survives the release that was meant to undo it. Nothing errors. The write "succeeds". The field simply keeps the other writer's value, forever.

Note what is not wrong here: the removal on the line above is fine, because it is a value the lambda always writes under a key it owns. The unsafe part is the field whose presence in the patch depends on state the caller cannot see.

The rule

On a node you do not own, a lambda may write values. It may not decide, from its own mirror, that a field needs no write β€” unless that decision is correct for every state the owner might be in.

A useful test: if the field I am leaving alone had been changed by someone else a millisecond ago, would my patch still be right? If the answer is no, the condition has to move.

What to do instead β€” state a fact, let the owner act

The condition has to be evaluated where the state is current, and that is the owning hub. AGENTS.md already prescribes the shape for this and calls it out as the answer to state-machine semantics:

Set a RequestedX field and let the owning hub's watcher react.

So the caller writes something unconditional and true regardless of the owner's state, under a key it owns β€” and the owner, whose lambda is serialised against current state, decides what that implies:

// βœ… the candidate states a FACT under its own key β€” unconditional, merge-safe
stream.Update(node => node with { Content = state with
{
    Registrations = state.Registrations.Remove(me),
    StoodDown = (state.StoodDown ?? Empty).SetItem(me, DateTime.UtcNow),
}});

// βœ… …and the owner, which sees the real state, draws the conclusion
Update(node => ReleaseStoodDownClaim(node, options, now));

Three properties make this work, and all three are worth copying:

Reach for this only when a condition genuinely must hold at apply time. Most writes are values, and stream.Update handles those exactly as it appears to.

What NOT to do

Where this has actually happened

Build-claim arbitration, twice, and the second time is the clearer statement of the shape.

A follower that has seen the build's GO stands down by calling WithdrawBuildClaim. Its second half β€” hand back a claim we were granted but never started β€” is conditional on ClaimedBy naming us. The arbiter grants on the node it owns.

The measured residue was a single terminal state β€” a holder at Planning with nobody queued and the GO already published β€” differing from the healthy state in exactly one field. It is worth remembering how quiet that is: no exception, no log, no failed write, and a symptom (a rollout that never becomes ready) several layers away from the cause.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.