Secret Rotation
Reactive credential propagation: vault rotates, consumers reconnect, zero kernel changes.
The Problem
API keys expire. Vaults rotate them on a schedule. Every service that holds a database connection or an API client needs the new credential – without a restart.
The typical approaches all have costs:
- Polling. Each consumer polls on a timer. Stale keys for up to one poll interval, wasted I/O when nothing changed.
- Restart-on-rotate. The vault triggers a rolling restart. Falls apart for long-lived connections and services with expensive warm-up.
- Manual callback wiring. Each consumer registers a listener. O(n) boilerplate that must be maintained for every new consumer.
All three violate the same principle: the consumer should not need to know how or when credentials change. It should declare what it reads, and the infrastructure should handle propagation. That is what the reactive graph does.
Architecture
The chain involves four components, three of which are stock platform providers:
VaultSimulator config.set("...api_key", new_key)
▼
ConfigProvider Signal._state.set(new_dict) → notifies subscribers
▼
CredentialProvider scoped .get() → config.get() → reactive read
▼
DatabaseClient @effect re-runs → reconnects with new key
The kernel itself is not in this chain. ConfigProvider owns a Signal[dict]. CredentialProvider reads it through config.get(). DatabaseClient’s @effect reads credentials. When the vault calls config.set(), the Signal produces a new dict (different identity), notifies the effect, which re-runs and reconnects.
How It Works
Step 1: ConfigProvider stores state in a Signal
At activation, ConfigProvider creates self._state = Signal(data) (line 125 of src/signalpy/providers/config.py). Every call to config.get(key) calls self._state.get() internally – a reactive read that registers the caller as a subscriber of the Signal.
When config.set(key, value) is called, it copies the dict, mutates the copy, and calls self._state.set(new_dict). Because new_dict is a different object from the old dict (value is not self._value in Signal.set), the Signal increments its version and notifies all subscribers.
Step 2: CredentialProvider is a pass-through
CredentialProvider (line 19 of src/signalpy/providers/credentials.py) holds a reference to IConfig and delegates every .get() to self._config.get(f"apps.{app}.targets.{target}.{key}"). It does not cache. It does not subscribe. It is a thin scoping wrapper.
The kernel wraps it further with _ScopedCredentials (line 596 of src/signalpy/kernel/__init__.py), which binds the app parameter to the consumer’s component name. So when DatabaseClient calls self.rt.creds.get("api_key", target="prod"), the actual config path resolved is apps.db-client.targets.prod.api_key.
Step 3: The @effect tracks the full dependency chain
Here is the full DatabaseClient from src/signalpy/examples/secret_rotation.py:
@component("db-client", version="1.0", depends=["config", "credentials"])
@requires(config="IConfig", creds="ICredentials")
class DatabaseClient:
@lifecycle.activate
def activate(self):
self.connection_log = [] # observable history for testing
self._connected_key = None
@effect
def on_credential_change(self):
"""Reconnect when credentials change."""
# This call chain: self.rt.creds → _ScopedCredentials.get()
# → CredentialProvider.get() → config.get() → Signal._state.get()
# The Signal registers this Effect as a subscriber.
key = self.rt.creds.get("api_key", target="prod")
if key and key != self._connected_key:
self._connected_key = key
masked = key[:4] + "****" + key[-4:] if len(key) > 8 else "****"
self.connection_log.append(f"connected:{masked}")
print(f" [effect] DB client reconnected with key {masked}")At boot, the kernel creates an Effect wrapper around on_credential_change and runs it once. During that first run, _active_consumer (the context variable in src/signalpy/kernel/reactive.py, line 30) points to this Effect. The call to self.rt.creds.get(...) bottoms out at Signal._state.get(), which checks _active_consumer, finds the Effect, and adds it to the Signal’s subscriber set.
From this point on, any config.set() that replaces the dict triggers Signal._notify_subscribers(), which calls Effect._notify(), which calls Effect.run() – re-executing on_credential_change with the new key.
Step 4: The vault rotates by calling config.set()
@component("vault", version="1.0", depends=["config"])
@requires(config="IConfig")
class VaultSimulator:
@lifecycle.activate
def activate(self):
self._rotation_count = 0
@runnable("rotate", params=BaseModel, description="Rotate the DB API key")
async def rotate(self, params):
self._rotation_count += 1
new_key = f"sk-rotated-{self._rotation_count:04d}-prod-live"
# This is the trigger: config.set() → Signal.set() → effect re-runs
self.rt.config.set("apps.db-client.targets.prod.api_key", new_key)
return {"rotated": True, "rotation": self._rotation_count}VaultSimulator is a plain component. In production, its rotate runnable would be called on a timer or by a webhook from HashiCorp Vault. The only line that matters is self.rt.config.set(...) – everything downstream is automatic.
Running It
PYTHONPATH=src python -m signalpy.examples.secret_rotationExpected output (abbreviated):
[effect] DB client reconnected with key sk-i****live
Status: {'connected_key_prefix': 'sk-i...', 'reconnect_count': 1}
=== Rotation 1 ===
[vault] Rotated key to sk-rotated-0...
[effect] DB client reconnected with key sk-r****live
...
Final status: {'connected_key_prefix': 'sk-r...', 'reconnect_count': 4}
Secret rotation demo complete.
The test suite (src/signalpy/tests/test_examples.py, class TestSecretRotation) verifies both the direct config.set() path and the vault-via-bus path.
Production Considerations
Real vault client. Replace VaultSimulator with a component that polls HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault. The component only needs to call self.rt.config.set() when it detects a new version.
Retry logic. The @effect fires synchronously (or via create_task for async effects). If the reconnect fails, the effect does not retry automatically. Wrap the reconnect call in a retry loop with backoff, or use a separate Signal to track connection health and a second effect that retries on failure.
Credential caching. CredentialProvider delegates to config.get(), which calls Signal._state.get() – an O(1) read. If resolution involves network calls (KMS decryption), cache the decrypted value in a local Signal and refresh only when the upstream Signal changes.
Rotation frequency vs. effect cost. Each config.set() notifies all effects that read any config key. For rotations every few minutes, this is negligible. For thousands of keys per second, use separate Signals per credential domain to avoid unnecessary effect re-runs.
Audit. Pair with set_policy("db-client", {"audit": True}) to log every reconnect to the bus. See the Audit Trail pattern for details.
Correlated rotations. A real vault often rotates endpoint + key together (blue/green cutover, tenant migration). Two config.set() calls in a row will fire the consumer’s @effect twice, with the auth attempt in between hitting the new endpoint with the old key — a real failure that shows up in audit logs. Wrap correlated rotations in with batch(): to collapse the half-state. See Reactive Intent → batch() for the full recipe.
Key Takeaway
The secret rotation pattern requires zero kernel changes. A vault component calls config.set(). The config provider’s internal Signal notifies. The credential provider passes through the reactive read. The consumer’s @effect re-runs and reconnects. Four components, one reactive chain, end-to-end credential propagation with no event wiring, no polling, and no restarts.