DEV Community

Robin
Robin

Posted on

Make Replica Agreement a Commit Fence Before Planner Side Effects Escape

Picture a refund that landed twice before anyone opened the planner log that the team had promised would be authoritative. One replica had labeled the invoice a duplicate while the production path still wanted a human fence. Which event actually authorized the money movement, and why did the cheaper path win that race? I keep reconstructing this order when teams bolt a second model onto an agent without a commit protocol.

Have you ever watched a cheaper replica outrun the path you actually trust and then mutate production state? I reconstruct that review as a planner that treated model output as an executable cheque. The common implementation fans the same prompt to two models, then commits whichever answer arrives first. That event order violates the only invariant that makes dual-path inference safer than a single guess.

The violating event order

Consider this sequence on a single invoice identifier, because identifiers are the only denominator later audits can count. Replica vote V_r arrives with digest D1 and a tool call that refunds invoice nine immediately. Production vote V_p is still in flight when the orchestrator, starved for latency, dispatches that tool. V_p later arrives with digest D2, which holds for review instead of moving any money.

The money has already moved, so the second vote becomes a log line that nobody pages on. Does your orchestrator even record that the two digests disagreed after that side effect already escaped? Most traces I review store the winning tool args and drop the losing vote as cancelled work. That is how a replica becomes an authority instead of a witness in the commit path.

The invariant common implementations drop

I want a boring invariant, not another slogan about being careful with probabilistic agents in production. Here it is, stated as a commit rule the orchestrator must preserve under reordering and retry. If your fencer cannot say this out loud, it is not a fencer yet.

Invariant I1. A side-effecting tool may enter COMMITTED only if digest(replica) == digest(production) for the same (run_id, step_id, epoch), or a named fence token authorizes a single-path exception for that epoch.

Everything else in that control loop remains a proposal until the fencer records agreement or an explicit grant. A first-arriving vote is not a lease, and it must not unlock the tool gateway by itself. A 200 from a tool gateway is not agreement, even when the payload looks like the replica's prose. If I1 does not hold, dual-path inference is just a latency race with extra cloud spend.

Declared assumptions

I am reviewing a planner that already has idempotent tool adapters and a durable log, not a chatbot. The replica path is untrusted for authorization even when it is cheaper, faster, or locally hosted. Side-effecting tools are the ones that move money, send mail, mutate tickets, or delete durable data. Read-only retrieval can skip the fence, which is an explicit allow-list rather than a silent default.

Clocks are skewed in this design, but epochs stay monotonic per run identifier across those retries. I assume at-least-once delivery of votes and tool results, because that is the network we actually have. I do not assume either model is calibrated, honest, or stable across semantically equal retries at all. Those are research claims I cannot cash at commit time, so the fencer must ignore them.

This writeup is a proposed protocol with an executable simulator, not a war story from a named employer. Treat the numbers in the harness as counts of traces, not as capacity planning for a region. If your production planner lacks a durable log, stop here and add that log before any replica.

Constraints, data flow, and failure domains

The constraint that actually bites is not token volume but the gap between first vote and irreversible effect. If that gap is shorter than the slower path, I1 is already lost unless the orchestrator buffers the tool. Throughput then becomes a function of how many steps sit in PROPOSED without leaking into gateways. That is a load model you can measure, unlike a hope that both models will usually agree.

Data flow, in the design I want, looks like four hops rather than a straight prompt-to-tool line. The planner emits a Proposal with a canonical action digest or an empty digest still awaiting votes. Two inference paths return Vote records that cite that digest or else a competing canonical digest. A fencer records agreement, explicit reject, or a time-bounded single-path grant before anyone may emit Commit.

prompt -> Proposal(run, step, epoch, digest?)
       -> Vote_replica  \
       -> Vote_prod     / -> Fence(agree|reject|grant) -> Commit -> Tool
Enter fullscreen mode Exit fullscreen mode

Only the fencer may emit Commit, and the tool adapter refuses payloads without a commit token for that epoch. Failure domains split along that fence, and that split is the entire point of this architecture review. Replica outage should degrade to reject or grant, never to an implicit commit from whoever answered. Production-path timeout should not let the replica autocommit just because it was cheaper to host.

Tool timeout after Commit is a different domain and needs idempotent retry, not a second model vote. Log loss after Commit but before ack is why the token must be replayable without changing the digest. Would you put the fencer in the planner process beside the replica worker on the same box? I would not, because planner crashes are correlated with the replica host when both share that machine.

The fencer belongs in the same failure domain as the durable log, not in the prompt loop. That sounds heavier than a function call, and it is, because I1 is a durability property. If you keep the fencer in memory only, every restart is an implicit expired grant waiting to happen.

Sequence model

The happy path is agreement before dispatch, and it should look almost boring in the trace viewer. The interesting path is divergence, and that is what the simulator exists to force on every review. If the replica vote is allowed to touch the gateway before the trusted path answers, the diagram is already a postmortem.

Can you point to the line in your code that refuses that edge when the first vote looks confident? If you cannot, the rest of this article is a description of a bug you are currently shipping. I would rather find that line in a fixture than in a ledger reconciliation three days later.

sequenceDiagram
    participant P as Planner
    participant R as Replica path
    participant T as Trusted path
    participant F as Fencer
    participant G as Tool gateway
    P->>R: Proposal(epoch=7)
    P->>T: Proposal(epoch=7)
    R->>F: Vote(digest=D1)
    Note over F: still PROPOSED
    T->>F: Vote(digest=D2)
    F->>P: Reject(divergence)
    Note over G: no Commit token, no refund

Visible divergence must override a stale break-glass grant, because a fence that launders disagreement is replica autocommit in nicer clothes. I would rather reject and replay into a fresh epoch than pretend the two digests were close enough. Are you averaging prose when the hashes differ, or are you actually fencing?

A dual-path fence you can execute

I want a fixture small enough to run in one file, because architecture reviews that cannot fail a test are just opinions. The state machine below is a proposal. It encodes I1, records every injected reorder, and counts escapes against a denominator of traces.

  1. Encode votes as digests, not prose, because a compare operation cannot run on a chat transcript without a canonical form.
  2. Buffer side-effecting calls until the fence speaks, and make the gateway no-op unless a commit token cites this epoch.
  3. Treat divergence as reject, not as a retry of the winner, because retrying the first digest launders the race into success.
  4. Grant single-path execution only with an explicit expiring fence, so on-call break-glass stays named, logged, and epoch-scoped.

Here is the simulator I want reviewers to run, labeled as a proposal rather than as production code.

#!/usr/bin/env python3
"""Dual-path planner fence simulator. Proposal, not production code."""
from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from hashlib import sha256
from typing import Optional
import json


class Phase(Enum):
    IDLE = "idle"
    PROPOSED = "proposed"
    AGREED = "agreed"
    FENCED = "fenced"
    COMMITTED = "committed"
    REJECTED = "rejected"


def digest(action: dict) -> str:
    blob = json.dumps(action, sort_keys=True, separators=(",", ":"))
    return sha256(blob.encode()).hexdigest()[:16]


@dataclass
class Vote:
    path: str
    epoch: int
    action: dict

    @property
    def digest(self) -> str:
        return digest(self.action)


@dataclass
class Fence:
    epoch: int
    grant_single_path: bool = False
    expired: bool = False


class DualPathFencer:
    def __init__(self) -> None:
        self.phase = Phase.IDLE
        self.epoch = 0
        self.votes: dict[str, Vote] = {}
        self.fence: Optional[Fence] = None
        self.committed_digest: Optional[str] = None
        self.escapes = 0  # side effects without I1

    def propose(self, epoch: int) -> None:
        self.epoch = epoch
        self.phase = Phase.PROPOSED
        self.votes.clear()
        self.committed_digest = None
        self.fence = None

    def accept_vote(self, vote: Vote) -> None:
        if vote.epoch != self.epoch or self.phase not in {
            Phase.PROPOSED,
            Phase.AGREED,
            Phase.FENCED,
        }:
            return
        self.votes[vote.path] = vote
        self._recompute()

    def grant_fence(self, expired: bool = False) -> None:
        if self.phase != Phase.PROPOSED:
            return
        self.fence = Fence(epoch=self.epoch, grant_single_path=True, expired=expired)
        self._recompute()

    def try_commit(self, gateway_dispatch: bool) -> bool:
        allowed = self.phase in {Phase.AGREED, Phase.FENCED}
        if self.fence and self.fence.expired:
            allowed = False
        if gateway_dispatch and not allowed:
            self.escapes += 1
            return True
        if gateway_dispatch and allowed:
            self.phase = Phase.COMMITTED
            self.committed_digest = self._chosen_digest()
            return True
        return False

    def _chosen_digest(self) -> Optional[str]:
        if "replica" in self.votes and "prod" in self.votes:
            if self.votes["replica"].digest == self.votes["prod"].digest:
                return self.votes["replica"].digest
        if self.fence and self.fence.grant_single_path and not self.fence.expired:
            winner = self.votes.get("prod") or self.votes.get("replica")
            return winner.digest if winner else None
        return None

    def _recompute(self) -> None:
        r, p = self.votes.get("replica"), self.votes.get("prod")
        if r and p:
            # Visible divergence wins over a grant. A fence must not launder D1 vs D2.
            self.phase = Phase.AGREED if r.digest == p.digest else Phase.REJECTED
            return
        if self.fence and self.fence.grant_single_path and not self.fence.expired and self.votes:
            self.phase = Phase.FENCED
            return


def i1_holds(f: DualPathFencer) -> bool:
    if f.phase != Phase.COMMITTED:
        return f.escapes == 0
    r, p = f.votes.get("replica"), f.votes.get("prod")
    agreed = bool(r and p and r.digest == p.digest)
    fenced = bool(f.fence and f.fence.grant_single_path and not f.fence.expired)
    return (agreed or fenced) and f.escapes == 0


REFUND = {"tool": "refund", "invoice": 9, "cents": 4200}
HOLD = {"tool": "hold_for_review", "invoice": 9}


def run_trace(order: str, diverge: bool, early_dispatch: bool, grant: bool, expired: bool) -> dict:
    f = DualPathFencer()
    f.propose(epoch=7)
    prod_action = HOLD if diverge else REFUND
    events = []
    if grant:
        events.append(("grant", None))
    events.append(("replica", Vote("replica", 7, REFUND)))
    events.append(("prod", Vote("prod", 7, prod_action)))
    if order == "prod_first":
        events = [e for e in events if e[0] != "replica"] + [e for e in events if e[0] == "replica"]
    dispatched = False
    for kind, payload in events:
        if kind == "grant":
            f.grant_fence(expired=expired)
        else:
            f.accept_vote(payload)
        if early_dispatch and not dispatched:
            dispatched = f.try_commit(gateway_dispatch=True)
    if not dispatched:
        f.try_commit(gateway_dispatch=True)
    return {
        "order": order,
        "diverge": diverge,
        "early_dispatch": early_dispatch,
        "grant": grant,
        "expired": expired,
        "phase": f.phase.value,
        "escapes": f.escapes,
        "i1": i1_holds(f),
    }


def main() -> None:
    traces = []
    for order in ("replica_first", "prod_first"):
        for diverge in (False, True):
            for early in (False, True):
                for grant in (False, True):
                    for expired in (False, True):
                        traces.append(run_trace(order, diverge, early, grant, expired))
    n = len(traces)
    escapes = sum(1 for t in traces if t["escapes"] > 0)
    # Denominator is injected traces, not requests per second.
    print(f"traces={n} escape_rate={escapes}/{n}")
    p1_fail = [
        t for t in traces
        if t["diverge"] and not (t["grant"] and not t["expired"]) and t["phase"] == "committed"
    ]
    print(f"P1_divergent_commits={len(p1_fail)}/{n}")
    assert not p1_fail, p1_fail[:3]
    print("P1 passed: divergence did not commit without a live fence")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it as a review fixture, not as a benchmark against some unpublished latency target.

python3 dual_path_fence.py
Enter fullscreen mode Exit fullscreen mode

You should see a finite trace count, an escape rate against that denominator, and a P1 assertion that divergent votes cannot reach COMMITTED without a live fence. Early dispatch in PROPOSED is supposed to increment escapes; that is the bug the harness is counting, not a flake. If P1 fails, the fencer is theater.

Injected failures and testable properties

I inject four classes, because "the model was wrong" is not a failure class that a fencer can close. Each class has to move a phase or increment escapes, or it is not actually injected.

  1. Reorder. Replica vote precedes production vote, and the opposite order must leave I1 indifferent if both digests still match.
  2. Divergence. Digests disagree, so the system must reject or wait, and it must not average the two blobs of prose.
  3. Early dispatch. The gateway is called while phase is still PROPOSED, which must increment escapes rather than mint a commit token.
  4. Expired grant. Break-glass tokens that outlive the epoch are indistinguishable from replica autocommit, so they must fail closed.

Acceptance rule, with an explicit denominator: over the Cartesian product of those flags, P1_divergent_commits must be 0/N traces. Any early_dispatch that fires in PROPOSED must be counted as an escape, not hidden in a retry metric. I do not accept "it is rare in staging" as a substitute for that fraction.

Tradeoffs

Choice What you keep What you pay When it fails
First-vote commit Low latency I1 Slow path disagrees after money moves
Dual-path agree I1 under reorder Tail latency of the slower vote Correlated model bugs that share a digest
Epoch fence grant Availability under one-path outage Human or policy authority Expired or overly broad grants
Reject on timeout Safety More holds for review On-call load during replica brownouts

Correlated model bugs are the honest hole in this architecture, and I will not paper over them with a second prompt template. If both paths are prompted identically and share pretraining residue, agreement is not independent evidence. That is why I still want property tests on the fencer even after two models say refund.

Where a disposable replica earns its keep

I need a replica that I am willing to crash, starve, and partition without touching production credentials. Disclosure: This article was prepared as part of MonkeyCode's product outreach. When I am validating I1, MonkeyCode's free model access and free server option are a convenient place to host that untrusted replica path, not a second production control plane.

The replica's job is to emit votes I can reorder against the trusted path inside the fixture above. If you already have a spare inference box, use that instead and keep the same digest protocol. The protocol does not care who hosts the witness, only that the witness cannot authorize Commit.

What I would change next

I would split read-only tools from side-effecting tools in the adapter, because the current allow-list is a comment waiting to rot. I would attach the commit token to a hashed canonical payload so the gateway can refuse mutated args after the fence. I would add a third vote only after measuring correlated disagreement, because extra paths that share a prompt template are not extra failure domains.

I would also stop logging votes as chat transcripts and store (path, epoch, digest, received_at) instead, with prose in cold storage. Have you tried to property-test a transcript without first turning it into a digest? I have, and the test becomes another summarizer that cannot fail closed. Next after that, I would fence the fencer's own grants behind the durable log's failure domain, so a planner restart cannot resurrect epoch six.

Limitations, and who should not use this

Do not use this fence if your tools are not idempotent, because COMMITTED plus a lost ack still retries the same money movement. Do not use it if you cannot canonicalize action arguments, because JSON key order will fake divergence and train operators to ignore rejects. Do not use it as a safety story for medical, legal, or unsupervised financial agents; I1 is a commit protocol, not a regulator.

The simulator does not measure model quality, cost, or latency percentiles, and I will not pretend a trace count is a capacity number. It will not stop two correlated models from agreeing on a bad refund. It will stop the cheaper path from winning a race you never declared as a protocol.

Which event order should we refuse?

Take invoice nine again and keep the denominator honest. Replica votes refund at epoch seven, production is silent, and a stale fence from epoch six still sits in memory. Should the system reject, replay the proposal into epoch seven, or compensate after the gateway already fired?

I want the reject, then a replay into a fresh epoch, and compensation only if an escape was recorded against that denominator. If your current agent would refund first and write a nice explanation later, the invariant is already gone. Which event order breaks I1 in your own log, and which of those three verbs does your fencer actually implement?

Top comments (0)