Recover credential-expiry deliveries instead of dropping them (CL-7209) - #488
Conversation
Covers CL-7209: a credential should stay `active` (and so remain due for a later sweep tick) whenever its reconnect mail was never sent, instead of being claimed as `expired` first and left permanently unnotified on a mail failure or a missing recipient.
tickCredentialExpirySweep claimed a credential as expired before mailing its reconnect nudge. A throw from the mail step (or zero active recipients) left the credential permanently expired with no notification ever sent and no later tick reconsidering it, since findDueCredentialExpiries only looks at active rows. Reorder to mail first and claim only once that succeeds (or is a harmless dedupe of an already-sent notification — a credential-expired event keys on credentialId alone, not the tick). A credential with no recipient, or whose mail attempt throws, is left active so the sweep's own periodic due-scan naturally retries it later, instead of adding a separate retry mechanism. This means a credential can now stay active past its expiresAt for longer than one tick when nobody can be notified yet or delivery keeps failing; other code checking `active` for "still usable" should keep that in mind. Each candidate's mail attempt is also now isolated in its own try/catch reported through reportError, so one candidate's failure no longer aborts the rest of the tick's due and refreshable candidates. Same reorder applied to the MCP-refresh-failure branch, which had the identical claim-then-mail shape.
deliverNotification's mail write and its dispatch enqueue still have no shared transaction or reconciliation path (CL-7238) — closing that gap needs a seam @corbits/mailbox doesn't expose yet, so it's tracked separately rather than bolted onto this repo.
TheGreatAxios
left a comment
There was a problem hiding this comment.
self-review · substituting for Critique (concurrent-subagent cap saturated by other lanes, did not retry)
apps/hub/src/credential-expiry-sweep.ts — mail-then-claim reorder applied to both branches (main due loop line ~400, MCP-refresh-failure branch line ~432), no leftover claim-before-mail path. claimExpiry's conditional UPDATE is unchanged, so the replica race is still guarded; a deduped mail (no throw) still falls through to claim, only a thrown error skips it. reportError call shape (operation, tenantId, extra.credentialId) matches packages/error-sink/src/context.ts's ErrorContext schema.
apps/hub/test/credential-expiry-sweep.test.ts — flipped the "no recipients" test to assert not-claimed/not-mailed; added throw-then-retry-succeeds, dedupe-still-claims, and one-candidate-failure-does-not-abort-the-rest for both the HF-style and MCP-refresh-failure branches. All fail against the pre-fix code (verified before implementing) and pass after.
packages/notify/src/deliver.ts — doc-only change, references CL-7238 (filed) for the deferred mail/dispatch atomicity gap; not fixed in this PR per architecture review (real fix needs an upstream @corbits/mailbox change).
No blocking findings.
Follow-up to the mail-then-claim reorder: leaving a credential `active` forever when nobody can be notified traded a silent notification gap for a silent state-correctness one. The credential is genuinely dead at the provider either way; an unbounded retry turns a transient mail outage (or a slow-to-provision recipient) into a permanent lie in the database, and anything selecting `active` credentials to actually use keeps trusting one that will never work again. mailThenClaimExpiry now takes how long the credential has been due (`dueSince`, its stored `expiresAt`) and claims the expiry anyway once that age crosses a budget, even without ever getting the notification out — reported through reportError with the credential id and how long it went unnotified, so a person can find it without its owner having been told. Two different budgets, since the failure shapes aren't the same kind of problem: - MAX_UNMAILED_CREDENTIAL_AGE_MS (24h): a mail outage is a transient, systemic condition most likely to resolve within a day if it's going to resolve at all. - MAX_UNOWNED_CREDENTIAL_AGE_MS (7 days): zero active recipients is more often a slow-moving tenant-provisioning gap (a new tenant, a departed user being replaced) that deserves more real-world time before writing off the notification as unreachable. RefreshableMcpCredential now carries its own `expiresAt` (already read off the credential row for the MCP-refresh-failure branch's cutoff filter, just not previously projected into the type) so the same bound applies there. This commit necessarily bundles the RefreshableMcpCredential.expiresAt field with the tests exercising it, since the test fixtures cannot be typed against a field the implementation hasn't added yet — splitting tests-first would leave an uncompilable intermediate commit.
TheGreatAxios
left a comment
There was a problem hiding this comment.
self-review · substituting for Critique (concurrent-subagent cap saturated again, did not retry)
Bound how long an unnotified credential expiry stays active (HEAD) — verified:
ageMs = now - dueSincecomputed and compared correctly in both branches ofmailThenClaimExpiry; happy-path (mail succeeds) is unaffected by the bound and claims immediately regardless of age.- Zero-recipients and mail-throws paths each fall through to
claimExpiryonly once their respective bound is crossed, never before, never double-claiming (claimExpiry's existing conditional UPDATE is unchanged). reportError(new Error(...), {...})for the zero-recipients abandonment (no real thrown error to report there) has direct precedent in this repo:packages/chat/src/chat-orchestrator.ts:556.RefreshableMcpCredential.expiresAt: Dateplumbing typechecks; theduefilter's type predicate correctly narrowsDate | null→Date.- Confirmed all 4 new tests fail against the immediately-prior commit (checked out
HEAD~1's implementation withHEAD's tests, all 4 new assertions failed as expected) and pass again after restoringHEAD.
No blocking findings.
Summary
CL-7209: notify delivery had no recovery path when a durable write outlived its paired step. This addresses the credential-expiry sweep half of that (
apps/hub/src/credential-expiry-sweep.ts).tickCredentialExpirySweepused to callclaimExpiry(active→expired) before mailing the reconnect nudge. A throw from the mail step, or zero active recipients, left the credential permanentlyexpiredwith no notification ever sent and no later tick reconsidering it —findDueCredentialExpiriesonly looks atactiverows.mailThenClaimExpiry. Acredential-expiredevent dedupes oncredentialIdalone (not per-tick — seepackages/notify/src/render.ts'snotificationExternalIdand the existingdeliver.test.tscoverage), so retrying the mail on a later tick is always a harmless no-op if it already went out, which is what makes claiming after mail race-safe.activeinstead of claiming it — the sweep's own periodic due-scan is the retry mechanism, no new queue/table. This folds in the "no recipients" claim-then-drop shape the same reorder already fixes for the throw case.reportError(@corbits/error-sink), so one candidate's failure no longer aborts the rest of the tick's due/refreshable candidates (previously the whole loop would abort on the first throw).deliverNotification's mail-write-then-dispatch-enqueue gap inpackages/notify) — see "Deferred" below.Bounded, per review feedback: leaving a credential
activeforever when nobody can ever be notified traded the original notification-gap bug for a worse state-correctness one — the record would assertactive(usable) for a credential that's genuinely dead at the provider, indefinitely, with anything selectingactivecredentials trusting a row that will never work again.mailThenClaimExpirynow bounds the grace period by how long the credential has been due (dueSince, its storedexpiresAt):MAX_UNOWNED_CREDENTIAL_AGE_MS= 7 days — a slow-moving tenant-provisioning gap (new tenant, departed user being replaced) deserves real-world time to resolve before writing off the notification as unreachable.MAX_UNMAILED_CREDENTIAL_AGE_MS= 24 hours — a mail outage is a transient, systemic condition most likely to resolve within a day if it's going to resolve at all.Past its budget, the sweep claims the expiry anyway (so the DB stops asserting something false) and reports the abandonment through
reportErrorwith the credential id and how long it went unnotified, so a person can find and reconnect it even though its owner was never told. Applies to both the HF-style due loop and the MCP-refresh-failure branch (RefreshableMcpCredentialnow carries its ownexpiresAt, already read off the row for the cutoff filter but not previously projected into the type).Deferred to CL-7238 (not in this PR)
deliverNotification's mail-write-then-dispatch-enqueue gap (packages/notify/src/deliver.ts) is a separate, harder problem: closing it for real needs a seam@corbits/mailboxdoesn't expose today (an in-transaction hook, or a way to read back an existing mail row's id by key) — an upstream change, not something this repo can fix cleanly. Filed CL-7238 with the concrete ask. Confirmed while investigating: in the liveapps/hubcomposition, the credential-expiry sweep isdeliverNotification's only wired caller, and its dispatch store is in-memory (not durable) with no sink registered — so this gap has no active production impact today.Process note
Critique hit the concurrent-subagent cap (another set of lanes was saturating it) both times it was attempted and was not retried; I self-reviewed each diff against the same checklist instead (posted as PR review comments), including re-running the new bound tests against the pre-bound commit to confirm they fail red before the fix and pass green after.
Test plan
WORKBENCH_CHECK_CONCURRENCY=2 WORKBENCH_CHECK_SINCE=origin/main bun run typecheckWORKBENCH_CHECK_CONCURRENCY=2 WORKBENCH_CHECK_SINCE=origin/main bun run test(all affected packages green; the one unrelated failure inscripts/run-all.test.tsis a pre-existing collision between the ambientWORKBENCH_CHECK_CONCURRENCYenv var and a test asserting default concurrency — reproduces identically with this branch's changes stashed)WORKBENCH_CHECK_CONCURRENCY=2 bun run lint(0 errors; 8 pre-existing warnings in untouched files)bun run check:structural