0047 — Retain RenewalSaga as the Temporal-written durable read-model (do not delete it)
- Status: Accepted
- Date: 2026-06-04
- Deciders: Fede (renewal engineer)
- Supersedes: the saga-deletion direction in
docs/planning/renewal-saga-retirement-handoff.md; resolves the "(a) delete or (b) demote to a pure read-projection" fork left open inrenewal-temporal-spine-handoff.md§3 in favour of (b). - Related: ADR-0016 (renewal state source registry), ADR-0025 (Temporal for renewals), ADR-0035 (D2 FK re-key), ADR-0037 (formal NTV via PM capture), ADR-0046.
- Trello:
sYheYp7B(drain of the pre-cutover saga cohort — gated follow-up).
Context
The renewal flow has been migrated to Temporal orchestration (ADR-0025). A
2026-06-04 audit (multi-agent code sweep + direct verification) established the
current state at main:
Already true (orchestration cutover is effectively complete):
- Every renewal entry point starts or signals a Temporal workflow — PM
send/approve/status-override routes, the daily auto-start Temporal Schedule, the
PMS rent-roll poller, and the NTV / Clara-signal decline handlers. There is no
dual code path, no
RENEWAL_USE_TEMPORAL/shadow routing flag, and no per-property allowlist gating legacy-vs-Temporal. (TEMPORAL_SHADOW_ENABLEDdefaultsfalseand is kept only as an emergency mirror kill-switch.) - The Temporal activity layer is the sole writer of renewal lifecycle state
(
recordWorkflowPhase→ RVIEW snapshot,recordWorkflowOutcome→ audit log;transitionSagaToPrepareFailedis internal torecordWorkflowOutcome). - The three legacy schedulers are deleted: the renewal-nudge Lambda (#1005), the
transitionSagasaga state machine (#1249/#1250), and the saga-reconciler cron (#1075). SLA escalation now lives in the workflow (escalateToPM, #1896).
What is still legacy:
- ~11 stuck pre-cutover Camellia sagas + ~4
MIGRATED_TO_WORKFLOWsagas frozen in production. - Orphaned saga states
OUTREACH_ACTIVE,INTENT_TO_RENEW,HUMAN_REVIEW_PENDING— no producer atmain, but still read defensively (workflow Phase-0 resume, therenewal-viewprojection, cost-savings counting,sendReminderSmscopy selection). - Migration-scaffold saga fields (
migratedAt,migratedFromState,workflowRunId) and dead schedulerLeasefields (renewalNudgeScheduledAt,renewalNudgeAnchorAt,renewalNudgeAnchorKind). Note:renewalNudgeSentAt/renewalSecondNudgeSentAtare live — the TemporalsendReminderSmsactivity stamps them — and must be kept. - No GSI4: ADR-0035 D2 (re-key
RenewalSaga/RenewalSnapshotfromtenantId→personId) was deliberately parked pending the decision below.
The open architectural fork (stated explicitly in the retirement handoff):
- Reading A — keep the
RenewalSagaentity as the durable read-model that the Temporal workflow is the sole writer of. "Off legacy" means retiring the legacy writers and states, not the saga. - Reading B — delete the saga entity entirely: move resume state into Temporal
history, cut the ~10 readers off the saga, archive the
RSAGA#rows; no GSI4.
This ADR resolves the fork so the remaining cleanup and the spine's D2 step can proceed without contradicting each other.
Decision
Adopt Reading A. The RenewalSaga DynamoDB entity is retained as the
durable identity, audit, and read-model anchor for a single renewal cycle. The
Temporal renewal workflow — through its activity layer — is the sole writer of
renewal lifecycle state; the saga is never transitioned by a legacy state
machine again.
The one sanctioned non-workflow writer is the PMS rent-roll poller's mint
path (src/lib/domain/pms/writers/renewal.ts → openExecutedSaga /
openOfferPreparedSaga, and the Clara openIntentToVacateSaga bridge). It opens
an observed/terminal saga only for the PM-manual-in-AppFolio edge case where
no workflow exists, and it signals the workflow when one does. This is a
read-model write for an externally-driven fact, not orchestration.
A future reader can tell whether code violates this ADR with one check:
putSagaConditional must appear only in the Temporal activity layer
(src/lib/temporal/activities/renewal.ts, saga-mint.ts) and the sanctioned
mint helpers above. Any other production writer is a regression.
RenewalSaga is a canonical entity (spine trace: via leaseId; activity log
LOG#renewal#<id>); it is unchanged by this ADR and introduces no new entity, so
the entity-classification table is omitted.
Consequently: GSI4 will be built and ADR-0035 D2 keeps its RenewalSaga
third (rather than that third disappearing under Reading B). Gera's open "R0"
question — does the workflow still need to read the DDB saga to resume? — is
moot under Reading A: the workflow reading loadSagaInitialState from the
saga is the intended design, not migration debt to be removed.
Consequences
What becomes easier
- Lowest-risk path off legacy: no state evacuation into Temporal history, no ~10-reader cutover, no deletion of live production rows.
- The saga remains a stable identity/audit anchor and a cheap projection surface
for the renewals UI and
getSagasByTenantreaders. - The "workflow is the sole state writer" invariant already holds — it only needs a guard to keep it from regressing.
What this commits us to
- Build GSI4; re-scope ADR-0035 D2 to retain (not retire) the
RenewalSagathird of the FK-tail re-key. - Fence the second persistence surface with a drift test asserting
putSagaConditionallives only in the sanctioned writers. - The orphaned states/fields stay in the type union (read-only) until the pre-cutover cohort is drained, then are removed.
- Reconcile the docs this supersedes: the retirement handoff and spine handoff §3 get a "superseded by ADR-0047 (Reading A)" banner, and the CLAUDE.md "architecture migration in progress" section is updated to "orchestration cutover complete; saga retained as read-model."
Definition of done (Reading A)
- Sole writer fenced — drift test green.
- Pre-cutover cohort drained — 0 sagas in
OUTREACH_ACTIVE/INTENT_TO_RENEW/HUMAN_REVIEW_PENDING/MIGRATED_TO_WORKFLOW(gated; per-saga sign-off). - Orphaned states + scaffold fields removed from the union and readers (after 2).
- Dead scheduler
Leasefields removed + drift-guarded; live nudge-sent fields documented as Temporal-managed. - Lying type/doc comments fixed; handoff + CLAUDE.md reconciled.
- GSI4 built; D2 re-scoped.
Follow-ups (tracked, not decided here)
- Drain of the ~15 sagas — gated on per-saga sign-off (Trello
sYheYp7B). Recommended disposition: reconcile each to AppFolio truth (close toEXECUTED/NTV_RECEIVEDto match reality; route genuinely-open ones to a PM viaescalateToPM; archive truly-deadRSAGA#→ARCHIVED_SAGA#, archive-first per the standing prod-row rule). Disposition deferred per 2026-06-04. - GSI4 implementation — spine workstream.
- Optional — refactor the PMS-poller mint into a Temporal activity to fully decouple it (not required under Reading A).
Alternatives considered
Reading B — delete the RenewalSaga entity (the retirement-handoff plan).
Rejected for now. It requires (R0) proving the workflow carries full resume state
in Temporal history and removing the Phase-0 DDB read; (R1) repointing ~10
production readers to a workflow query / projection; (R2–R3) deleting the saga
data layer and saga-mint; and (R4) an archive-first deletion of live RSAGA#
rows. That is a multi-PR, higher-risk evacuation whose only structural payoff over
Reading A is shedding GSI4 — which we are choosing to build anyway because the
saga remains the renewal identity anchor the spine re-keys. Reading B stays
documented as the long-horizon option; nothing in Reading A forecloses it.
Keep the saga but allow legacy writers (status quo drift). Rejected: a second
unfenced writer is exactly the failure mode (ConflictError drops from concurrent
transitionSaga + poller writes) the migration set out to remove. Reading A is
only safe with the sole-writer drift fence.