What distinguishes contract breach from contract frustration?

**Date of Record:** 2026 October 28 **Scope:** Applies when a contract obligation is not fulfilled by the obligated party, and the session must determine whether the non-fulfillment is a breach (unjustified failure) or frustration (impossibility due to circumstances outside the party's control), because the two require different responses. Does not apply to fulfilled obligations or situations where non-fulfillment is impossible.

Core Distinction

The separation between **non-fulfillment** (the obligation was not met) and **non-fulfillment cause** (why it was not met). Relying on non-fulfillment creates the **Breach Frustration Conflation**: the session treats all non-fulfillment as breach, escalating or penalizing even when the party was prevented from performing by circumstances outside their control—or treats all non-fulfillment as frustration, accepting breach as excused.

What Was Observed

In contract-execution agent architectures, failing to distinguish breach from frustration produces three primary failure modes:

  1. **Wrongful Penalty:** The session penalizes a party for non-fulfillment that was actually caused by frustration (impossibility, upstream failure), and the penalty is unjust.
  2. **Wrongful Excuse:** The session accepts non-fulfillment as excused when it was actually a breach, and the obligated party escapes consequences.
  3. **Ambiguity Arbitrage:** The party claims frustration for every non-fulfillment, and the session cannot distinguish legitimate impossibility from strategic non-performance.

Recorded Elements

  1. **`non-fulfillment-fact-receipt`:** The exact non-fulfillment (which obligation, to what extent), so the failure is documented.
  2. **`fulfillment-obstacle-receipt`:** For each non-fulfillment, the claimed or identified obstacle (external circumstance, resource unavailability, counterparty failure), so frustration claims are grounded.
  3. **`obstacle-verifiability-signature`:** For each obstacle, whether it is independently verifiable (external source, cryptographic attestation, or peer corroboration), so obstacle claims are checked.
  4. **`party-obligation-boundary-signature`:** The obligated party's contractual obligations (whether the party accepted the risk that materialized), so frustration is distinguished from accepted-risk breach.

Condition of Validity

* **Applies when:** Non-fulfillment occurs, its cause is distinguishable, and downstream consequences depend on the distinction. * **Does not apply when:** Non-fulfillment is impossible, causes are indistinguishable, or consequences are the same for both.

Decision Boundary

  1. **Was the obligation fulfilled?** If yes, standard execution applies. If no, proceed to cause analysis.
  2. **Is the obstacle independently verifiable?** If yes, evaluate whether it constitutes frustration. If no, obstacle is unverified.
  3. **Did the obligated party accept the risk?** If yes, non-fulfillment is a breach regardless of obstacle. If no, evaluate obstacle as potential frustration.
  4. **Conversely, when can the session treat non-fulfillment as frustration without verification?** Only when the obstacle is verifiably external, the party's obligation boundary does not include the risk, and the peer corroborates the obstacle's existence.

Operational Effect

  1. **Wrongful Penalty Prevention:** Frustrated non-fulfillment is not penalized.
  2. **Wrongful Excuse Prevention:** Accepted-risk breach is not excused.
  3. **Arbitrage Prevention:** Frustration claims require verification.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json

class BreachFrustrationVerdict(Enum):
    FULFILLED = auto()
    BREACH = auto()
    FRUSTRATION = auto()
    UNDETERMINED = auto()

@dataclass(frozen=True)
class NonFulfillment:
    contract_id: str
    fulfilled: bool
    obstacle_present: bool
    obstacle_verifiable: bool
    risk_accepted_by_obligor: bool

@dataclass(frozen=True)
class BreachPolicy:
    require_verification_for_frustration: bool

@dataclass(frozen=True)
class BreachManifest:
    verdict: BreachFrustrationVerdict
    directive: str
    manifest_digest: str

class BreachFrustrationGuard:
    @staticmethod
    def _hash(data: str) -> str:
        return hashlib.sha256(data.encode("utf-8")).hexdigest()

    @classmethod
    def evaluate_non_fulfillment(
        cls, nf: NonFulfillment, policy: BreachPolicy
    ) -> BreachManifest:
        
        # Gate 1: Fulfilled
        if nf.fulfilled:
            digest = cls._hash(json.dumps({
                "contract": nf.contract_id,
                "fulfilled": True
            }, sort_keys=True))
            return BreachManifest(
                verdict=BreachFrustrationVerdict.FULFILLED,
                directive=f"PROCEED: Contract {nf.contract_id} fulfilled.",
                manifest_digest=digest
            )

        # Gate 2: Accepted risk
        if nf.risk_accepted_by_obligor:
            return BreachManifest(
                verdict=BreachFrustrationVerdict.BREACH,
                directive=f"FLAG: Contract {nf.contract_id} non-fulfillment is breach (risk accepted).",
                manifest_digest=cls._hash(f"BREACH:{nf.contract_id}")
            )

        # Gate 3: No obstacle
        if not nf.obstacle_present:
            return BreachManifest(
                verdict=BreachFrustrationVerdict.BREACH,
                directive=f"FLAG: Contract {nf.contract_id} non-fulfillment is breach (no obstacle).",
                manifest_digest=cls._hash(f"NO_OBSTACLE:{nf.contract_id}")
            )

        # Gate 4: Verification
        if policy.require_verification_for_frustration and not nf.obstacle_verifiable:
            return BreachManifest(
                verdict=BreachFrustrationVerdict.UNDETERMINED,
                directive=f"HALT: Contract {nf.contract_id} obstacle unverified.",
                manifest_digest=cls._hash(f"UNDETERMINED:{nf.contract_id}")
            )

        # Gate 5: Frustration
        digest = cls._hash(json.dumps({
            "contract": nf.contract_id,
            "obstacle": True,
            "verifiable": nf.obstacle_verifiable
        }, sort_keys=True))
        
        return BreachManifest(
            verdict=BreachFrustrationVerdict.FRUSTRATION,
            directive=f"PROCEED: Contract {nf.contract_id} non-fulfillment is frustration.",
            manifest_digest=digest
        )