Summary
A failed revoke inside rotateCredential leaves the newly written credential live in the vault. The caller sees an error and treats the rotation as failed, so the orphan is invisible and can survive across retries.
Evidence
server/src/credentials.ts:303 writes the new credential, then calls store.revoke on the previous one without any rollback:
export async function rotateCredential(
service: CredentialService,
input: CredentialInput & { previousCredentialId: string },
) {
const credential = await persistCredential(service, input);
await service.store.revoke(input.previousCredentialId);
...
}
A test that reproduces it (added on the fix branch, fails on main):
expect(received).toContain(expected)
Expected to contain: "credential-new"
Received: [ "credential-old" ]
Without a rollback the new credential stays; only the old one appears in the revoke log.
Impact
readModelSecret orders by createdAt desc, so the orphan becomes the credential the runtime resolves to for that (provider, keyId). Every subsequent model call spends the orphan.
- No
credential.rotated audit event is written for a failed attempt, so the drift never appears in the audit trail. An operator has no signal that two live credentials exist for the same key.
- A caller that retries a failed rotate writes another orphan each attempt. Nothing in the code deduplicates by
(provider, keyId), so the vault ends up with N active copies of one logical secret.
Root Cause
The write and the revoke are two independent store calls with no compensation. persistCredential succeeds and commits its row before store.revoke runs, so any failure past that point (transient DB error, previousCredentialId that no longer exists, connection reset) leaves the two sides out of step.
Summary
A failed revoke inside
rotateCredentialleaves the newly written credential live in the vault. The caller sees an error and treats the rotation as failed, so the orphan is invisible and can survive across retries.Evidence
server/src/credentials.ts:303writes the new credential, then callsstore.revokeon the previous one without any rollback:A test that reproduces it (added on the fix branch, fails on
main):Without a rollback the new credential stays; only the old one appears in the revoke log.
Impact
readModelSecretorders bycreatedAtdesc, so the orphan becomes the credential the runtime resolves to for that(provider, keyId). Every subsequent model call spends the orphan.credential.rotatedaudit event is written for a failed attempt, so the drift never appears in the audit trail. An operator has no signal that two live credentials exist for the same key.(provider, keyId), so the vault ends up with N active copies of one logical secret.Root Cause
The write and the revoke are two independent store calls with no compensation.
persistCredentialsucceeds and commits its row beforestore.revokeruns, so any failure past that point (transient DB error,previousCredentialIdthat no longer exists, connection reset) leaves the two sides out of step.