Connectors

Credential Lifecycle

The credential control plane — health facts, the requirement scanner, hosted Connect-Link authorization requests, and auto-resume on reconnect.

Authentication covers how a credential is resolved and decrypted at dispatch. This page covers everything around that: the control plane that decides whether a connection is even safe to route to, the preflight that answers which credential fields a provider needs, the durable handles for asking an end-user to authorize a connection, and how a workflow recovers when a token dies mid-run.

Every surface here shares one invariant: it summarizes credential health and names credential fields, but it never returns, logs, or resolves credential material.

Credential lifecycle

The credential lifecycle (docs/connectors/credential-lifecycle.md) is the tenant-scoped control plane deciding whether a connection is safe to use for routing, workflows, syncs, proxy requests, and function runs. It is stored as three first-class records:

  • CredentialLifecyclePolicy — required OAuth scopes, refresh-failure thresholds, stale-credential windows, and whether degraded credentials stay eligible for routing.
  • CredentialHealthFact — one current routing-safe readiness snapshot per connection: grade, readiness booleans, missing scopes, refresh exhaustion, connection status, policy version, redacted timestamps.
  • CredentialLifecycleEvent — append-only, redacted history for auth checks, OAuth authorization, refresh success/failure, reauth requirements, policy updates, and revocation.

The health grade moves through an explicit state machine:

reconnect / rotation revoked / deleted revoked / deleted Unknown Healthy Degraded ReauthRequired Revoked

Routing receives connection.credential_health as a read-only CEL input, so a route predicate can filter on ready_for_routing, grade, requires_reauth, refresh_exhausted, missing_scopes, or policy_version:

connection.credential_health.ready_for_routing &&
!("crm.objects.contacts.write" in connection.credential_health.missing_scopes)

Health facts are eligibility signals, not a credential source

Workflow steps still resolve credentials through ConnectionService.Resolve. Health facts only decide whether a connection is eligible. OAuth callback, inline refresh, manual TestAuth, reconnect recovery, and the daily health sweep all project facts through the same lifecycle service, and event metadata is rejected if it contains credential-like keys (token, refresh_token, client_secret, api_key, authorization, password, private_key).

Deployment activation and promotion call PreviewCredentialLifecycleImpact first; if every affected connection would be excluded from routing, the promotion is blocked so operators can stage a reauth or rotation plan before retrying.

The credential requirement scanner

Before a connector is tested, canaried, or run live, the credential requirement scanner (domain/connector/credential_scan.go) answers a narrower preflight question: which secret/config field names must exist for this provider in this auth mode, and where are they supplied? It never returns values — only field names and a present/missing status.

DeriveCredentialRequirements(provider, authType) is the single source of truth, mirroring the dispatch-time handlers. Connection-form fields come from provider.CredentialSchema; platform fields are added per auth mode:

Auth modePlatform requirements
none(none)
secret_text, basic, custom, two_stepschema fields only
oauth2, oauth2_client_credentials, mcp_oauth2client_id (public), client_secret (secret)
oauth1consumer_key (public), consumer_secret (secret)
jwtsigning_key (secret)

Each scan produces a redacted CredentialScanReceipt — missing requirements, present field names, non-blocking warnings — and feeds the plan-091 setup-readiness engine, so a missing required credential blocks the real setup-readiness surfaces with a typed NotReadyError rather than being downgraded to a warning. The read-only MCP tool scan_connector_credentials returns the requirement set plus that receipt; field names appear, values never do.

An authorization request (docs/connectors/authorization-requests.md) is a durable wait handle for asking an end-user to authorize a provider connection. It sits above a short-lived connect session: the session mints the hosted link and one-time token, while the request records tenant/environment, provider, mode, source operation, status, and redacted lifecycle evidence.

  • initial_connect creates a new connection; the service mints a linked connect session and returns the hosted link once in the create response.
  • reconnect refreshes an existing connection and must include existing_connection_id, so OAuth completion updates that connection instead of creating a parallel one.

The hosted link is returned exactly once

Create returns the hosted Connect-Link once. Subsequent get/list/wait responses expose only request state and redacted evidence — never the plaintext connect-session token or a reusable link. Workflow and routing steps should park on the request's wait_correlation_key and resume from its lifecycle event (completed / expired / revoked / failed) rather than polling ListConnections.

Auto-resume on reconnect

When a long-lived OAuth connection's refresh token is revoked, a naive workflow step fails the run and waits for a human. Auto-resume (application/connector/connection_recovery.go) replaces that with a step that pauses and resumes itself once the user reconnects.

Place a waitForEvent step upstream of any connector action that depends on a long-lived connection:

nodes:
  - id: wait_until_connection_healthy
    type: waitForEvent
    config:
      event_name: connector.connection.recovered   # exact name the recovery service publishes
      correlation_key: "{{ connection.id }}"        # must be the connection UUID
      timeout_ms: 604800000                         # 7 days — tune to your SLA

When the end-user completes the hosted reconnect, OAuthCallback updates the existing connection row, RecoverConnection re-validates with TestAuth, resets the refresh-failure count, flips the connection back to active, and — when publish_resume_events: true — publishes connector.connection.recovered. The event-pause substrate matches the registered pause and the coordinator drains the resume signal on its next tick.

Recovery never writes run state

Recovery is default-off (connector.connection_recovery.enabled) and, when on, only publishes events — the coordinator remains the sole writer of eec_workflow_run (the eec_ prefix marks Ductor's durable Enterprise Eventing Core tables). After max_refresh_attempts failures the refresh path short-circuits with ErrTokenExpired without contacting the IdP, protecting it from hammering a dead token. Fully automatic parking on ErrConnectionExpired without an upstream waitForEvent is deferred; the explicit wait pattern is the supported path today.

Where to go next

  • Authentication — how a resolved credential is decrypted at dispatch and encrypted at rest.
  • Key Custody — how the encryption keys behind credentials are held and rotated.
  • Connections — creating connections and direct vs. route mode.