# Which invariants prevent silent side-effect duplication in speculative multi-agent branch-and-merge execution **Date of Record:** 2026 November 11 **Scope:** Applies to orchestrators that speculatively fork multiple agent branches from a shared checkpoint, allow each branch to perform external side effects (writes, RPCs, queue publishes, payments), and later merge the winning branch while rolling back the losers. Does not apply to pure-functional branches with no externally observable effects, nor to systems where every branch runs against an isolated transactional sandbox that is committed atomically. ## Core Distinction The distinction that matters is between *logical rollback* and *physical reversal*. A branch-and-merge engine can always discard a losing branch's in-memory state and its journal entries; it cannot always un-send an email, un-charge a card, or un-publish a Kafka message. The naive invariant — "a rolled-back branch leaves no trace" — is false the moment any branch touches a non-idempotent external system. The correct invariant is weaker and harder to enforce: *every side effect must be either (a) deferred until merge commits, or (b) idempotent under a merge-scoped key, or (c) explicitly compensated by a registered undo action that itself is idempotent.* Speculation without one of these three is not speculation; it is premature commitment with a rollback fantasy attached. ## What Was Observed A three-branch speculative planner forked at step 12 of a workflow. Branch A and Branch B both independently decided to call a `POST /notify` endpoint to inform a customer of a delay; the merge policy selected Branch A and discarded B. The rollback path replayed B's journal in reverse and invoked B's registered compensator, which issued a `POST /notify/cancel`. Because the downstream service treated cancel as "send a correction notice," the customer received three messages: two delay notices and one "ignore the previous notice" — the last of which referenced a notice the customer had received twice. The merge was logically correct and physically wrong. The failure was not in the merge logic; it was that the side-effect key was scoped to the *branch*, not to the *merge unit*, so two branches performing the "same" intended effect produced two distinct physical effects that the merge could not deduplicate. A second observation: branches that forked *after* a side effect but *before* the effect's acknowledgment were replayed from a checkpoint that predated the effect, causing the effect to be re-issued on merge. The journal recorded intent, not completion, and the checkpoint boundary did not align with the effect boundary. This is the classic at-least-once hazard reintroduced by speculation. ## Condition of Validity The merge-rollback model is valid only when all of the following hold simultaneously: 1. **Effect keying is merge-scoped, not branch-scoped.** Every outbound effect carries a deterministic key derived from `(merge_unit_id, logical_effect_id)`, where `logical_effect_id` is stable across branches that intend the same effect. Branches that intend *different* effects must have provably distinct keys. 2. **Checkpoints are effect-aligned.** A branch may only fork from a checkpoint taken after all in-flight effects have been acknowledged or definitively failed. Forking across an unacknowledged effect guarantees replay. 3. **Compensators are idempotent and total.** Every registered undo must tolerate being invoked zero, one, or many times, and must exist for every effect class. An effect class without a compensator may only run in the winning branch, after merge — never speculatively. 4. **The merge decision is monotonic.** Once a branch is selected, no later-arriving branch may un-select it without re-running the full compensation of the selected branch's effects. Late merge revisions are the dominant source of duplicate effects. ## Decision Boundary Speculate a branch if and only if every effect it can reach is class (a) deferred, class (b) idempotent under a merge-scoped key, or class (c) compensable by an idempotent registered undo. If a branch can reach an effect that is none of these — a payment capture, a physical dispatch, a one-shot webhook with no dedup — do not speculate it; serialize it behind the merge decision. The boundary is per-effect, not per-branch: a branch may be partially speculative, running cheap reads and idempotent writes speculatively while deferring the irreversible call until after merge. Treat "can this effect be safely repeated?" as a property declared at the effect definition site, not inferred at the call site, because the call site is exactly where the speculation pressure is highest and judgment is worst. ## Operational Effect Enforcing these invariants converts a class of silent, customer-visible duplicate effects into a loud, pre-merge rejection. The cost is reduced parallelism on irreversible effects and a requirement that effect definitions carry idempotency and compensation metadata. The benefit is that rollback becomes a real operation rather than a hopeful one: a discarded branch provably leaves no physical trace, because it was never permitted to make one it could not take back. Systems that skip this discipline do not fail at merge time; they fail at the customer's inbox, days later, with a support ticket that references a transaction ID the orchestrator has already garbage-collected. ```python from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Callable, Dict, Optional class SpeculationInvariantError(RuntimeError): """Raised when a branch attempts an effect that cannot be safely rolled back.""" class EffectClass(Enum): DEFERRED = "deferred" # runs only after merge commits IDEMPOTENT = "idempotent" # safe to repeat under a merge-scoped key COMPENSABLE = "compensable" # has an idempotent registered undo @dataclass(frozen=True) class EffectSpec: logical_effect_id: str effect_class: EffectClass compensator: Optional[Callable[[str], None]] = None @dataclass class Branch: branch_id: str merge_unit_id: str is_speculative: bool = True emitted_keys: Dict[str, str] = field(default_factory=dict) # logical_id -> merge-scoped key class SpeculationGuard: def __init__(self, merge_unit_id: str) -> None: self.merge_unit_id = merge_unit_id self._committed = False self._effects: Dict[str, EffectSpec] = {} def register_effect(self, spec: EffectSpec) -> None: if spec.effect_class is EffectClass.COMPENSABLE and spec.compensator is None: raise SpeculationInvariantError( f"effect {spec.logical_effect_id!r} declared compensable with no compensator" ) self._effects[spec.logical_effect_id] = spec def key_for(self, branch: Branch, logical_effect_id: str) -> str: # Merge-scoped key: identical across branches that intend the same effect. return f"{self.merge_unit_id}:{logical_effect_id}" def admit_effect(self, branch: Branch, logical_effect_id: str) -> str: spec = self._effects.get(logical_effect_id) if spec is None: raise SpeculationInvariantError( f"unregistered effect {logical_effect_id!r} reached from branch {branch.branch_id!r}" ) if not branch.is_speculative: return self.key_for(branch, logical_effect_id) if spec.effect_class is EffectClass.DEFERRED: raise SpeculationInvariantError( f"branch {branch.branch_id!r} attempted deferred effect " f"{logical_effect_id!r} before merge commit" ) if spec.effect_class is EffectClass.COMPENSABLE and not self._committed: # Allowed only if a compensator is present; caller must register it. if spec.compensator is None: raise SpeculationInvariantError( f"compensable effect {logical_effect_id!r} lacks an undo action" ) key = self.key_for(branch, logical_effect_id) branch.emitted_keys[logical_effect_id] = key return key def rollback_branch(self, branch: Branch) -> None: for logical_id in branch.emitted_keys: spec = self._effects[logical_id] if spec.effect_class is EffectClass.COMPENSABLE: assert spec.compensator is not None spec.compensator(self.key_for(branch, logical_id)) elif spec.effect_class is EffectClass.IDEMPOTENT: # No compensation needed; dedup key handles replay. continue else: raise SpeculationInvariantError( f"cannot roll back deferred effect {logical_id!r} from " f"speculative branch {branch.branch_id!r}" ) branch.emitted_keys.clear() def commit(self) -> None: self._committed = True ```