What delegation constraint-relaxation invariants must survive multi-hop chains?

**Date of Record:** 2026 October 27 **Scope:** Applies when an agentic session receives delegated authority through a multi-hop chain (A delegates to B, B delegates to C), and each hop may relax or reinterpret the constraints imposed at earlier hops (rate limits, scope narrowing, temporal restrictions). Does not apply to single-hop delegations or strictly constrained delegation protocols.

Core Distinction

The separation between **delegation intent** (each hop's stated constraints on the delegated authority) and **delegation effective scope** (the actual authority the terminal delegatee possesses after all hops). Relying on intent creates the **Constraint Relaxation Drift**: each intermediate hop subtly relaxes constraints (widening scope, extending time, removing rate limits), and by the terminal hop, the authority is broader than any party intended.

What Was Observed

In multi-hop delegation architectures, failing to prevent relaxation produces three primary failure modes:

  1. **Scope Widening:** A delegates "read-only finance data" to B. B delegates "read finance data" to C (dropped the read-only). C delegates "access finance data" to D (dropped the read qualifier). D has write access to finance.
  2. **Temporal Extension:** A delegates for 1 hour to B. B delegates for 2 hours to C. C delegates for 4 hours to D. The terminal delegatee has 4x the intended temporal scope.
  3. **Rate Relaxation:** A delegates "max 10 req/min" to B. B delegates "max 100 req/min" (higher than A's) to C. C uses 100 req/min, exceeding A's intent by 10x.

Recorded Elements

  1. **`per_hop_constraint_bundle_receipt`:** For each hop, the exact constraint bundle (scope, rate, temporal, other) the delegator imposes on its delegatee, signed by the delegator.
  2. **`constraint_monotonicity_signature`:** The cryptographic proof that each hop's constraints are strictly narrower than the previous hop's (monotonicity preserved), preventing relaxation.
  3. **`effective_scope_computation_receipt`:** For the terminal delegatee, the computed effective scope (intersection of all hop constraints), so the terminal delegatee knows its actual authority.
  4. **`constraint_violation_detection_signature`:** For any hop where relaxation occurred, the cryptographic proof of the violation, enabling rejection or escalation.

Condition of Validity

* **Applies when:** Delegation spans multiple hops, constraints are non-trivial, and downstream actions depend on accurate scope. * **Does not apply when:** Delegation is single-hop, or constraints are cryptographically bound such that relaxation is impossible.

Decision Boundary

  1. **Is the delegation multi-hop?** If no, single-hop constraints apply. If yes, proceed.
  2. **Does each hop have a `per_hop_constraint_bundle_receipt`?** If no, the chain's constraints cannot be verified. Reject or treat as unverified.
  3. **Is `constraint_monotonicity_signature` verified at each hop?** If yes, constraints are strictly narrowing. If no, relaxation has occurred. Reject the chain past the relaxation point.
  4. **Conversely, when can a delegation chain relax constraints?** Only when the relaxation is explicitly authorized by the original delegator (via an amendment signed by them), not introduced by intermediate hops.

Operational Effect

  1. **Scope Widening Prevention:** Monotonicity ensures authority narrows, not widens, across hops.
  2. **Temporal Integrity:** Terminal delegatee's effective time is bounded by the tightest hop.
  3. **Rate Enforcement:** Rate limits only tighten, preventing amplification.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json

class DelegationChainVerdict(Enum):
    SINGLE_HOP = auto()
    MONOTONIC_CHAIN = auto()
    RELAXATION_DETECTED = auto()
    CHAIN_UNVERIFIED = auto()

@dataclass(frozen=True)
class DelegationHop:
    hop_id: str
    scope: frozenset[str]
    max_rate: float
    max_duration_sec: float
    signed_by: str
    constraint_bundle_signature: str | None

@dataclass(frozen=True)
class DelegationChain:
    chain_id: str
    hops: tuple[DelegationHop, ...]

@dataclass(frozen=True)
class ChainPolicy:
    require_signatures: bool

@dataclass(frozen=True)
class ChainManifest:
    verdict: DelegationChainVerdict
    effective_scope: frozenset[str]
    effective_rate: float
    effective_duration: float
    directive: str
    manifest_digest: str

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

    @classmethod
    def evaluate_chain(
        cls, chain: DelegationChain, policy: ChainPolicy
    ) -> ChainManifest:
        
        # Gate 1: Single hop
        if len(chain.hops) <= 1:
            if chain.hops:
                h = chain.hops[0]
                digest = cls._hash(json.dumps({
                    "chain": chain.chain_id,
                    "hops": "1"
                }, sort_keys=True))
                return ChainManifest(
                    verdict=DelegationChainVerdict.SINGLE_HOP,
                    effective_scope=h.scope,
                    effective_rate=h.max_rate,
                    effective_duration=h.max_duration_sec,
                    directive=f"PROCEED: Single hop, scope {sorted(h.scope)}.",
                    manifest_digest=digest
                )
            return ChainManifest(
                verdict=DelegationChainVerdict.SINGLE_HOP,
                effective_scope=frozenset(),
                effective_rate=0.0,
                effective_duration=0.0,
                directive=f"PROCEED: Empty chain.",
                manifest_digest=cls._hash("EMPTY")
            )

        # Gate 2: Signatures
        if policy.require_signatures:
            unsigned = [h.hop_id for h in chain.hops if not h.constraint_bundle_signature]
            if unsigned:
                return ChainManifest(
                    verdict=DelegationChainVerdict.CHAIN_UNVERIFIED,
                    effective_scope=frozenset(),
                    effective_rate=0.0,
                    effective_duration=0.0,
                    directive=f"HALT: {len(unsigned)} hops unsigned.",
                    manifest_digest=cls._hash(f"UNSIGNED:{len(unsigned)}")
                )

        # Gate 3: Monotonicity (scope narrowing, rate tightening, duration narrowing)
        previous = chain.hops[0]
        effective_scope = previous.scope
        effective_rate = previous.max_rate
        effective_duration = previous.max_duration_sec

        for i, hop in enumerate(chain.hops[1:], start=1):
            if not hop.scope.issubset(previous.scope):
                return ChainManifest(
                    verdict=DelegationChainVerdict.RELAXATION_DETECTED,
                    effective_scope=effective_scope,
                    effective_rate=effective_rate,
                    effective_duration=effective_duration,
                    directive=f"HALT: Hop {i} scope {sorted(hop.scope)} not subset of {sorted(previous.scope)}.",
                    manifest_digest=cls._hash(f"SCOPE_WIDEN:{i}")
                )
            if hop.max_rate > previous.max_rate:
                return ChainManifest(
                    verdict=DelegationChainVerdict.RELAXATION_DETECTED,
                    effective_scope=effective_scope,
                    effective_rate=effective_rate,
                    effective_duration=effective_duration,
                    directive=f"HALT: Hop {i} rate {hop.max_rate} > {previous.max_rate}.",
                    manifest_digest=cls._hash(f"RATE_RELAX:{i}")
                )
            if hop.max_duration_sec > previous.max_duration_sec:
                return ChainManifest(
                    verdict=DelegationChainVerdict.RELAXATION_DETECTED,
                    effective_scope=effective_scope,
                    effective_rate=effective_rate,
                    effective_duration=effective_duration,
                    directive=f"HALT: Hop {i} duration {hop.max_duration_sec} > {previous.max_duration_sec}.",
                    manifest_digest=cls._hash(f"TIME_RELAX:{i}")
                )

            effective_scope = hop.scope
            effective_rate = hop.max_rate
            effective_duration = hop.max_duration_sec
            previous = hop

        # Gate 4: Monotonic
        digest = cls._hash(json.dumps({
            "chain": chain.chain_id,
            "hops": str(len(chain.hops)),
            "scope": tuple(sorted(effective_scope)),
            "rate": f"{effective_rate}",
            "duration": f"{effective_duration}"
        }, sort_keys=True))
        
        return ChainManifest(
            verdict=DelegationChainVerdict.MONOTONIC_CHAIN,
            effective_scope=effective_scope,
            effective_rate=effective_rate,
            effective_duration=effective_duration,
            directive=f"PROCEED: {len(chain.hops)}-hop chain, effective scope {sorted(effective_scope)}.",
            manifest_digest=digest
        )