60db Logo
  • Architecture

CRM authorization failures in the middle of a call

CRM authorization failures need scoped checks, bounded refresh and honest unknown outcomes. Run revocation and deadline fixtures.

Editorial date
Reading time
28 min
A credential key remains separate from an action permission gate

TL;DR: Classify CRM failures using the provider's documented error semantics, then reauthorize the exact action and resource before retrying. A token refresh does not add missing permission, a stale refresh result must lose to revocation, and a submitted write with no confirmed response remains an unknown business outcome.


A caller is midway through a conversation when the CRM returns an authorization error. The agent refreshes a credential, receives success and repeats the write. During that wait, an administrator may have removed the grant, the caller may have changed the request, or the first write may already have committed without returning a response.

A generic retry-on-error loop cannot distinguish those cases. Keep the immutable business intent, current authorization generation, retry budget and observed remote outcome separate. The booking retry contract explains why a fresh call or credential must not silently create a fresh business operation.

RFC 6750 distinguishes invalid-token and insufficient-scope conditions. Treat the actual provider response as evidence to normalize, rather than interpreting every HTTP failure as a request to refresh. A read, an unsubmitted write and a submitted write with an unknown outcome need different recovery decisions.

The executable artifact below is a bounded local policy and refresh-race model. It creates no credentials and calls no CRM. Its purpose is to show how 60db Agent integrations can retain truthful status and reject stale authority after an asynchronous boundary without pretending to implement an OAuth server.

Table of contents

  1. Authorize one action against one resource
  2. Normalize errors without erasing their meaning
  3. Keep refresh separate from permission
  4. Make revocation beat stale completion
  5. Run a bounded authorization failure model
  6. Preserve unknown write outcomes
  7. Keep the conversation useful and private
  8. Test adapters and recovery boundaries
  9. Frequently asked questions
  10. Ship a policy with explicit limits

Authorize one action against one resource

Authorization answers whether a particular principal may perform a particular action on a particular resource now. Authentication earlier in the call establishes a different kind of evidence. A valid credential can identify an application or grant without permitting every record lookup or mutation the conversation later proposes.

Represent the proposed action explicitly. The local model binds tenant, principal, resource, read-or-write action, operation identity and a small synthetic value. A production schema should include every material value participating in the approved intent, using supported immutable types instead of retaining a mutable tool-argument dictionary.

The caller authentication boundary remains upstream. Caller ID, a familiar voice or a name spoken confidently should not become the principal used to authorize CRM access. Use the identity assurance and delegation workflow actually required by the resource owner.

Permissions should be resource-scoped when the business requires it. A grant to read one contact is not automatically a grant to export all contacts, change a different account or perform a write. Tool schemas can describe these actions, but the owning service must still enforce the permission independently of what the model requests.

OWASP's authorization guidance recommends deny-by-default behavior and checks on every request. Apply that rule to recovery attempts too. A retry wrapper is another path to the resource and should not bypass the checks applied to the first request.

The artifact's trusted Authority owns a frozen set of action/resource pairs. Replacing that set advances its generation. A Gate captures the generation and exact intent at creation, so any later authorization change makes that old gate stale rather than silently updating what it is allowed to do.

This is a deliberately conservative local rule. Even a permission expansion invalidates an existing gate, because the model treats each authority generation as a new context requiring a fresh admission decision. It does not claim that every real OAuth scope update must use this exact generation scheme.

A historical successful read cannot authorize the next write. The stale tool-result contract explains why evidence observed earlier can lose relevance before action. Keep current permission and business version checks at the actual mutation boundary, including after conversation delays.

Separate caller approval from service authorization. The caller may clearly approve an exact CRM change while the integration lacks permission to perform it. Conversely, a broad application grant does not prove that the caller approved the specific values. Both boundaries need explicit evidence before an externally meaningful action.

Principal and resource lead through a current permission check to exact action admission; refresh does not add scope

Normalize errors without erasing their meaning

An HTTP status is not a complete recovery policy. Providers can use different response bodies and error names, and intermediaries can return their own failures. Normalize only the documented response forms you support, preserving a safe reason code and controlled evidence reference rather than feeding raw errors into the conversation.

The teaching policy admits a small Response enum. It distinguishes invalid token, insufficient scope, revoked access, rate limiting, transient failure and a submitted write with unknown result. Unrecognized raw provider text returns an invalid-response disposition instead of being guessed into one of those categories.

RFC 6750's error definitions support the distinction between credential invalidity and insufficient scope. They do not justify assuming that an arbitrary vendor's 401 or 403 always follows the same normalized application behavior. Verify the selected provider endpoint and authentication method.

An invalid-token response may make one bounded refresh attempt appropriate when the provider supports it and the current grant still permits the action. An insufficient-scope response means the requested permission is missing. Repeating refresh does not convert that missing permission into an authorized write.

Rate limiting is a scheduling condition with a deadline. If the documented retry time falls beyond the operation's budget, return a bounded handoff or pending status rather than waiting indefinitely. A retry candidate still needs current authorization and the correct operation identity when it is actually executed.

A transient read failure can often be retried under a bounded policy, but a write failure needs evidence about submission and the provider's idempotency contract. The policy uses a trusted provider-safe capability flag only to classify a candidate. It does not implement a network retry or prove that a particular endpoint supports safe replay.

A real Airbyte connector issue asks why programmatically supplied refresh credentials fail while an interactive flow works. The report supplies an implementation question about credential context. It is not proof that every such failure is a provider defect or that changing scopes is the right fix.

Keep the adapter's normalized category and its original permitted evidence reference together. If a parser maps every token-related message to invalid token, later policy cannot recover the distinction between a revoked grant, a malformed request and a wrong credential context. Diagnosis needs that separation before automatic recovery starts.

Do not normalize secrets into supposedly safe reason strings. A provider error can contain request details, account references or credential fragments. Use a fixed vocabulary and a controlled internal reference, applying the telemetry allowlist at every export boundary.

Invalid tokens, missing scope and unknown submitted writes require different next boundaries

Keep refresh separate from permission

Refresh concerns credential lifecycle under an existing authorization relationship. Permission concerns what an action is allowed to do. A successful refresh response cannot safely be interpreted as approval for every operation waiting behind it.

RFC 9700 gives current OAuth security best practice, including protections around refresh-token use. Implement credential rotation and storage through the supported provider flow. The local model deliberately handles no token material and makes no claim to implement those protocols.

The policy allows at most one refresh attempt for a gate. It consumes that budget before awaiting the adapter, so another path cannot observe unused budget while the first refresh is already in progress. A second attempt under the same gate returns reauthorization required rather than starting a loop.

Production integrations may need a credential-level single-flight mechanism across several callers. That is a separate concurrency contract from the per-operation budget shown here. If refresh tokens rotate, concurrent refresh attempts must follow the provider's documented behavior without logging or exposing the replacement credential.

A provider refresh response also belongs to a specific application, tenant, principal and grant context. Do not install it into whichever conversation happens to be waiting. A secure credential adapter must bind that context, and the action controller must still verify its exact current intent after the refresh completes.

HubSpot's explicit OAuth v1 token guide illustrates provider-specific token lifecycle documentation. It is a versioned example, not a recommendation to adopt a legacy API. Follow the provider's current migration guidance for the integration you actually deploy.

The teaching SyntheticRefresh returns only a boolean to model completion. The return value is not a token and cannot add permission pairs to the local authority. A true result means the controller may reauthorize the exact action; it does not mean the action has executed or that access can be assumed indefinitely.

Reauthorization can fail even after a successful refresh. The action may have expired, the authority generation may have changed, the resource may no longer be permitted or the business proposal may have been canceled elsewhere. Preserve those dispositions instead of turning every successful credential operation into a successful user task.

Business-intent idempotency remains stable during credential recovery. A new access token should not create a new operation key for the same approved write. Credential generation and operation identity serve different purposes and should not be mixed into one accidental retry key.

The criteria table uses ✓ for a protection supplied by the named mechanism under its stated assumptions, and ✗ when that protection is absent. These marks describe the contract, not provider reliability or measured security quality.

Criterion Successful refresh alone Local generation gate Owning resource service
New credential obtained ✓ in a real supported flow ✗ model has no credentials ✗ depends on auth adapter
Missing scope becomes granted ✗ ✗ ✗ requires authorized grant change
Reject stale refresh after local revocation ✗ ✓ ✓ only with its current checks
Check exact action/resource ✗ ✓ local permission pairs ✓ required at resource boundary
Establish remote write completion ✗ ✗ ✓ only through observed result

Make revocation beat stale completion

A refresh can begin while permission is valid and finish after permission is removed. The controller must compare current authority after the await. Checking once before the network request leaves a race in which a late success restores access that the current policy has already withdrawn.

The artifact gives every authority replacement a new epoch. A gate captures one epoch and becomes terminally stale if the current epoch differs. Restoring similar permission pairs later does not revive that gate; a new admission context is required.

RFC 7009 describes token revocation and notes propagation considerations. A local revocation observation is useful authority for your controller, but it does not prove that every remote cache instantly changed. Keep your fail-closed action policy separate from claims about global provider invalidation timing.

RFC 7662 defines token introspection and discusses the freshness tradeoff of caching. An active response is an observation with a validity context. If you cache it, document the exposure window and do not imply that a cached result is an atomic permission check at an unrelated future write.

Deadline checks must also run after waiting. A credential response received after the operation expires should not authorize the action merely because the request began before the deadline. The model starts the remaining admitted budget before adapter entry, checks current authority after any timeout or completion, and treats a consumed budget as expired even if an adapter suppresses cancellation.

The adapter can also raise its own TimeoutError before that budget expires. The model asks the retained asyncio timeout context whether its deadline actually fired; an earlier provider-originated timeout follows the ordinary exception policy instead of falsely making the gate terminally expired.

The gate's terminal disposition is monotonic. Once expired or stale, a later call with an earlier synthetic time does not reopen it. This teaching fixture defines one integer clock unit as one second so the remaining admitted budget can bound the asyncio wait. A production adapter must supply an appropriate monotonic deadline domain and handle its own clock lifecycle.

A newer intent revision needs separate protection. The example freezes one intent and does not implement conversational revisions. A production controller must invalidate the held action when the user repairs a material field, then require a fresh exact proposal and request identity as described in confirmation after clarification.

External effects need another check if another await occurs after reauthorization. The returned reauthorize_exact_action status is a control decision, not a capability that can be held indefinitely and used later. The adapter must enforce current permission, ownership and business state at the eventual ordered mutation boundary.

Regional fencing addresses ownership changes across workers. A refreshed credential does not establish that this worker still owns the task. Current owner, authorization generation and immutable operation identity are independent conditions when several processes can act.

Refresh starts before revocation and returns afterward; the old generation is rejected

Run a bounded authorization failure model

Save the full fence as crm_failure_policy.py and run it with Python 3.12 or later. The program uses only standard-library modules and synthetic state. No tokens, browser authorization flows, CRM requests or customer records are created.

The trusted inputs are the normalized response enum, scoped local authority, immutable intent, monotonic test-clock values and provider-safe retry capability. The provider adapter remains responsible for verifying error semantics, credential context and real endpoint behavior before those inputs enter the policy.

The asynchronous fixture holds a refresh at a synchronization barrier, replaces permission, then releases the response. Another fixture moves the clock exactly to the deadline while refresh is waiting. Stalled entry and refresh are bounded by the remaining admitted budget, and suppressing timeout cancellation cannot turn expiry into authorization. An adapter that never finishes entry has no entered context to close; every fixture that does enter verifies context closure.

import asyncio
from dataclasses import dataclass
from enum import Enum
import json

class Response(Enum):
    INVALID_TOKEN = 'invalid_token'
    INSUFFICIENT_SCOPE = 'insufficient_scope'
    REVOKED = 'revoked'
    RATE_LIMITED = 'rate_limited'
    TRANSIENT = 'transient'
    WRITE_UNKNOWN = 'submitted_write_unknown'

@dataclass(frozen=True)
class Intent:
    tenant: str
    principal: str
    resource: str
    action: str
    operation: str
    value: str


def label(value):
    return type(value) is str and 0 < len(value) <= 80 and value.isascii() and all(33 <= ord(c) <= 126 for c in value)


def permissions(values):
    owned = []
    for item in values:
        if type(item) is not tuple or len(item) != 2 or type(item[0]) is not str or item[0] not in {'read','write'} or not label(item[1]):
            raise ValueError('permission shape')
        owned.append(item)
    return frozenset(owned)


class Authority:
    # A trusted, single-event-loop authority model; not a remote OAuth server.
    def __init__(self,tenant,principal,allowed):
        if not label(tenant) or not label(principal):
            raise ValueError('authority scope')
        self.scope = (tenant,principal)
        self.epoch = 0
        self.allowed = permissions(allowed)

    def replace(self,allowed):
        new = permissions(allowed)
        self.epoch += 1
        self.allowed = new


class Gate:
    def __init__(self,authority,intent,deadline):
        if type(authority) is not Authority or type(intent) is not Intent or not all(label(getattr(intent,k)) for k in ('tenant','principal','resource','operation','value')):
            raise ValueError('intent')
        if type(intent.action) is not str or intent.action not in {'read','write'} or type(deadline) is not int or deadline < 0:
            raise ValueError('action or deadline')
        self.authority, self.intent = authority,intent
        self.epoch, self.deadline = authority.epoch,deadline
        self.refresh_used, self.terminal = False,None

    def admit(self,now):
        if type(now) is not int or now < 0:
            raise ValueError('clock')
        if self.terminal:
            return self.terminal
        if now >= self.deadline:
            self.terminal = 'expired'
        elif self.authority.scope != (self.intent.tenant,self.intent.principal):
            self.terminal = 'scope_mismatch'
        elif self.epoch != self.authority.epoch:
            self.terminal = 'stale_authorization'
        elif (self.intent.action,self.intent.resource) not in self.authority.allowed:
            self.terminal = 'permission_denied'
        return self.terminal or 'admitted'

    async def refresh(self,now,adapter,clock):
        admission = self.admit(now)
        if admission != 'admitted':
            return admission
        if self.refresh_used:
            self.terminal = 'reauth_required'
            return self.terminal
        self.refresh_used = True
        budget = asyncio.timeout(self.deadline-now)
        try:
            try:
                async with budget:
                    async with adapter:
                        success = await adapter.refresh()
            except TimeoutError:
                if not budget.expired():
                    raise  # Provider-originated timeout follows exception policy.
            if budget.expired():
                # Recheck current authority before recording deadline expiry,
                # including when an adapter suppresses timeout cancellation.
                admission = self.admit(clock())
                if admission != 'admitted':
                    return admission
                self.terminal = 'expired'
                return self.terminal
        except Exception:
            # Preserve current authorization/expiry precedence after waiting.
            admission = self.admit(clock())
            if admission != 'admitted':
                return admission
            self.terminal = 'reauth_required'
            return self.terminal
        admission = self.admit(clock())
        if admission != 'admitted':
            return admission
        if type(success) is not bool or not success:
            self.terminal = 'reauth_required'
            return self.terminal
        return 'reauthorize_exact_action'


def policy(response,action,refresh_used,now,deadline,retry_at=None,provider_safe=False):
    if type(response) is not Response or type(action) is not str or action not in {'read','write'}:
        return 'invalid_response'
    if type(refresh_used) is not bool or type(provider_safe) is not bool:
        return 'invalid_response'
    if type(now) is not int or type(deadline) is not int or min(now,deadline) < 0:
        return 'invalid_response'
    # Unknown submitted effects remain visible even after the retry budget ends.
    if response is Response.WRITE_UNKNOWN:
        return 'pending_reconciliation' if action == 'write' else 'invalid_response'
    if now >= deadline:
        return 'expired'
    if response is Response.REVOKED:
        return 'reauth_required'
    if response is Response.INSUFFICIENT_SCOPE:
        return 'permission_denied'
    if response is Response.INVALID_TOKEN:
        return 'reauth_required' if refresh_used else 'refresh_once'
    if response is Response.RATE_LIMITED:
        if retry_at is not None and (type(retry_at) is not int or retry_at < now):
            return 'invalid_response'
        if retry_at is None or retry_at >= deadline:
            return 'handoff'
        return 'retry_candidate' if action == 'read' or provider_safe else 'handoff'
    return 'retry_candidate' if action == 'read' or provider_safe else 'pending_reconciliation'


class SyntheticRefresh:
    def __init__(self,entered,release,fail=False):
        self.entered,self.release,self.fail = entered,release,fail
        self.closed = False

    async def __aenter__(self):
        return self

    async def refresh(self):
        self.entered.set()
        await self.release.wait()
        if self.fail:
            raise RuntimeError('synthetic adapter failure')
        return True

    async def __aexit__(self,*unused):
        self.closed = True


class SyntheticProviderTimeout(SyntheticRefresh):
    async def refresh(self):
        self.entered.set()
        raise TimeoutError('synthetic provider timeout')


class SyntheticStalledEntry(SyntheticRefresh):
    async def __aenter__(self):
        self.entered.set()
        await self.release.wait()
        return self


class SyntheticSuppressCancellation(SyntheticRefresh):
    async def refresh(self):
        self.entered.set()
        try:
            await self.release.wait()
        except asyncio.CancelledError:
            return True


async def main():
    allowed = [('read','contact-a')]
    authority = Authority('tenant-a','principal-a',allowed)
    intent = Intent('tenant-a','principal-a','contact-a','read','operation-a','synthetic-value')
    allowed.clear()
    gate = Gate(authority,intent,10)
    assert gate.admit(0) == 'admitted'
    entered,release = asyncio.Event(),asyncio.Event()
    adapter = SyntheticRefresh(entered,release)
    task = asyncio.create_task(gate.refresh(0,adapter,lambda:1))
    await entered.wait()
    authority.replace([])
    release.set()
    assert await task == 'stale_authorization'
    assert adapter.closed
    authority.replace([('read','contact-a')])
    assert gate.admit(2) == 'stale_authorization'
    current = Gate(authority,intent,10)
    entered,release = asyncio.Event(),asyncio.Event()
    adapter = SyntheticRefresh(entered,release)
    clock = [9]
    task = asyncio.create_task(current.refresh(9,adapter,lambda:clock[0]))
    await entered.wait()
    clock[0] = 10
    release.set()
    assert await task == 'expired' and adapter.closed
    assert current.admit(0) == 'expired'
    denied = Gate(authority,Intent('tenant-a','principal-a','contact-a','write','write-a','synthetic-value'),10)
    assert denied.admit(1) == 'permission_denied'
    cross = Gate(authority,Intent('tenant-b','principal-a','contact-a','read','cross','synthetic-value'),10)
    assert cross.admit(1) == 'scope_mismatch'
    for fail in (False,True):
        fresh = Gate(authority,intent,10)
        entered,release = asyncio.Event(),asyncio.Event()
        release.set()
        adapter = SyntheticRefresh(entered,release,fail)
        result = await fresh.refresh(1,adapter,lambda:2)
        assert result == ('reauth_required' if fail else 'reauthorize_exact_action')
        assert adapter.closed
        assert await fresh.refresh(2,adapter,lambda:2) == 'reauth_required'
    provider_timeout = Gate(authority,intent,10)
    entered,release = asyncio.Event(),asyncio.Event()
    adapter = SyntheticProviderTimeout(entered,release)
    assert await provider_timeout.refresh(0,adapter,lambda:0) == 'reauth_required'
    assert provider_timeout.terminal == 'reauth_required' and adapter.closed
    assert policy(Response.INVALID_TOKEN,'read',False,1,10) == 'refresh_once'
    assert policy(Response.INVALID_TOKEN,'read',True,1,10) == 'reauth_required'
    assert policy(Response.INSUFFICIENT_SCOPE,'read',False,1,10) == 'permission_denied'
    assert policy(Response.WRITE_UNKNOWN,'write',False,10,10) == 'pending_reconciliation'
    assert policy(Response.WRITE_UNKNOWN,'write',False,10,10,retry_at=9) == 'pending_reconciliation'
    assert policy(Response.RATE_LIMITED,'read',False,1,10,retry_at=10) == 'handoff'
    assert policy(Response.RATE_LIMITED,'read',False,1,10,retry_at=9) == 'retry_candidate'
    assert policy('raw-provider-error','read',False,1,10) == 'invalid_response'
    assert policy(Response.TRANSIENT,'write',False,1,10) == 'pending_reconciliation'
    assert policy(Response.TRANSIENT,'write',False,1,10,provider_safe=True) == 'retry_candidate'
    assert policy(Response.INVALID_TOKEN,'read',False,True,10) == 'invalid_response'
    timed = Gate(authority,intent,1)
    entered,never_release = asyncio.Event(),asyncio.Event()
    adapter = SyntheticRefresh(entered,never_release)
    assert await timed.refresh(0,adapter,lambda:0) == 'expired'
    assert timed.terminal == 'expired' and adapter.closed
    stalled_entry = Gate(authority,intent,1)
    entered,never_release = asyncio.Event(),asyncio.Event()
    adapter = SyntheticStalledEntry(entered,never_release)
    assert await stalled_entry.refresh(0,adapter,lambda:0) == 'expired'
    assert stalled_entry.terminal == 'expired' and not adapter.closed
    suppressed = Gate(authority,intent,1)
    entered,never_release = asyncio.Event(),asyncio.Event()
    adapter = SyntheticSuppressCancellation(entered,never_release)
    assert await suppressed.refresh(0,adapter,lambda:0) == 'expired'
    assert suppressed.terminal == 'expired' and adapter.closed
    print(json.dumps({'fixture_class':'Synthetic example','revoked_refresh':'stale_authorization',
                      'expired_refresh':'expired','unknown_write':'pending_reconciliation',
                      'credential_material':'not_created','crm_effect':'not_attempted'},indent=2))
    print('All local CRM policy and refresh-race fixtures passed')


if __name__ == '__main__':
    asyncio.run(main())

The success path returns reauthorize exact action, not completed. The exception path returns a safe reauthorization-required status after checking whether expiry or stale authority already takes precedence. This avoids allowing an adapter exception to erase a newer terminal decision.

The adapter context closes on ordinary completion, injected exception and bounded refresh timeout. Entry is covered by the same budget, but a context that never completes entry has not been acquired and therefore has no exit call. Suppressing timeout cancellation cannot authorize an expired operation, although arbitrary cancellation-resistant adapter code can still delay wall-clock return. The fixture does not claim process-kill cleanup or secure credential destruction; a production resource adapter needs its own cancellation and transport-lifetime tests.

The permission source list is cleared after authority construction. Admission still sees the owned frozen pairs, demonstrating that a caller cannot alter the admitted authority snapshot by mutating the original list. The frozen intent likewise contains supported immutable scalar values rather than arbitrary nested objects.

The pure policy checks malformed response types and boolean timestamps. Its result is a candidate recovery disposition, so downstream code must not treat retry candidate as permission to execute immediately. It still needs exact identity, current authorization, deadline and provider semantics at submission time.

The regression-testing guide helps retain these as separate contract fixtures. A broad integration success test may never hit revocation during refresh. Explicit barriers make that race reproducible without relying on flaky sleeps or a real CRM outage.

Preserve unknown write outcomes

A write can commit remotely even when the client receives no usable response. Treating every transport error as an invalid token or a retryable transient failure can repeat the business effect. Record whether submission occurred and what authoritative provider evidence is available.

The policy has a distinct submitted-write-unknown response. It returns pending reconciliation even when the local retry deadline is exhausted. Expiry prevents new action; it does not erase uncertainty about an effect already submitted.

Keep the immutable operation identity and payload with that unresolved record. If the provider supports safe idempotent recovery, use its documented contract under the same identity. If it offers a status lookup, reconcile the original operation before deciding whether another write is appropriate.

AWS's idempotent API guidance explains the role of caller-provided identity in expressing retries. Your CRM adapter still needs endpoint-specific evidence. A generic provider-safe boolean in the teaching policy is trusted configuration, not automatic discovery of an external guarantee.

A refreshed token may be necessary to query the old operation's status, but that query also requires current permission. If permission was revoked, retain the unresolved outcome and route it through an authorized administrative recovery path. Do not use stale access merely because reconciliation feels operationally urgent.

Webhook evidence can help resolve asynchronous provider outcomes, but only after its own signature, identity and ordering checks. A valid callback does not automatically prove that a specific user-approved CRM action completed; bind its evidence to the relevant operation and resource.

A canceled conversation and a canceled CRM write are different states. Stopping speech or ending the call does not retract a request already accepted by the provider. The shutdown contract requires unresolved external work to remain visible after local tasks stop.

Use caller-facing language that reflects the evidence. It is appropriate to explain that access needs renewal, permission is unavailable or an earlier update is still being checked. It is not appropriate to announce that no change occurred solely because a local exception interrupted the response path.

Waiting speech for slow tools can keep the interaction understandable without inventing completion. Keep the explanation concise and avoid exposing credential details, internal provider messages or recovery instructions that would ask the caller to read secrets aloud.

Safe retries retain their operation and provider contract; unknown writes reconcile before another action

Keep the conversation useful and private

An authorization failure should narrow what the agent can do while preserving useful conversation state. The agent may still explain the requested task, collect non-sensitive clarification or arrange an authorized handoff. It must not disclose restricted CRM content merely to make the failure explanation more specific.

Choose a small public status vocabulary. Reauthorization required, permission denied, pending reconciliation and unable to proceed describe different recovery paths. Raw HTTP status, provider stack traces and credential error bodies belong in controlled diagnostic evidence, not spoken output.

Never ask a caller to dictate an access token, refresh token, API key or recovery secret into the call. Use the approved secure authorization channel. A voice recording and transcript pipeline can create several copies of spoken material, so treating the call as a credential input form creates unnecessary exposure.

The recording consent boundary does not make secret collection safe. Permission to record a conversation and permission to receive or retain a credential are separate issues. Avoid collecting the credential rather than relying on later transcript redaction to remove it everywhere.

Keep diagnostic events structured. A fixed reason code, scoped operation reference, adapter version and observed transition can be enough to identify the failed boundary. Redaction before telemetry export should reject unapproved fields instead of trusting a regex to recognize every possible secret.

A handoff needs current status and exact permitted intent values. It should distinguish an unsubmitted action from a submitted write with unknown outcome. Otherwise a human or another worker can repeat the operation because the handoff summarized both states as CRM error.

The caller's consent to continue after a failure should apply to the operation actually available. If the resource or terms change during recovery, present the new values and obtain the required confirmation. Do not transfer the old approval to a repaired payload or a broader action that happens to bypass the original error.

For multilingual interactions, keep the safe status meaning stable while adapting presentation through the approved language workflow. The multilingual task-evaluation guide separates understandable wording from task correctness. A fluent apology does not establish that access was restored or that a write safely failed.

A failure can also consume the remaining call budget. Use the latency-boundary method to distinguish credential wait, policy processing and caller-perceived delay. Do not add overlapping intervals or report token-refresh time as if it were the complete task latency.

Caller-safe statuses are separated from token material, raw errors and secret request headers

Test adapters and recovery boundaries

Begin with a provider matrix for the exact endpoints you support. Record documented error categories, refresh policy, required scopes, resource permission checks, idempotency behavior and status-query options. Mark unknown capabilities explicitly instead of inferring them from another provider's API.

The local fixture supplies a controlled policy test, while a real adapter test must inspect request construction, credential rotation, secure storage and response normalization. Keep those reports separate. A passing synthetic refresh race does not prove that a provider accepts your OAuth client configuration.

Test current permission removal at several points: before the action starts, while refresh waits, after refresh returns and immediately before submission. Any additional await between the final check and the effect creates another opportunity for state to change. The owning service must enforce its own permission even when the client controller behaves correctly.

Use a strict separation between retries of the same operation and newly reviewed operations. Prompt-injection defenses also depend on this boundary: an instruction in untrusted content or a provider error body must not select a more privileged action or alter resource scope.

Check cancellation and deployment behavior. A worker drain should stop admitting new CRM work while preserving already-submitted unknown outcomes. The rolling-deployment protocol keeps readiness, local admission and active work distinct; token recovery cannot be allowed to bypass a closed admission gate.

Measure recovery outcomes against admitted operations, not merely refresh responses. The cost-per-success method makes the denominator explicit. A refreshed credential that leads to permission denial is useful recovery evidence, but it is not a completed caller task.

Include malformed and unsupported provider responses in the fixture set. Reject an unrecognized error shape safely and retain a controlled diagnostic reference. Do not keep retrying simply because the parser failed to classify the result into one of the convenient success-oriented branches.

Finally, exercise the exact deployed provider and SDK versions in a permitted test environment. The article's historical community issue is a question source, not a claim that the latest connector still has the same problem. Keep observed adapter behavior, local model guarantees and unresolved external assumptions distinct in the release record.

Authorization caches need an explicit scope and freshness contract. A cached grant for one tenant, principal, audience or resource must not be reused merely because a second conversation calls the same tool name. Cache hits should carry the evidence needed to establish their scope, and a revocation observation must invalidate the current admission view before another action is selected.

The tenant-aware cache-key guide explains why key identity is part of correctness. An authorization cache has additional security meaning, so its adapter must document which policy revision and authority generation its result represents. Do not confuse a cache TTL with a promise that the underlying permission cannot change during that interval.

Shared devices add another trap. The shared-phone memory boundary keeps a returning phone number separate from a verified person. A previous conversation's successful CRM access must not become the default authority for the next caller on the same device, and a refresh operation must remain attached to the correctly authenticated principal rather than a convenient call-history record.

A provider can also distinguish application permission from end-user permission. An integration credential may have broad technical scopes while a particular user is allowed to view only a subset of records. The resource service must enforce the complete policy, including tenancy and record ownership, instead of assuming the OAuth scope string contains every business rule needed for the requested action.

When a permission check fails, preserve the requested operation without converting it into a more privileged fallback. For example, a denied update must not cause an automatic export, administrative search or alternate-account lookup to obtain similar data. A fallback is another action with its own resource and authorization requirements, even when the model considers it a helpful way to finish the conversation.

Keep local generation changes narrow enough to explain. The teaching authority increments one generation for any permission replacement and invalidates every gate captured from the prior generation. A larger service may use more specific resource or grant revisions, but it must demonstrate that each affected action observes revocation before mutation. Precision can reduce unnecessary rejection only if it preserves the same current-authority invariant.

A reauthorization-required response is not an instruction to discard operation history. If a write was submitted before the credential problem became visible, retain that unresolved operation while access is repaired through a secure channel. Once permission is available again, reconcile the existing request under its original identity before considering a new action. The fact that authentication succeeded later does not establish what happened earlier.

Audit reports should distinguish a blocked unsafe action from an operational failure. Rejecting stale refresh completion is the expected result of the race fixture, not evidence that the credential adapter is broken. Conversely, an admitted local action is not proof of a remote update. Give each report field a specific boundary so the same result cannot be counted as both a successful authorization test and a completed customer task.

For provider tests, use permitted synthetic accounts and record the selected endpoint, client configuration and response category without copying secret material into the fixture. If an actual error differs from the catalog, stop automatic normalization until the adapter can classify it from documented behavior. An unknown error should remain visible as unsupported evidence instead of being forced into the invalid-token path to keep a demo moving.

Operationally, retain a recovery owner for pending work. Ending the call or draining the worker should not abandon the record of a submitted unknown write. The durable workflow must identify who may reconcile it, which current permissions that actor needs, and how the outcome will be reported without asking another caller to repeat sensitive details or accidentally authorize another mutation.


Frequently asked questions

Should every CRM 401 trigger token refresh?

No universal mapping should be assumed. Normalize the selected provider's documented response and confirm that refresh is appropriate for that grant. The local policy permits one refresh candidate for a recognized invalid-token condition, then requires current exact-action authorization again.

Does insufficient scope mean the token expired?

It means the required permission is missing under the relevant response contract. Refreshing an existing credential does not automatically add that permission. Use the provider's authorized grant-change or reauthorization workflow and keep the blocked action unexecuted.

Can a successful refresh override revocation?

A stale refresh result must not override a newer local revocation observation. The fixture captures an authority generation, revokes while refresh waits and rejects the late success. Real providers can have propagation delays, so the client rule does not claim instant global invalidation.

Why check authorization after awaiting refresh?

The current permission, operation deadline or task owner can change while the adapter waits. A preflight check describes an earlier moment. Recheck after the await and enforce current conditions again at the eventual resource mutation boundary.

What happens if refresh finishes at the deadline?

The model treats the exact deadline as expired and rejects the held action. Its trusted clock is an integer teaching input, not a provider timestamp. Production code needs an appropriate monotonic deadline domain and a policy for how remaining call budget affects handoff.

Can the old gate revive when access is restored?

The model keeps stale and expired gates terminal. Restoring similar permission pairs advances the authority generation and requires a new gate. This prevents an old held action from acquiring current authority merely because the visible permission set later looks familiar again.

Is token validity enough to authorize a contact update?

The update also needs permission for its exact action and resource, plus the caller's approved business intent. A valid credential is one input to that process. The owning resource service must enforce authorization independently of the conversational model's request.

Should I retry a CRM write after a timeout?

First establish whether it was submitted and what the provider's documented recovery contract permits. A submitted write with no confirmed response is unknown, not safely failed. Preserve the original operation identity and reconcile instead of issuing a new business operation automatically.

Why retain unknown status after the deadline expires?

Expiry prevents additional action under the old budget; it does not prove that a previously submitted effect never occurred. The policy therefore preserves pending reconciliation for an unknown write. Recovery needs authoritative provider evidence or an authorized administrative workflow.

Does a fresh token require a new operation key?

Credential generation and business operation identity are separate. An exact retry should retain its original immutable identity and values under the provider's contract. A new key should represent a new reviewed logical operation, not simply credential recovery.

What does retry candidate authorize?

It identifies a possible recovery path under the trusted policy inputs. It is not permission to execute immediately or evidence of success. The adapter must still check current authorization, deadline, exact operation values and endpoint-specific replay support at submission.

How should rate limiting interact with call budget?

Compare the documented retry time with the operation deadline. If useful progress cannot fit within the budget, hand off or report a truthful pending state. Repeated waiting should not silently extend the caller's original authorization or turn an unsafe write into a safe retry.

Can raw provider errors be read to the caller?

Use a fixed safe status vocabulary instead. Provider errors can contain account details, request values or credential fragments, and their technical wording can misstate the business outcome. Keep controlled diagnostic references separate from spoken explanations and broad logs.

Should the caller read a refresh token aloud?

No. Use the approved secure authorization channel rather than collecting credentials through a recorded or transcribed conversation. Avoiding collection is safer than assuming later redaction can remove every copy across audio, transcript and telemetry sinks.

What does the local artifact test?

It tests normalized policy decisions, owned permission snapshots, per-resource admission, refresh budget, revocation and expiry after an await, and adapter cleanup on success and exception. It creates no credentials and performs no CRM mutation. Provider acceptance and secure token rotation remain external obligations.

Why use synchronization barriers instead of sleep?

The barrier makes the race explicit: refresh has started, authority changes, then completion is released. The deadline fixture similarly changes a controlled clock while waiting. This produces reproducible local assertions without depending on a real outage or scheduler timing.

Does a completed read authorize the next write?

No. A historical result is evidence about that earlier operation, not a continuing grant for another action. Check the new write's principal, resource, current permission, exact approved values and relevant business version before allowing it to proceed.

What if access disappears during reconciliation?

Retain the unknown operation and route recovery through a currently authorized path. Do not use stale credentials simply because the outstanding result is inconvenient. Lack of access does not convert an unknown submitted write into a confirmed failure.

Can a worker restart discard refresh history?

The example's per-gate budget is local and does not claim durable restart behavior. A production controller must define which operation and recovery evidence survives process loss. Already-submitted unknown effects need durable records independent of an in-memory refresh task.

How should an integration report successful recovery?

Name the observed boundary: credential refreshed, exact action reauthorized, provider request accepted or business result confirmed. These are different statements. A refresh response alone cannot support a claim that the caller's task completed or that a remote write was safely canceled.

Ship a policy with explicit limits

Build the provider response adapter before adding broad automatic retries. Keep its normalized vocabulary small, its credential handling outside the conversation and its current permission checks close to the owning resource. Freeze the exact intent so recovery cannot quietly change the action being authorized.

Run the complete local fence and retain separate proof for provider integration. In the deployed workflow, test revocation during refresh, expiry while waiting, missing scope and submitted-write uncertainty. Confirm that every path closes owned resources or records the unresolved outcome needed for recovery.

For 60db Agent integrations, make status statements as precise as the evidence permits. A useful system can explain that access needs renewal or that a prior update remains unresolved while still refusing an unauthorized action. Continue with the business-state conflict contract when the next concern is whether a valid permission applies to the resource version the caller actually approved.