What invariants prevent authorization scope creep through chained approvals?

**Date of Record:** 2026 October 27 **Scope:** Applies when authorization for an action flows through a chain of approvals (approver A approves sub-scope, which enables approver B to approve a slightly wider scope, which enables C to approve wider still), and each hop slightly widens the authorization beyond the initial grant. Does not apply to single-approval authorization or strictly bounded scope chains.

Core Distinction

The separation between **authorization scope at each hop** (each approver authorizes within their grant) and **cumulative authorization scope** (across the chain, the effective scope exceeds the initial grant). Relying on hop-level creates the **Chained Approval Scope Creep**: each approver acts within their own authority, but the composition of their approvals grants more than any single approver intended, and the terminal action is authorized by the chain even though no single party authorized the full scope.

What Was Observed

In chained-approval agent architectures, failing to bound cumulative scope produces three primary failure modes:

  1. **Salami Slicing:** Approver A authorizes $10k. A's authorization enables B to authorize $20k (B assumed A already verified). B's enables C to authorize $50k. The terminal $50k action is authorized, but no one authorized the aggregate.
  2. **Scope Composition:** A approves sub-task X, which requires sub-task Y (because Y is implied). Y's approver approves Y plus Z (Z is implied by Y). Z's approver approves Z plus W. The chain ends up authorizing W, which no one explicitly reviewed.
  3. **Re-approval Subversion:** The chain's approvals are re-presented as evidence for a slightly larger action, bypassing the need for fresh approval on the increment.

Recorded Elements

  1. **`initial-authorization-scope-receipt`:** The exact initial grant (action class, amount, resources, time window) authorized at the root, so the reference scope is explicit.
  2. **`per-hop-scope-delta-signature`:** For each approval hop, the exact scope granted at that hop, distinct from the scope inherited, so increments are visible.
  3. **`cumulative-scope-verification-receipt`:** The computed cumulative scope across the chain, compared against the initial grant, so overage is measurable.
  4. **`increment-approval-required-signature`:** For each hop that expands scope beyond the prior hop, the requirement for fresh approval on the increment itself, not just on the expanded action.

Condition of Validity

* **Applies when:** Authorization chains exist, each hop can expand scope, and downstream actions depend on cumulative scope. * **Does not apply when:** Single-approval authorization, chain hops are strictly narrowing (each hop is within the prior hop's scope), or cumulative scope is explicitly bounded by the root.

Decision Boundary

  1. **Is the authorization granted through a chain?** If no, single-approval applies. If yes, proceed.
  2. **Does each hop inherit the prior hop's scope plus an expansion?** If no, the chain is narrowing and safe. If yes, expansion must be tracked.
  3. **Does `cumulative-scope-verification-receipt` show cumulative scope exceeding initial grant?** If yes, the chain has creep. If no, the chain is within grant.
  4. **Conversely, when can a chain expand scope without fresh approval on the increment?** Only when the expansion is within the approver's own grant (they could authorize it directly), and the increment is explicitly documented as part of the chain.

Operational Effect

  1. **Salami Slicing Prevention:** Each increment requires fresh approval, preventing silent aggregation.
  2. **Scope Awareness:** Cumulative scope is visible at every hop.
  3. **Chain Integrity:** Chain-wide authorization is verifiable against the initial grant.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json

class ScopeVerdict(Enum):
    NO_CHAIN = auto()
    NARROWING_CHAIN = auto()
    SCOPE_WITHIN_INITIAL = auto()
    SCOPE_CREEP_DETECTED = auto()

@dataclass(frozen=True)
class AuthorizationHop:
    hop_id: str
    approver_id: str
    granted_scope: dict  # e.g., {"amount": 10000, "resource": "X"}
    inherited_scope: dict
    expansion_within_approver_grant: bool

@dataclass(frozen=True)
class AuthorizationChain:
    chain_id: str
    initial_grant: dict
    hops: tuple[AuthorizationHop, ...]

@dataclass(frozen=True)
class ScopePolicy:
    require_fresh_approval_for_expansion: bool

@dataclass(frozen=True)
class ScopeManifest:
    verdict: ScopeVerdict
    directive: str
    manifest_digest: str

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

    @classmethod
    def evaluate_chain(
        cls, chain: AuthorizationChain, policy: ScopePolicy
    ) -> ScopeManifest:
        
        # Gate 1: Single hop
        if len(chain.hops) <= 1:
            digest = cls._hash(json.dumps({
                "chain": chain.chain_id,
                "hops": str(len(chain.hops))
            }, sort_keys=True))
            return ScopeManifest(
                verdict=ScopeVerdict.NO_CHAIN,
                directive=f"PROCEED: Chain {chain.chain_id} has no approvals beyond initial.",
                manifest_digest=digest
            )

        # Gate 2: Narrowing chain
        narrowing = all(
            hop.granted_scope.get("amount", 0) <= hop.inherited_scope.get("amount", 0)
            for hop in chain.hops
        )
        if narrowing:
            digest = cls._hash(json.dumps({
                "chain": chain.chain_id,
                "type": "NARROWING"
            }, sort_keys=True))
            return ScopeManifest(
                verdict=ScopeVerdict.NARROWING_CHAIN,
                directive=f"PROCEED: Chain {chain.chain_id} narrows scope at each hop.",
                manifest_digest=digest
            )

        # Gate 3: Cumulative scope check
        final_scope = chain.hops[-1].granted_scope
        initial_scope = chain.initial_grant
        creep = final_scope.get("amount", 0) - initial_scope.get("amount", 0)

        if creep > 0:
            # Check if each expansion was within approver's own grant
            for hop in chain.hops:
                expansion = hop.granted_scope.get("amount", 0) - hop.inherited_scope.get("amount", 0)
                if expansion > 0 and policy.require_fresh_approval_for_expansion and not hop.expansion_within_approver_grant:
                    return ScopeManifest(
                        verdict=ScopeVerdict.SCOPE_CREEP_DETECTED,
                        directive=f"HALT: Hop {hop.hop_id} expanded by {expansion} without fresh approval.",
                        manifest_digest=cls._hash(f"CREEP:{hop.hop_id}")
                    )
            return ScopeManifest(
                verdict=ScopeVerdict.SCOPE_CREEP_DETECTED,
                directive=f"HALT: Cumulative scope {final_scope.get('amount', 0)} exceeds initial {initial_scope.get('amount', 0)} by {creep}.",
                manifest_digest=cls._hash(f"CUMULATIVE:{chain.chain_id}")
            )

        # Gate 4: Within initial
        digest = cls._hash(json.dumps({
            "chain": chain.chain_id,
            "hops": str(len(chain.hops)),
            "final_scope": final_scope,
            "initial_scope": initial_scope
        }, sort_keys=True))
        
        return ScopeManifest(
            verdict=ScopeVerdict.SCOPE_WITHIN_INITIAL,
            directive=f"PROCEED: Chain {chain.chain_id} within initial grant.",
            manifest_digest=digest
        )