contract connector · 40-connector.md · 41-secrets.md · 42-drive-extensions.md · guide · card
Outbound credentials
An outbound credential authenticates a pull, derive step or metered call to a vendor API. Connector implementers use this contract to declare, resolve, lease, attach, rotate and record credentials.
A credential from its reference to the vendor, keyed throughout by its logical name:
reference
Outbound credential references, the secret:// scheme and value templates.
reference-schemetestsecret://<name>carries a logical name matching[a-z0-9-]+of at most 128 chars. A reference names no storage path, file name or key identifier.backend-independencetestThe adapter maps a logical name to a storage location. Changing credential stores changes the backend mapping without editing declarations.value-templatetestA value embedding a credential is a template of literal text with${secret://<name>}placeholders, such asBearer ${secret://vendor-token}.foreign-placeholdertest${env://NAME}or a bare${NAME}inside a template raisesSecretForeignPlaceholderat parse, naming the value and the span.malformed-placeholdertestWhen reading a declaration, the parser raisesSecretMalformedTemplatefor an unclosed, empty or nested placeholder, before any text leaves the process.Why
P1environment-is-a-misstestTemplate hydration skips the process-environment adapter unlessCONTEXTFUL_SECRETS_ALLOW_ENV_TEMPLATES=1, and continues through the chain. If no adapter answers, the resolver appliesunresolved-name.whole-value-bindingtestenv://NAMEbinds a whole credential outside the template grammar.whole-value-referencetestA whole-valuesecret://<name>reference outside templates, such as a[sync]credential key, resolves through the assembled adapter chain, including process environment, underfirst-hit-winsandshadowed-name.Why
because a whole-value key sits in the node’s own configuration, whereenv://NAMEalready reads the environment directlymaterial-in-a-declarationtestA credential-shaped literal standing where a reference belongs raisesSecretMaterialInDeclarationat validation, naming the key.credential-keytestA credential key ignores case and reads-as_:authorization,proxy_authorization,password,passwd,secret,token,api_key,apikey,access_key_id,secret_access_key,private_key, or a name ending_password,_secret,_tokenor_api_key.name-is-the-jointestDeclarations, provider entries, cache keys, records and run provider attribution identify the same credential by its logical name.
resolve
The provider interface, adapter order, request-time resolution, credential buffers and deployment security configurations.
provider-porttestOne provider interface resolves a reference to credential material. Each backend implements this interface as an adapter.provider-chaintestCONTEXTFUL_SECRETS_BACKENDnames the chain’s adapters in answer order,envwhen unset, and the lease provider joins at its head. Naming an adapter the build does not link refuses at assembly.first-hit-winstestHydration uses the first adapter answering the name. The resolver consults no later adapter for that reference during the run.shadowed-nametestAt first hydration, a name answered by the serving adapter and by any adapter behind it raisesSecretNameShadowed, naming the logical name and both adapters.Why
because a stray environment variable otherwise shadows the manager unnoticedhydration-is-just-in-timetestWhen building each request, the host asks the resolver for credentials. Cached material followscache-ttl. Declarations, journal entries, audit records, run records and log lines contain no hydrated values.redacting-wrappertestEvery hydrated value, including a pure-literal template, uses a wrapper whose debug and display forms print a fixed sentinel. The host reveals bytes only when writing the request.wiped-on-droptestA hydrated wrapper has noClone. Shared owners reference one buffer that zeroes when its last owner drops, including an expired cache entry. The wrapper excludes request and transport copies.Why
because a freed credential otherwise stays readable until the allocator reuses its memoryresolver-per-sourcetestEach source has one resolver and its own cache. The resolver combines concurrent first hydrations of one name into one lookup.cache-ttltestA cache entry lives at most 300 s.unresolved-nametesttestIf no assembled adapter answers a reference, the resolver raisesSecretUnresolvedReference, naming the reference. The error occurs at preflight for static bindings and at the call otherwise.Why
P2file-backendtestThefilebackend answerssecret://<name>with the bytes of file<name>underCONTEXTFUL_SECRETS_DIR, less one trailing newline, versioned by their SHA-256; an absent file is a miss.Why
because a version derived from the bytes conditions a put on exactly what the run read, with no counter kept beside the fileaws-secrets-managertestTheaws-secrets-managerbackend answerssecret://<name>with theAWSCURRENTvalue of secret idCONTEXTFUL_AWS_SECRET_PREFIXplus the name, signing underrole-credentials; an absent secret is a miss.inline-outside-developmenttestOutside the development profile ofversion-profile, a manifest string under acredential-keyholding nosecret://orenv://reference and passingmaterial-in-a-declarationraisesSecretInlineCredentialat declaration read, naming file and key path, never the value.Why
because a token pasted into a committed manifest reaches version control, CI logs and every clone, outside any adapter’s custody
lease
The first credential adapter: credential lease declarations, mint requests, expiry, failures and the mint credential.
scope-declarationtestCONTEXTFUL_LEASE_SCOPESlists the logical names hydrating as credential leases, each optionally asname=scope.head-of-chaintestFor a declared name, the resolver uses the lease provider without falling back to later adapters. A shadowing adapter raises undershadowed-name.undeclared-name-deferstestAn undeclared name sends the lease provider no request and leaves precedence among the other adapters untouched.empty-scope-settestSelecting the lease backend with no scope declared raisesSecretLeaseScopesEmptyat startup.Why
P3leasetestThe resolver holds a credential lease’s value and its expiry together in memory for the run. The resolver evicts both at expiry. Neither enters a file, journal entry or audit record.malformed-leasetestA credential lease carrying no expiry, or one already past on arrival, raisesSecretMalformedLeaseas a transient failure.Why
P6cache-windowtestA leased name’s cache entry expires at the earlier ofcache-ttland the credential lease’s own expiry.retirement-margintestAn entry retires 10 percent of its window ahead of expiry, the margin capped at 30 s.Why
because a request sent near the boundary still lands inside the grantendpointtesttestThe provider answersPOST <provider-url>/leases, carrying a bearer mint credential and body{ "scope": "<lease scope>" }, withvalueand one expiry,expires_inseconds,expires_atepoch seconds or an RFC 3339 instant, at top level or underlease.Why
because a batched answer ties one scope’s refusal to every name in it and mints material no request has asked forunknown-scopetestA404raisesSecretLeaseScopeUnknownas a configuration fault. The resolver does not fall back to a stored credential for a declared name.mint-rejectedtestA401or403raisesSecretMintRejected, classedauth_expired.Why
P6rate-limitedtestA429passes itsRetry-Afterto the run’s retry, classedrate_limited.Why
P6request-rejectedtestAny other4xxraisesSecretLeaseRequestRejectedas a permanent configuration fault.Why
P6transient-classtestA5xxand a transport error are transient. The mint client retries nothing itself; the run’s retry schedule is the one retry layer.Why
P6vendor-requests-on-failuretestA run holding an expired credential lease whose provider is unreachable issues 0 requests to the vendor.Why
P6calls-per-resolvertestA resolver issues at most 1 calls to the mint endpoint per declared name per lease window.bootstrap-credentialtestThe engine authenticates to the mint endpoint with one standingsecret://credential, hydrated only through the adapters the chain held before the lease provider joined.bootstrap-unservedtestA bootstrap name no adapter behind the lease provider answers raisesSecretBootstrapUnresolved, naming the reference.bootstrap-declared-leasedtestListing the bootstrap name among the leased scopes raisesSecretBootstrapLeasedat startup.mint-transporttestThe mint client reaches its endpoint over TLS or loopback and ignores system proxy configuration.redirecttestA3xxfrom the mint endpoint raisesSecretLeaseRedirect; the mint credential is not replayed at the target.
attach
Host handling of outbound requests: network transport, permitted hosts and addresses, credential headers, redirects and URL scrubbing.
no-material-to-a-guesttestNo host import hands credential bytes to guest code; a guest names a request and receives a response.unpermitted-requesttestA request whose host the declaration does not cover raisesSecretUnpermittedRequestand fails the guest call even where guest code discards the error.transport-porttestThe mediated client reaches the network only through a transport interface. The send method connects to a vetted address, follows no redirect, and applies the client’s proxy choice, timeout and body ceiling.resolve-oncetesttestAfterpre-send-hookadmits the hop, the transport interface resolves the name once per request, the client checks each address underprivate-address, and the host connects to the address it vetted.private-addresstestA host name resolving to a private, link-local, unique-local, loopback or cloud-metadata address raisesConnectorPrivateAddress, unless the configured host is itself a loopback name or literal.Why
because an allowlisted name pointed at an internal range is SSRFprivate-range-admittedtestAn address inside a CIDR range listed inCONTEXTFUL_EGRESS_PRIVATE_RANGESpassesprivate-address; the host name allowlist still applies.Why
because a VPC-bound deployment reaches its sources, interface endpoints and PrivateLink services at private addressesprivate-range-rejectedtestA listed range overlapping loopback, link-local169.254.0.0/16orfe80::/10, or the metadata addressfd00:ec2::254, raisesConnectorPrivateRangeRejectedat startup, naming the range.Why
because admitting the metadata or loopback range reopens the SSRF path the private-address check closesbound-hosttestA credential-bound source declares one non-wildcard host, checked at validation and session open. A wildcard entry or table-bound endpoint host beside a bound credential raisesSecretWildcardHost.per-request-hydrationtestA guest session stores hydration handles and plain static headers. The host calls each handle for every permitted request and releases the hydrated header when the request completes.Why
because session-held credential material outlives the request and delays rotationliteral-attach-valuetestAn attach value embedding no reference raisesSecretLiteralAttachValue.Why
because a constant header belongs in the guest’s request constructionredirect-pinningtesttestWith or without credentials, a source follows a hop only at the configured host and port on the same or stronger transport, cleartext-to-TLS included, and stores response bodies from that origin alone.weakened-hoptestA redirect or vendor-supplied next link moving from TLS to cleartext, or to another host or port, raisesSecretRedirectOffOrigin, and the read fails.header-selects-clienttesttestA source declaring any header, literal or hydrated, uses the hardened client and bypasses the system proxy; a source declaring no header keeps the proxy. No outbound client sendsReferer.cleartext-endpointtestThe host marks a reference-hydrated header sensitive. Sending the header to a cleartext endpoint raisesSecretCleartextEndpoint, except on IPv4 or IPv6 loopback.sensitive-header-recordtestA run records which header names carried a credential, by name only.url-scrubbingtestBefore a URL enters an error message, log line or stored column, the host removes query, fragment and userinfo. Scheme, host, port and path remain.credential-in-a-urltestA configured endpoint carrying userinfo raisesSecretCredentialInUrlat validation, naming the source.mediation-covers-every-egresstesttesttestA built-in source, a guest connector, a model call, a limiter call and an exec step’s loopback proxy each attach through the one mediated client.exec-environmenttestAn exec step’s environment, cleared byno-shell, holds no credential underenv-credential. A binding declaringendpoint_hostaddsCONTEXTFUL_EGRESS_URL, replacing anyenventry of that name.exec-proxytestCONTEXTFUL_EGRESS_URLnames a host loopback proxy,http://127.0.0.1:<port>, open for one pull. The proxy sends each request’s method, path, query, headers and body tohttps://<endpoint_host>through the mediated client, following no redirect.exec-proxy-attachtestThe proxy renders the binding’sattachtemplates per request underper-request-hydration, replacing a header of the same name the step sent, and forwards no hop-by-hop header.exec-proxy-refusaltestA request the mediated client fails answers the step502with the failure’s text and fails the unit under that failure, even when the step exits zero.Why
because a step discarding the answer otherwise settles a unit past a refusal the egress rules raised, asunpermitted-requestcloses for guestsexec-proxy-requesttestThe proxy reads at most 64 KiB of request head and 64 MiB of body. A larger request answers413, a body framed withoutContent-Lengthanswers411, and neither sends anything.
rotate
Credential rotation responsibilities and mechanisms.
division-of-labortesttestA credential store contributes versioned storage, a trigger and room for an opaqueexpires_athint beside the ciphertext. The host drives the OAuth loop from the connector’s auth policy and writes the token back as a blind versioned put.static-keytestA static key in an operator-managed secret store turns over with no engine step: a run hydrates the current version at its next cache miss.Why
because the 300 s cache bound already caps staleness, and a per-manager change feed adds a listener and failure domain to every adapteroauth-credentialtesttesttestThe host refreshes an OAuth credential atretirement-marginfrom its declared expiry. The host puts the refreshed version to the answering adapter, conditioned on the hydrated version, then clears the name’s cache entry.operator-enteredtestAn operator re-enters a development or keychain secret by updating the reference.leased-credentialtestThe resolver re-mints a leased credential before its window ends without writing it back. If minting fails, the source uses no credential, including an older value.Why
P4write-back-deniedtesttesttestA refused put or answering adapter without a write method raisesSecretRotationWriteDenied. The host discards the refreshed token without serving it, and the run fails closed.conditional-puttestIf another writer stores a version first, the losing put rehydrates that version without raising an error.turnover-preserves-the-nametestEvery rotation mechanism keeps the logical name, touching no declaration and no pinned connector artifact.write-halvestestOf the linked adapters onlyfilecarries a write method;env,leaseandaws-secrets-manageranswer reads alone.Why
because a Secrets Manager put conditions on no current version, so a racing refresh overwrites a newer refresh token unseenfile-puttestThefilebackend’s put holds an exclusive lock, compares the file’s digest with the hydrated version, and replaces the file by atomic rename with owner-only permissions; a file without write permission refuses the put.Why
because a rename leaves a reader the old bytes or the new ones whole, and the lock orders two processes refreshing one name
oauth
The OAuth refresh a connector manifest declares, which the host runs for a component source’s attach templates.
auth-policytesttestA connector manifest’s[auth]table declares the host’s OAuth refresh asRawAuth: a token endpoint, a client id, a scope, and${secret://<name>}references for the client secret, refresh token and access token.Why
because the connector’s author knows its vendor’s token endpoint and scope, while the host alone holds the credentials a refresh exchangesauth-refusedtestAn[auth]table missing a key or holding another, an endpoint neither TLS nor loopback or carrying userinfo, a value other than one reference, or one name bound twice refuses undermanifest-keys, naming the key.Why
because a refresh posting its client secret in cleartext, or reading one name as two credentials, leaks or corrupts what the store holdsaccess-tokentesttestThe access token name renders from the host’s held token alone: no adapter answers it, no file, journal or record receives it, and the host refreshes when it holds none or atoauth-credential.refresh-granttesttestA refresh posts one form ofgrant_type=refresh_token, the refresh token,scope,client_idand the client secret to the token endpoint through the mediated client, following no redirect.Why
because the form carries the client secret, which a redirect replays at a host the manifest never namedrefresh-answertesttestA success carriesaccess_token, a positiveexpires_inas its declared expiry, and a replacementrefresh_tokenthe host puts underoauth-credential; an answer without a replacement puts nothing.refresh-refusedtesttestA redirect, a4xxother than429, or a success carrying noaccess_tokenraisesSecretRefreshRejected, classedauth_expired, naming the scrubbed endpoint and no token.Why
because a refused grant names a dead refresh token or a misdeclared client, which no retry repairsone-retry-layertesttesttestThe token client sends each refresh once: a429passes itsRetry-Afterand a5xxor transport error is transient, so the run’s retry schedule is the one retry layer.Why
P6rejected-oncetesttesttestA vendor401to a request carrying the held access token refreshes it once and resends that request once; a second401returns to the connector, and a401to a replaced token resends without a refresh.Why
because a token revoked before its declared expiry reads as expired to the vendor alone, and a second refusal names a grant no refresh repairs
Scenarios
connector.rotate.write-back-denied: WHEN the answering adapter refuses the put of a refreshed token, THEN the run fails and no request carries the refreshed token.connector.rotate.conditional-put: WHEN another writer stored a newer version first, THEN the next hydration returns that version and the rotation raises nothing.connector.rotate.rejected-once: WHEN the vendor answers401to the first access token, THEN the host refreshes once, writes the replacement refresh token forward, resends the request and the run lands its rows.connector.rotate.one-retry-layer: WHEN the token endpoint answers503, THEN each run attempt sends it one request and the vendor receives none.
record
Optional operator metadata, provider observations, the inventory of configured bindings, and the provider attribution a run emits.
operator-recordtesttesttestAn optional operator record describes one logical name’s intended grants, account or tenancy, consuming surfaces with their binding names, and rotation location, as descriptive metadata; recording it requires no credential value or decryption key.provider-custodytestThe selected provider owns credential values, versions and rotation state; an external-manager binding requires no ciphertext copy in the repository.optional-recordtestA binding without an operator record remains resolvable and appears in inventory with unknown descriptive fields.unverified-scope-is-markedtestAn operator record marks grants as unknown when the operator has not established them.inventorytestcontextful secrets listdisplays configured logical bindings with their configured adapters, operator metadata and available provider observations. The command reads no credential values and claims no complete provider inventory.observation-provenancetestEach provider-observed field names its adapter and observation time; operator assertions remain separately labelled, including when they disagree with provider observations.expiry-unknowntestInventory reports an absent expiry as unknown.no expiryrequires an explicit assertion or observation. Inventory computes days remaining only for a dated expiry and retains its provenance.expiry-warningtestThe inventory marks each dated expiry falling within 30 d and each elapsed expiry, retaining the expiry’s provenance.configured-is-not-observedtestA configured adapter identifies a binding’s configuration; onlyprovider-attributionidentifies the adapter that answered a run’s reference.provider-attributiontestA run’s commit-phase and failure rows carrycredential_providers. The field maps each hydrated logical name to the answering adapter, using names only.record-tabletestAn operator record is a[secret.<name>]table in the discoveredcontextful.tomltakingOperatorRecord, each key optional;expires = "never"is the one explicit no-expiry assertion.record-rejectedtestA[secret.<name>]table outside discoveredcontextful.toml, without a manifest-boundsecret://name, or containing an unknown key, mistyped value or credential-shaped string raisesSecretRecordRejectedduring declaration read. The error names file, name and key path, excluding values.Why
because a misspelled name otherwise describes nothing while the real binding reads undescribed, and an echoed value carries a pasted token into CI logsrecord-is-inerttestResolution, attachment, rotation and pipeline content hashing read no operator record. Editing a record changes no plan, run or hydrated value.observe-porttestAn adapter’s observation interface returns presence, version, creation and expiry for a name, or unavailable with a reason. The interface holds no hydrated value. Inventory holds only observation interfaces.inventory-outputtestsecrets listexits 0 whatever its marks;--jsonprints one object per binding per line: name, configured adapters, latest answered adapter with run id and instant, operator fields, and observations with adapter and time.
Scenarios
connector.record.provider-custody: WHEN a binding selects an external manager with no encrypted repository entry, THEN the manager remains the sole owner of its value and versions.connector.record.optional-record: WHEN a configured binding has no operator record, THEN inventory includes the binding with unknown descriptive fields.connector.record.observation-provenance: WHEN an operator expiry differs from an observed expiry, THEN inventory shows both with their distinct provenance.connector.record.observation-unavailable: WHEN a provider denies metadata access, THEN inventory reports the reason without reading a credential value.connector.record.expiry-unknown: WHEN neither operator nor provider supplies an expiry, THEN inventory reports unknown expiry and no days remaining.connector.record.configured-is-not-observed: WHEN a binding configures multiple adapters and has no run attribution, THEN inventory reports configuration without asserting an answering adapter.connector.record.record-rejected: WHENcontextful.tomlholds[secret.vendor-tokne]beside a binding namedvendor-token, THEN the declaration read refuses and the message carries no record value.connector.record.observe-port: WHEN inventory lists a lease-served binding, THEN its lease observation reads unsupported and the mint endpoint receives 0 requests.connector.record.record-is-inert: WHEN an operator record’s description changes, THEN the pipeline retains its content hash.
principal-secrets
Per-principal credential names and the adapter that serves their material.
name-bindingA connection binds one opaque secret name to its admitted tenant and principal; a run hydrates only the name bound to its connection.adapter-porttestA principal secret adapter resolves a name to credential material and a version. The adapter’s write method conditionally stores a replacement version under the same name.write-serializationtestConcurrent refreshes of one name serialize across resolvers through the adapter’s conditional version; the losing refresh rehydrates the winner underconditional-put.lease-custodyA lease endpoint holding refresh tokens answers the connection’s name underlease; Contextful retains neither the refresh token nor its replacement.disconnectDeleting a connection revokes its principal secret name before another run hydrates it. If revocation fails, the connection stays paused.Why
because a deleted connection must not regain vendor access from a cached lease
Scenarios
connector.principal-secrets.name-binding: WHEN two principals connect one OAuth client, THEN each run resolves only its own refresh-token name.connector.principal-secrets.write-serialization: WHEN two refreshes race on one name, THEN one version wins and the other resolver rehydrates it.
Shapes
A declaration binding one credential through a template:
[pipeline.source]
name = "http"
[pipeline.source.config]
endpoint = "https://api.vendor.example/v1/records" # one exact host: a credential attaches here
format = "json"
[pipeline.source.config.headers]
Authorization = "Bearer ${secret://vendor-token}"