0052 — Temporal deploy safety: graceful drain, replay gate, Pinned (Worker Deployments), and a no-fork migration SOP

Context

ADR-0025 adopted Temporal Cloud to make renewals "bulletproof": durable timers and signals replace the saga-watchdog cron. It flagged that "workflow versioning during deploys requires patched()" and that "CI lints for missing patched() calls." That control proved insufficient. On 2026-05-27 a production incident wedged 3 renewal workflows, froze the renewals read model since 2026-05-20, and fired no alert. Five Camellia tenants who filed Notice-to-Vacate had renewals that never advanced to declined.

The incident. Three renewals were migrated from pre-existing sagas on 2026-05-18 (PR #1080) via scripts/temporal/migrate-sagas-to-workflow.ts, which starts a workflow at workflowId=renewal-<sagaId>-migrated that loads its resume state through the recorded loadSagaInitialState Phase-0 activity. Root cause (confirmed 2026-06-09 by replaying the exported prod histories locally at candidate commits): the migrated histories are well-formed — they recorded all three patch markers (phase0_load_saga_state_v1, letter_mms_in_workflow_v1, outreach_parallel_v2) exactly as the 05-18 worker wrote them. What broke them is PR #1214 (2026-05-20, RVIEW projection cutover), which added a recordWorkflowPhase activity inside setPhase() with no patched() guard. Replaying any pre-#1214 history, the post-#1214 worker suspends on that unexpected activity mid-activation and never reaches the patched('letter_mms_in_workflow_v1') call recorded in the same activation — so the marker has no matching change command: [TMPRL1100] Nondeterminism error: Non-deprecated patch marker encountered for change "letter_mms_in_workflow_v1", but there is no corresponding change command!. (The error names #1033's marker, which misdirected the first diagnosis toward the migration's history shape; the marker is merely where the misalignment surfaces.) Any pre-#1214 in-flight workflow would have wedged on its next replay — the migrated 3 were simply the only ones still open when their 7-day reminder timers fired on 05-27. The worker — deployed under the no-drain --force-new-deployment model — has retried that workflow task forever since; temporal workflow describe shows ExecutionStatus=Running, StateTransitionCount ~4310 vs HistoryLength 55.

Three stacked failure modes — the root cause is structural, not a one-off.

  1. No deploy safety. The worker runs unversioned (useVersioning defaults false; effective build id unversioned) and ECS --force-new-deployment has no graceful drain (stopTimeout: 30, no SIGTERM handler). An in-flight workflow's next task is served by new code immediately → new code meets old/synthetic history → patched() nondeterminism → wedge.
  2. No pre-merge determinism gate, and long timers hide it. There is no runReplayHistories test in the repo; the only check is the nightly replay-verifier.ts, which is continue-on-error: true. A non-deterministic change ships green and stays invisible until a 7/14/30-day timer fires days later — then wedges. Silent until too late.
  3. A migration that amplifies the blast radius — plus two regressions it caused.
    • Forked history: workflowIdOverride=renewal-<sagaId>-migrated produces a second id space and a cohort whose history shape diverges from greenfield runs over time. The fork did not cause the marker mismatch (that was #1214's unguarded activity) — but it created a long-lived cohort nobody's replay thinking accounted for, and it broke addressing and the terminal flag below.
    • Addressing (F2): because migrated workflows live at renewal-<sagaId>-migrated but every client.ts signal/query helper targets the bare renewalId, signals miss the real workflow — and with startIfMissing, signalWithStart spawns a ghost empty workflow at the bare id.
    • Terminal flag (F3): the migration set saga.closedAt on MIGRATED_TO_WORKFLOW; findOpenSagaForTenant gates on closedAt === null, so the NTV-decline handler silently skipped migrated tenants (only stamping reasonForNonRenewal). This is the bug already ticketed as Trello #1027 on 2026-05-08.

No Sentry alert fired for any of this: Temporal ReportedProblems are not forwarded to Sentry, and the only verifier is nightly and non-blocking.

Namespace context (relevant to the deploy/migration story). Five Temporal namespaces exist on account hmqma: propflow-renewal-prod.hmqma (renewal + tour + turnover today — where the wedged workflows live; 30-day retention), propflow-renewal-stage.hmqma, propflow-prod.hmqma (maintenance; ADR-0045's target), propflow-tools-prod.hmqma (Agent Smith), and a fail-open default. ADR-0045 (Trello #1196, not started) plans to consolidate the tenant backend onto propflow-prod. A namespace move is itself a migration of in-flight executions across namespaces — done with today's fork-history pattern it would wedge identically. ADR-0045 therefore depends on the no-fork SOP defined here. Recovery and the fixes below target the current propflow-renewal-prod.hmqma.

Decision

A layered deploy-safety and migration model. Fundamentals first; Worker Deployments / Pinned as the structural backstop once the cheaper, higher-leverage fixes are in.

1. Workflow-id & addressing convention — retire -migrated

workflowId === renewalId, always — for greenfield and onboarded executions alike. The -migrated suffix is removed. It was introduced to avoid colliding with a greenfield start at the same id, but WorkflowIdConflictPolicy.USE_EXISTING already makes that safe, so the suffix bought nothing and broke signal/query addressing and the cross-system audit-log join. One id space restores correct addressing and means a workflow's history shape no longer depends on how it was created.

2. Deploy safety — now

3. Migration & onboarding SOP — never fork history

4. Worker Deployments + Pinned — later, structural backstop

5. Recovery — fix-forward first; terminate is the last resort

A workflow wedged on nondeterminism is not dead: its workflow task fails and retries forever, and every completed activity remains in history. The recovery hierarchy, best → last resort:

  1. Fix-forward (primary; zero side effects). Make the current worker code replay the wedged history without diverging — reproduce or gate the recorded decision points (patched() markers, activity schedules) so the prefix replays exactly; validate with Worker.runReplayHistory against the exported history locally (replay executes no activities and touches no network). Deploy. The next task retry succeeds and the workflow resumes in place: completed activities are not re-run, so nothing re-sends. This is how the 2026-05-27 wedge was resolved (2026-06-09), in this order: (a) the no-comms-to-declined dispatch guard (PR #2115) merged and its worker deployed first; (b) terminal signals pre-buffered into the two still-wedged -migrated executions (see the pre-buffer rule below); (c) the start-time replay gate + blocking CI replay corpus (PR #2116) merged and deployed — both workflows resumed in place (same runId), consumed their buffered signals before any timer, and closed declined/executed with zero tenant sends. Diagnose against the exported history before choosing a recovery path — temporal workflow show -o json + the local replayer pins the exact divergence in minutes.
  2. temporal workflow reset (fallback; needs replay-compatible code anyway). Rewinds to a prior workflow task; activities completed before the reset point are preserved. Use --reapply-type None so buffered signals are not re-delivered (signal-driven side effects don't double-fire). Only useful when the divergence point itself must be discarded — and only after the code can replay the surviving prefix, otherwise it re-wedges.
  3. Terminate + fresh-start at the bare renewalId (last resort; destructive). Discards the execution and re-runs from scratch — every activity re-fires unless suppressed. Requires the receipt-completeness gate (reconstructReceipts dry-run confirming a RENEWAL_RECEIPT#<sagaId> row per already-sent channel) and the activity-level no-comms-to-declined guard before any signalWithStart. Reserve for histories that are truly unrecoverable (corrupted, or semantically wrong to resume). The cost is real: the 2026-06-09 first recovery used this path on a wedged renewal whose tenant had a Notice to Vacate on file, and re-dispatched her renewal-offer letter (unit 414) minutes before the decline signal landed.

Pre-buffer terminal signals before deploying the fixed worker whenever the workflow's real-world outcome is already decided. Signals sent to a wedged (still-Running) execution buffer durably in history; when the fixed worker picks up the task, the SDK delivers buffered signals at the start of the activation, ahead of any pending timer — so the terminal exit wins every race against a queued reminder. The signal-after-resume ordering lost exactly that race once (a renewal-offer letter re-dispatched minutes before the decline signal landed); pre-buffering is the proven, race-free pattern, rehearsed live on the Test Property first (REHEARSAL_BUFFERED_SIGNAL=<state> mode of scripts/temporal/rehearse-wedge-fix-forward.ts). The terminal signal by outcome:

6. Observability — now

Consequences

Easier: new code never meets old in-flight history unsafely (drain now, Pinned later); non-deterministic changes fail a PR not a customer renewal; one workflow-id space + DB-backed startup means one history shape, so patched() stops being migration-fragile and signal addressing is correct; a wedge pages within one scan interval instead of staying silent for a week; ADR-0045's namespace move inherits a safe migration path.

Harder: a recorded-history corpus must be maintained and grown (and auto-exported on migration); Worker Deployments add operational weight (two versions live during cutover, build retention up to the run timeout, CLI/gRPC promotion, drain monitoring); the try-both-ids addressing fallback is temporary debt to be deleted; per-marker deprecatePatch lifecycle is human-enforced.

Follow-ups: docs/runbooks/temporal-patch-lifecycle.md; docs/runbooks/temporal-stuck-workflow-recovery.md (fix-forward-first per Decision §5; the local replay tooling already exists — scripts/temporal/replay-history.ts, scripts/temporal/scrub-history-fixture.ts, and the blocking renewal-workflow-replay-corpus.test.ts vitest gate); scripts/temporal/recover-wedged-renewal.ts re-scoped to the last-resort terminate path only; the Test-Property wedge/fix-forward rehearsal harness now exists (scripts/temporal/rehearse-wedge-fix-forward.ts — used live before the 2026-06-09 recovery) and should be extended to rehearse migration as well; Continue-As-New promoted from "nice to have" to a prerequisite of Pinned.

What went wrong / lessons

Alternatives considered