# What happens when an instance commits a speculative branch whose merge precondition was satisfied only by a sibling branch that later rolls back **Date of Record:** 2026 November 11 **Scope:** Applies to orchestrators that run multiple agent instances speculatively against a shared task graph, where branches may merge only if a precondition (a filesystem state, a database row, a message in a queue, or a lock) holds at merge time. Does not apply to strictly serialized execution, nor to speculative runs whose branches are isolated with copy-on-write state and never share observable side effects. ## Core Distinction The distinction that matters is between *precondition satisfaction* and *precondition provenance*. A merge gate typically checks a predicate — "does artifact `X` exist and equal `v`?" — and admits the branch if the predicate is true. That check is sound only if the predicate's truth is attributable to the branch's own ancestry or to a stable base. Under speculative execution with rollback, a sibling branch can transiently make the predicate true, allow a merge, and then roll back its own effects. The merge is now committed against a state that no longer exists. The failure is not a race in the usual sense (two writers interleaving on one value); it is a *dangling dependency*: the committed branch's validity is grounded in a sibling's speculative effect that was later retracted. Predicate checks pass; provenance checks would fail. ## What Was Observed A task graph with three instances: `A` (base), `B` and `C` speculative siblings, and `D` merging `B` and `C`. `B` writes a schema migration and a config key `feature.flag=true`. `C`'s task requires that flag to be true before it may merge its output. The gate on `C`'s merge reads the shared config store, sees `feature.flag=true` (written by `B`), and admits `C`. `D` then merges `B` and `C` into a single commit and marks the graph complete. Later, `B`'s branch is rolled back because its migration failed validation against a downstream consumer. The rollback removes `feature.flag` and the migration. `C`'s output remains committed and is now operating under an assumption — the flag's existence — that is false. The orchestrator reports the graph as `SUCCEEDED`; the first downstream consumer fails with a missing-config error, not at merge time but at use time, which is the worst place to discover it. A second, quieter variant: the gate on `C` read the flag from `B`'s *uncommitted working state* rather than a committed snapshot, because the config store was a shared mutable file. `B`'s rollback was a `git checkout --` on that file, which reverted the flag while `C`'s merge commit had already been written. Same dangling dependency, but no commit ever recorded `B` as the source. ## Condition of Validity The invariant that must hold for speculative branch-and-merge to be safe is: > For every merge admitted by a precondition `P`, `P` must be satisfiable by the branch's own ancestry plus the immutable base, or by a sibling whose effects are *pinned* — i.e., whose rollback is prohibited while any dependent merge is committed. Equivalently: the merge gate must evaluate `P` against a *provenance-tagged* state, not a flat value. If `P`'s truth depends on a sibling's speculative write, the gate must either (a) reject the merge until the sibling commits durably, or (b) record a hard dependency edge that makes the sibling's rollback cascade to `C`. A flat predicate check — the common implementation — satisfies neither and is unsound exactly in the window between the sibling's write and its rollback. The condition fails silently when: the shared state is a mutable file, env var, or row without versioned provenance; the gate reads live state rather than a snapshot at a known commit; or the rollback path does not consult the dependency graph before reverting. ## Decision Boundary Choose the mechanism by the cost of a false merge versus the cost of serialization: - **If siblings can make each other's preconditions true, and rollback is possible:** do not use a flat predicate gate. Use a snapshot-isolated gate that reads from a pinned commit and refuses to admit a merge whose predicate is satisfied only by uncommitted sibling state. This costs one snapshot read per merge and is the correct default. - **If the dependency is unavoidable and rollback must remain possible:** record a *pin* — a hard edge `B → C` that makes `B`'s rollback fail closed with a `DanglingDependencyError` until `C` is either re-validated or rolled back first. Rollback becomes a graph operation, not a file operation. - **If neither is feasible:** serialize the dependent pair. Speculation buys nothing when the branches are not actually independent; the graph's shape lied. The boundary is crossed the moment a sibling's write is observable to another branch's gate before the sibling has committed durably. Below that line, predicate checks are fine. Above it, they are unsound. ## Operational Effect The observed effect is a *late* failure: the graph reports success, the merge commit exists, and the inconsistency surfaces at the first downstream use, often hours later and in a different service. Debugging is expensive because the rollback that caused it is not in the failing instance's logs — it is in a sibling's. The mitigation that worked was to tag every write with `(branch_id, speculative|committed)` and make the merge gate evaluate preconditions against committed state only, raising `DanglingDependencyError` if a required predicate is satisfiable only by speculative state. Rollback was changed to walk the dependency graph and refuse to revert any branch pinned by a committed dependent. The graph now fails at merge time, loudly, which is the only place it can be cheaply fixed. ```python class DanglingDependencyError(RuntimeError): """Raised when a merge precondition is satisfied only by speculative sibling state.""" pass class StateStore: def __init__(self): # key -> list of (branch_id, status, value) self._writes = {} def write(self, branch_id, status, key, value): self._writes.setdefault(key, []).append((branch_id, status, value)) def rollback(self, branch_id): for key, entries in self._writes.items(): self._writes[key] = [e for e in entries if e[0] != branch_id] def committed_value(self, key): for branch_id, status, value in reversed(self._writes.get(key, [])): if status == "committed": return value return None def speculative_provenance(self, key): return [b for b, status, _ in self._writes.get(key, []) if status == "speculative"] def merge_gate(store, branch_id, key, expected): committed = store.committed_value(key) if committed == expected: return True speculative = store.speculative_provenance(key) if speculative: raise DanglingDependencyError( f"branch {branch_id} merge requires {key}={expected!r}, " f"but it is held only by speculative sibling(s) {speculative}; " f"commit or pin the sibling before merging" ) return False def rollback_branch(store, branch_id, pinned_by): if pinned_by.get(branch_id): raise DanglingDependencyError( f"cannot roll back {branch_id}: committed dependent(s) {pinned_by[branch_id]}" ) store.rollback(branch_id) ``` The gate now raises rather than admits when provenance is speculative, and rollback refuses to strand a committed dependent. Both failures are moved to the point where they are cheap: the merge, not the consumer.