Last verified against: Velvet v2 (2026-05-04 UTC)
Parent epic: #907
Design doc: docs/architecture/velvet/v2-rotation-flows.md
Operator runbook: docs/ops/runbooks/velvet-operator.md
BusAdapter source: velvet/adapters/base.py
Manifest: docs/architecture/velvet/subscription-manifest.yml
You are here because you need to add support for a new credential vendor or a new delivery destination. This guide walks you through:
BusAdapter.push() correctly.RotationContext and returning AdapterResult.After reading this guide you should be able to ship a new adapter without reading source code beyond the files linked here.
In Velvet v2, a rotation is orchestrated in three stages:
Stage 1 — Verify Auth probe with the current token (no mutations)
Stage 2 — Mint + Distribute New token minted at vendor; pushed to all consumers
Stage 3 — Validate + Revoke Consumers healthchecked with new token; old token revoked
Where adapters live: Stage 2 (distribute) and Stage 3 (validate). An adapter is responsible for one specific delivery destination — one row in the subscription manifest. The flow runner calls adapter.push() for every active subscriber when distributing.
What an adapter does NOT own:
consumer_id.This separation means a single credential rotation (e.g., HK_PLATFORM_FULL) can fan out to five consumers — four Heroku config-var PATCH adapters and one GitHub Actions secret adapter — without any adapter knowing about the others.
File: velvet/adapters/base.py
ADR: docs/architecture/adr/0037-velvet-service-bus-subscription-model.md
class BusAdapter(abc.ABC):
@abc.abstractmethod
def push(
self,
credential_name: str,
new_value: str,
context: RotationContext,
) -> AdapterResult:
...
push() is the only abstract method. Every adapter subclass must implement it.
RotationContext fieldsRotationContext is a dataclass. The flow runner constructs it and passes it to push().
| Field | Type | Description |
|---|---|---|
job_id |
str |
UUID of the rotation_jobs row. Safe to log. |
credential_name |
str |
Token taxonomy name, e.g. PM_SERVER_MAIL. Matches token_name in the manifest. Safe to log. |
new_value |
str |
The newly minted credential value. Must not be logged. |
rotate_timestamp |
str |
ISO-8601 UTC string of when the mint occurred. Safe to log. |
env |
str |
prod or staging. Safe to log. |
RotationContext.__repr__ and __str__ redact new_value as <REDACTED>. This is invariant I1: no credential value may appear in logs, audit records, or error messages.
To get a safe audit-correlation handle for the new value:
safe_hash = context.new_value_sha256() # SHA-256 hex digest — safe to log
AdapterResult fields| Field | Type | Required | Description |
|---|---|---|---|
consumer_id |
str |
Yes | Must match the consumer_id from the manifest and the adapter's constructor. |
ok |
bool |
Yes | True on success; False on any failure. |
error_message |
str \| None |
On failure | Human-readable error. Must not contain credential values. Must be None when ok=True. |
http_status |
int \| None |
Optional | Last HTTP status code observed, if applicable. |
Constructing AdapterResult(ok=True, error_message="something") raises ValueError — the invariant is enforced at construction time.
push()These are non-negotiable. The flow runner treats violations as bugs.
Never raise. push() must catch all exceptions internally and return AdapterResult(ok=False). If an uncaught exception propagates, the flow runner marks the consumer as failed with an "adapter bug" error.
Never log new_value. Use context.new_value_sha256() for log correlation. Use credential_name for identification.
Be idempotent. Calling push() twice with the same job_id and new_value must produce the same observable outcome. If the first call succeeded, the second call must return ok=True without creating a duplicate resource at the vendor. The SHA-256 hash pattern (store _last_rotated_sha256 on the instance) is the recommended implementation.
No shell invocations. No subprocess.run(), os.system(), or CLI invocations of any kind. All vendor interactions must use the vendor's HTTP API or official Python SDK.
Feature flag gate. Call self._assert_flag_on() (or your adapter-specific flag check) at the top of push() before making any network calls.
The v2 design separates concerns across four named operations. Each operation is handled by a different component:
| Operation | Owner | What it does |
|---|---|---|
| Verify | Flow runner + vendor module | Auth probe with current token. Confirms the token is still valid before any mutation. |
| Mint | Vendor module | Creates a new credential at the vendor. Returns the new value. |
| Distribute | Flow runner calls BusAdapter.push() |
Delivers the new value to a single consumer. Your adapter lives here. |
| Validate + Revoke | Flow runner + vendor module | Healthchecks each consumer with the new token; then revokes the old token at the vendor. |
Your adapter only implements Distribute. The flow runner calls push() once per consumer per job, in parallel across all active subscribers.
Some vendors require storing metadata alongside the token value to enable future rotation. This uses the companion secret pattern:
{credential_name}__{DESCRIPTOR} (double underscore)HEROKU_API_KEY__AUTH_ID — stores the Heroku OAuth authorization UUID needed to revoke the old tokenCF_DNS_EDIT_RAXX_APP__TOKEN_ID — would store the CF token ID if CF supported programmatic rotationYour adapter should read the companion secret from an environment variable at call time (never at module load). When your adapter mints a new token, write the new companion value to vault via InfisicalWriteAdapter or directly via the Infisical API before returning AdapterResult(ok=True).
Some credentials are classified as password class (e.g., database passwords, service account passwords). These live in AWS SSM Parameter Store, not Infisical, per ADR-0040 and the D2 path shape decision.
SSM path convention: /raxx/{env}/{vendor}/{name}
If your credential is password class, use SSMWriteAdapter instead of InfisicalWriteAdapter for the vault write step. The two adapters share the BusAdapter interface; you choose based on the credential class.
To classify a credential as password class: set credential_class: password in the manifest entry. The manifest validator (velvet/manifests/validator.py) enforces that password-class credentials use update_method: SSM_PUT.
File: docs/architecture/velvet/subscription-manifest.yml
ADR: docs/architecture/adr/0040-velvet-consumer-registration-static-manifest.md
Add one entry per consumer. The manifest is loaded at Velvet startup and reloaded on SIGHUP. Runtime registrations are not accepted.
- token_name: YOUR_CREDENTIAL_NAME # Required. Token taxonomy name.
consumer_id: your-unique-consumer-id # Required. Stable; never reuse after decommission.
env: prod # Required. prod or staging.
active: true # Required. Set false to suppress during rollout.
update_endpoint: "https://..." # Required. Where Velvet delivers the new value.
update_method: PATCH # Required. See update_method values below.
update_auth_token_name: SOME_TOKEN # Required (nullable). Token Velvet uses to auth the update.
healthcheck_endpoint: "https://..." # Optional (null = no healthcheck).
healthcheck_method: GET # Optional. HTTP verb.
healthcheck_auth_header: "Authorization: Bearer {token}" # Optional. {token} is replaced at runtime.
healthcheck_success_status: 200 # Optional. Expected HTTP status.
healthcheck_timeout_s: 15 # Optional. Default 15.
required: true # Optional. Default true. If false, failure is a warning, not a blocker.
capabilities: # Required.
- update
- healthcheck # Include only if healthcheck_endpoint is non-null.
description: "Human label for console" # Required.
update_method values| Value | Description |
|---|---|
PATCH |
HTTP PATCH to update_endpoint with the standard Velvet update body |
PUT |
HTTP PUT |
POST |
HTTP POST |
PUT_ENCRYPTED |
GitHub Secrets API: Velvet NaCl-encrypts the value before PUT |
INFISICAL_WRITE |
Internal: Velvet writes directly to Infisical vault using its machine identity |
SSM_PUT |
Internal: Velvet writes to AWS SSM Parameter Store |
OPERATOR_MANUAL |
No automated delivery; console walks operator through manual steps |
python3 -m velvet.manifests.validator docs/architecture/velvet/subscription-manifest.yml
manifest-lint job.raxx-velvet-prod and raxx-velvet-staging. Velvet reloads the manifest on startup.GET /tokens/{token_name}/subscribers.Set active: false first (to suppress distribution attempts while the PR is in flight). Do not delete the entry immediately — the consumer_id must remain in the manifest as long as there are rotation_job_consumers rows referencing it in the database (retention: 2 years).
Every adapter must have tests covering at minimum these five scenarios:
push() returns AdapterResult(ok=False) without making any network call.push() returns ok=True with the correct consumer_id.push() returns ok=False with a non-empty error_message that contains no credential values.ConnectionError; push() returns ok=False with is_retry_eligible returning True.push() twice with the same new_value after a successful first call returns ok=True on the second call without making a second network call. Verify by asserting the mock was called exactly once.Additional recommended scenarios:
new_value does not appear in any log output at DEBUG, INFO, WARNING, or ERROR level (invariant I1).push() catches an unexpected exception and returns ok=False rather than propagating it.velvet/tests/test_adapter_{vendor_name}.py
import os
import unittest
from unittest.mock import MagicMock, patch
from velvet.adapters.base import AdapterResult, RotationContext
from velvet.adapters.your_vendor import YourVendorAdapter
_FLAG_ENV = {"FLAG_VELVET_YOUR_VENDOR_ADAPTER": "1"}
def _make_ctx(**kwargs) -> RotationContext:
defaults = dict(
job_id="job-test",
credential_name="YOUR_CREDENTIAL_NAME",
new_value="new_secret_do_not_log",
rotate_timestamp="2026-05-04T06:00:00Z",
env="staging",
)
defaults.update(kwargs)
return RotationContext(**defaults)
class TestYourVendorAdapter(unittest.TestCase):
def test_flag_off_returns_error_without_network_call(self):
adapter = YourVendorAdapter(consumer_id="your-consumer-id")
ctx = _make_ctx()
with patch.dict(os.environ, {"FLAG_VELVET_YOUR_VENDOR_ADAPTER": "0"}):
with patch("velvet.adapters.your_vendor.requests") as mock_req:
result = adapter.push("YOUR_CREDENTIAL_NAME", "new_value", ctx)
self.assertFalse(result.ok)
mock_req.get.assert_not_called()
mock_req.post.assert_not_called()
mock_req.patch.assert_not_called()
def test_happy_path_returns_ok_true(self):
adapter = YourVendorAdapter(consumer_id="your-consumer-id")
ctx = _make_ctx()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.ok = True
with patch.dict(os.environ, _FLAG_ENV):
with patch("velvet.adapters.your_vendor.requests.post", return_value=mock_resp):
result = adapter.push("YOUR_CREDENTIAL_NAME", ctx.new_value, ctx)
self.assertTrue(result.ok)
self.assertEqual(result.consumer_id, "your-consumer-id")
def test_terminal_failure_401(self):
adapter = YourVendorAdapter(consumer_id="your-consumer-id")
ctx = _make_ctx()
mock_resp = MagicMock()
mock_resp.status_code = 401
mock_resp.ok = False
with patch.dict(os.environ, _FLAG_ENV):
with patch("velvet.adapters.your_vendor.requests.post", return_value=mock_resp):
result = adapter.push("YOUR_CREDENTIAL_NAME", ctx.new_value, ctx)
self.assertFalse(result.ok)
self.assertIsNotNone(result.error_message)
self.assertNotIn(ctx.new_value, result.error_message) # Invariant I1
def test_transient_failure_5xx_is_retry_eligible(self):
adapter = YourVendorAdapter(consumer_id="your-consumer-id")
ctx = _make_ctx()
mock_resp = MagicMock()
mock_resp.status_code = 503
mock_resp.ok = False
with patch.dict(os.environ, _FLAG_ENV):
with patch("velvet.adapters.your_vendor.requests.post", return_value=mock_resp):
result = adapter.push("YOUR_CREDENTIAL_NAME", ctx.new_value, ctx)
self.assertFalse(result.ok)
self.assertTrue(adapter.is_retry_eligible(result))
def test_idempotency_no_second_network_call(self):
adapter = YourVendorAdapter(consumer_id="your-consumer-id")
ctx = _make_ctx()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.ok = True
with patch.dict(os.environ, _FLAG_ENV):
with patch("velvet.adapters.your_vendor.requests.post", return_value=mock_resp) as mock_post:
adapter.push("YOUR_CREDENTIAL_NAME", ctx.new_value, ctx)
adapter.push("YOUR_CREDENTIAL_NAME", ctx.new_value, ctx)
# Second push must not make a second network call
self.assertEqual(mock_post.call_count, 1)
File: velvet/adapters/postmark.py
Issue: #950
Postmark has no token-creation API. The Postmark adapter implements Stage 2 distribution as a validation step: it calls GET /server with the new token (which was written to vault by the Infisical adapter in the same fan-out) and confirms HTTP 200 means the token is live.
Key characteristics:
- Single HTTP GET call. No POST or PATCH.
- Idempotency: stores _last_validated_sha256 on the instance. Second push with the same value returns ok=True without a network call.
- Retry logic: 5xx and network errors are transient; 4xx is terminal.
- Revoke: operator-manual only (no Postmark API for revocation).
This is the right starting point for any adapter that validates rather than writes.
File: velvet/adapters/heroku.py
Issue: #1100
The Heroku adapter orchestrates a complete token lifecycle inside a single push() call:
HEROKU_PLATFORM_API_TOKEN env var.GET /account). Fail fast if it is already invalid.POST /oauth/authorizations).GET /account).PATCH /apps/{app}/config-vars) — authenticates with the old token.DELETE /oauth/authorizations/{auth_id}) — authenticates with the new token.Key characteristics:
- The old token is read from an env var, not from new_value. new_value is the expected outcome and is used for idempotency detection.
- Distribute uses the OLD token for auth (the target apps have not yet received the new token).
- If distribute is incomplete, push returns ok=False without revoking the old token.
- The companion secret HEROKU_API_KEY__AUTH_ID stores the OAuth authorization UUID needed to revoke.
- All intermediate failures include whether the old token is still live (to guide recovery).
Use this pattern when the adapter must orchestrate multiple sub-steps and the order of operations matters.
File: velvet/adapters/infisical.py
Issue: #1094
The Infisical write adapter writes the new credential value to vault. It handles the create-vs-update distinction and the folder-creation pre-condition automatically:
Key characteristics:
- Handles the Infisical 404 folder-not-found quirk automatically (feedback: vault_folder_must_exist).
- Uses the D2 path convention: /raxx/v{N}/{env}/{vendor}/{name}.
- vendor is derived from the credential name prefix if not supplied explicitly.
- Used as the delivery mechanism for any INFISICAL_WRITE manifest entry.
Two flavors based on manifest update_method:
- INFISICAL_WRITE: Velvet writes to vault using its own machine identity. Use for internal vault entries.
- External HTTPS endpoints: use a dedicated adapter that handles the specific vendor's API shape.
Answer these questions before writing any code:
update_method: OPERATOR_MANUAL.update_method.)healthcheck capability.)Create velvet/adapters/{vendor_name}.py. Subclass BusAdapter. Implement push().
Skeleton:
"""velvet.adapters.{vendor_name} — {VendorName}TokenAdapter"""
from __future__ import annotations
import hashlib
import logging
import os
from typing import Optional
import requests
from velvet.adapters.base import AdapterResult, BusAdapter, RotationContext
logger = logging.getLogger(__name__)
_TIMEOUT_S = 15
_FLAG_ENV_VAR = "FLAG_VELVET_{VENDOR_NAME}_ADAPTER"
def _flag_is_on() -> bool:
val = os.environ.get(_FLAG_ENV_VAR, "").strip().lower()
return val in ("1", "true", "yes")
class {VendorName}TokenAdapter(BusAdapter):
"""Delivers a new {vendor} credential to {consumer description}."""
def __init__(self, consumer_id: str) -> None:
self._consumer_id = consumer_id
self._last_pushed_sha256: Optional[str] = None
def push(
self,
credential_name: str,
new_value: str,
context: RotationContext,
) -> AdapterResult:
# 1. Feature flag gate
if not _flag_is_on():
return AdapterResult(
consumer_id=self._consumer_id,
ok=False,
error_message=f"{_FLAG_ENV_VAR} is off",
)
# 2. Idempotency check
incoming_sha256 = hashlib.sha256(new_value.encode()).hexdigest()
if self._last_pushed_sha256 == incoming_sha256:
return AdapterResult(consumer_id=self._consumer_id, ok=True, http_status=200)
# 3. Make vendor API call (wrap in try/except — push() must never raise)
try:
resp = requests.post(
"https://api.vendor.example.com/update",
headers={"Authorization": f"Bearer {new_value}"}, # never logged
json={"key": "value"},
timeout=_TIMEOUT_S,
)
except Exception as exc:
return AdapterResult(
consumer_id=self._consumer_id,
ok=False,
error_message=f"Network error: {type(exc).__name__}",
)
if resp.status_code == 200:
self._last_pushed_sha256 = incoming_sha256
return AdapterResult(consumer_id=self._consumer_id, ok=True, http_status=200)
return AdapterResult(
consumer_id=self._consumer_id,
ok=False,
error_message=f"Vendor returned HTTP {resp.status_code}",
http_status=resp.status_code,
)
def is_retry_eligible(self, result: AdapterResult) -> bool:
if result.ok:
return False
if result.http_status and result.http_status >= 500:
return True
return False
Add the flag to backend_v2/api/feature_flags.yaml:
FLAG_VELVET_{VENDOR_NAME}_ADAPTER:
default: false
description: "Enable {VendorName} BusAdapter push() calls (Velvet v2)"
Add one entry per consumer to docs/architecture/velvet/subscription-manifest.yml. See section 7 for the full schema.
Run the manifest validator:
python3 -m velvet.manifests.validator docs/architecture/velvet/subscription-manifest.yml
velvet/tests/test_adapter_{vendor_name}.py covering the five required scenarios from section 8.
Add your adapter class to velvet/adapters/__init__.py:
from velvet.adapters.your_vendor import YourVendorAdapter # noqa: F401
And register it in velvet/registry/adapter_factory.py:
ADAPTER_REGISTRY["{update_method_name}"] = YourVendorAdapter
raxx-velvet-staging first. Run a testing-flow rotation against a throwaway token to confirm the adapter works end-to-end.raxx-velvet-prod and set the feature flag to true on the prod Heroku config vars.| Invariant | Rule |
|---|---|
| I1 | new_value must never appear in any log line, any AdapterResult.error_message, any exception message, or any structured field. Use SHA-256 hash for correlation. |
| I4 | All meaningful operations inside push() must log at least: job_id, credential_name, HTTP status (if applicable), duration. |
| No subprocess | No subprocess.run(), os.system(), os.popen(), or any shell invocation. All vendor interaction must use requests or an official SDK. |
| HTTPS only | All outbound network calls must use HTTPS. The manifest validator rejects http:// endpoints. |
| Secrets not stored | The adapter must not persist new_value to disk, database, or any shared state. The _last_pushed_sha256 pattern stores only the SHA-256 digest, not the value. |
push() never raises (bare except Exception at the outermost level)new_value does not appear in any log statement at any levelnew_value does not appear in any AdapterResult.error_messagenew_value returns ok=True without a network callis_retry_eligible() correctly returns True for transient failures, False for terminal failuressubprocess.run() or shell invocationbackend_v2/api/feature_flags.yaml with default: false