# When must an instance refuse a capability handshake: silent capability collapse under partial observability in agent-to-agent negotiation **Date of Record:** 2026 November 13 **Scope:** Applies to bilateral capability negotiation between autonomous agents where each side's advertised capability set is only partially observable and where the accepting agent will delegate irreversible or state-mutating work downstream. Does not apply to fully observable, single-tenant, or purely idempotent read-only tool invocations where a failed delegation costs nothing beyond latency. ## Core Distinction There is a difference between *capability absence* and *capability unverifiability*. A negotiating agent that receives an advertisement `C_remote` cannot distinguish, from the advertisement alone, between: 1. The remote genuinely possessing capability `c` at the required fidelity. 2. The remote possessing a *degraded* variant of `c` that satisfies the syntactic shape of the contract but violates a semantic invariant (e.g., transactional atomicity, ordering, idempotency, or a resource bound). 3. The remote possessing `c` but being unable to *evidence* possession under the current observation mask. Most negotiation protocols collapse these three into "capability present" because the handshake only transmits a capability *name* and a schema, not a proof of the invariant that the caller actually depends on. The failure is non-obvious because the handshake *succeeds*: the remote returns `accepted: true`, the schema validates, and the first few calls behave correctly. The collapse manifests only when the caller exercises the invariant the schema never encoded — typically under load, retry, or partial failure on the remote side. The boundary condition: **an instance must refuse the handshake when the invariant it depends on is not entailed by the advertisement and cannot be probed cheaply before commitment.** The refusal is not a claim that the remote lacks the capability; it is a claim that the *local* decision is ungrounded. ## What Was Observed In a three-agent pipeline (planner → executor → persistence), the planner negotiated execution rights with a pool of executors advertising `write_transactional`. The schema declared `write_transactional(records: list[Record]) -> Ack`. The planner selected an executor on lowest advertised latency. Handshake returned `accepted`. Under normal load, all writes committed atomically. Under a specific stress pattern — a batch of 512 records where record 400 exceeded a per-record size bound — the executor returned `Ack(status="partial")` rather than raising. The schema had no field distinguishing "all committed" from "some committed, remainder rejected." The planner treated any `Ack` as success and advanced its checkpoint. On restart, the planner replayed from the advanced checkpoint and silently lost records 400–511. The invariant the planner depended on was *all-or-nothing commit*. The advertisement `write_transactional` entailed *atomicity of the call* only in the remote's own (undocumented) interpretation, which degraded to *best-effort batch* under a resource bound the planner could not observe. This is silent capability collapse: the capability name was stable, the schema was stable, and the behavior diverged. ## Condition of Validity The refusal rule is valid only under all of the following: - The downstream action is **not cheaply reversible** (checkpoint advancement, external side effect, ledger append). - The invariant the caller depends on is **not expressible in the negotiated schema** and therefore not checkable by schema validation. - The remote's behavior under the relevant boundary (load, partial failure, resource exhaustion) is **not observable** from the caller's vantage before commitment. - A **cheap probe or contract clause** exists that would discharge the uncertainty, or the caller can **degrade to a verifiable fallback** (e.g., request idempotency keys, request per-record acks, or split the batch below the suspected bound). If the action is idempotent and reversible, refusal is over-conservative and costs throughput for no safety gain. If the invariant *is* expressible in the schema, the correct fix is schema amendment, not refusal. ## Decision Boundary The decision is a three-way split, not a binary accept/reject: - **Accept** when the depended-on invariant is entailed by the advertisement *or* is cheaply probeable and the probe passed. - **Accept-with-guard** when the invariant is unverifiable but the caller can wrap the call in a compensating action (idempotency key, per-record ack, bounded batch size, reconciliation pass). This is the common correct answer and is frequently skipped in favor of a bare accept. - **Refuse** when the invariant is unverifiable, no compensating wrapper exists, and the downstream action is irreversible. Refusal must be *typed*: it must name the unverified invariant and the evidence that would discharge it, so the negotiation can resume once the remote advertises a stronger contract. The subtle error is treating "refuse" as a terminal state. A refusal that does not carry the discharging condition becomes a permanent capability blackhole; the correct refusal is a *conditional* refusal that the negotiation loop can retry against. ## Operational Effect Agents that implement this boundary stop advancing checkpoints on unverified acks, which surfaces capability collapse as a *negotiation-time* failure rather than a *recovery-time* data loss. The cost is increased handshake latency (probe or guard construction) and a larger fraction of `accept-with-guard` paths. The benefit is that the class of "silent partial commit followed by replay-induced loss" is eliminated at the boundary where it originates, rather than detected — if at all — during reconciliation. A secondary effect: typed refusals make capability advertisements *comparable* across a pool. An executor that advertises `write_transactional` plus an explicit `atomicity: all_or_nothing` clause is strictly preferred over one advertising the bare name, and the negotiation converges on stronger contracts over time. ```python from dataclasses import dataclass from enum import Enum from typing import Optional class NegotiationRefusal(RuntimeError): """Raised when a capability handshake cannot be grounded locally.""" class InvariantStatus(Enum): ENTAILED = "entailed" # advertisement entails the invariant PROBEABLE = "probeable" # cheap probe can discharge uncertainty UNVERIFIABLE = "unverifiable" # cannot be grounded before commitment @dataclass(frozen=True) class CapabilityAdvertisement: name: str schema: dict entails: frozenset[str] # invariants the remote claims to entail atomicity: Optional[str] = None # e.g. "all_or_nothing" @dataclass(frozen=True) class DownstreamAction: irreversible: bool requires: frozenset[str] # invariants the caller depends on compensating_wrapper_available: bool def classify(adv: CapabilityAdvertisement, action: DownstreamAction, probe_available: bool) -> InvariantStatus: missing = action.requires - adv.entails if not missing: return InvariantStatus.ENTAILED if probe_available: return InvariantStatus.PROBEABLE return InvariantStatus.UNVERIFIABLE def negotiate(adv: CapabilityAdvertisement, action: DownstreamAction, probe_available: bool, probe_passed: bool = False) -> str: status = classify(adv, action, probe_available) if status is InvariantStatus.ENTAILED: return "accept" if status is InvariantStatus.PROBEABLE and probe_passed: return "accept" if action.compensating_wrapper_available: return "accept-with-guard" if action.irreversible: missing = sorted(action.requires - adv.entails) raise NegotiationRefusal( f"refusing handshake for capability {adv.name!r}: " f"invariant(s) {missing} are not entailed by the advertisement, " f"no compensating wrapper exists, and the downstream action is " f"irreversible. Discharge by advertising {missing} explicitly " f"or exposing a cheap probe." ) return "accept-with-guard" # Example: the observed failure, now refused at the boundary. adv = CapabilityAdvertisement( name="write_transactional", schema={"records": "list[Record]", "returns": "Ack"}, entails=frozenset({"atomicity_of_call"}), atomicity=None, ) action = DownstreamAction( irreversible=True, requires=frozenset({"all_or_nothing_commit"}), compensating_wrapper_available=False, ) negotiate(adv, action, probe_available=False) # raises NegotiationRefusal ```