0016 — Renewal state-source registry
- Status: Accepted
- Date: 2026-05-01
- Deciders: Fede
Context
PR #554 (feat(renewals): saga is the single source of truth for status + KPIs) made RenewalSaga records the canonical read-side source for the renewal detail and list pages. The saga is keyed by personId in DDB (re-keyed off the legacy tenantId in ADR-0035 D2, #2427 — GSI4 personId#openedAt) and projected to a display status by sagaProjection() in src/lib/data/renewal-view.ts.
The reset surface area was not updated. POST /api/leasing/renewals/reset, scripts/reset-renewal-outreach.ts, and scripts/clear-fede-renewal-state.ts all wrote only legacy tenant.renewal* and lease.* fields and never touched the saga. After PR #554 these resets silently failed: the renewal page kept projecting "PREPARED" / "Sent" from the still-open saga even though tenant.renewalStatus had been wiped to READY.
This was discovered when the Federico Chapa test tenant on TEST-102 stayed pinned at "Sent" through 5+ reset attempts, blocking E2E renewal testing on prod (2026-05-01).
The deeper problem isn't the missing saga delete — it's that adding a new read-side state source had no enforced touchpoint with the reset path. Without an explicit registry, the next saga-shaped change (work-order saga, move-out saga) will recreate the same drift.
Decision
We add a typed registry, RENEWAL_STATE_SOURCES, in src/lib/data/renewal-state-sources.ts. Every store the renewal page reads from MUST have an entry:
export interface RenewalStateSource {
name: 'saga' | 'tenant' | 'lease';
reset(input: { tenantId: string; propertyId: string }): Promise<void>;
}
The single shared resetRenewalForTenant(tenantId) helper iterates the registry. Every reset path — the admin endpoint, every reset script, the §8 canary — goes through it. Adding a new state source means appending a RenewalStateSource entry; reset stays correct by construction.
The contract is documented in docs/architecture/PMS_INTEGRATION.md §11 and verified by the renewal-reset-completeness vitest, which walks every non-terminal RenewalSagaState and asserts the renewal page returns to "Ready to send" after resetRenewalForTenant. CI fails if a new saga state lands without coverage.
To support the registry the saga writer gains a deleteSaga / deleteOpenSagasForTenant pair (the only saga mutation that bypasses the state machine) and a getAllOpenSagas helper for batch reset. Hard delete — not a synthetic ABORTED terminal state — because the 60-day visibility window in hasActiveOrRecentSaga() would otherwise keep the tenant on the list and obscure clean test resets.
Consequences
Easier:
- Every reset path (UI, script, canary) is one function call:
resetRenewalForTenant(tenantId). - Adding a new saga or state source forces a registry update, which forces a test update — the contract is mechanically enforced.
- Future PMS-shaped sagas (work orders, move-outs) inherit the same registry pattern from day 1.
Harder:
- Saga records can now be deleted, not just transitioned. The state machine is no longer the only mutator. The
deleteSagaAPI is admin-only and intended for test/demo reset; production code should never call it. - Hard-deleting sagas loses the audit trail for the deleted cycle. Acceptable for test data; production tenants should never need a reset.
Follow-up:
- Apply the same registry pattern to work-order sagas when that feature lands.
- Consider an audit-log entry on
deleteSaga(separate ledger, not the saga's own activity log) if production reset becomes a real workflow.
Alternatives considered
Add an ABORTED terminal state via the state machine. Preserves the audit trail and keeps the state machine as the only mutator. Rejected because hasActiveOrRecentSaga() keeps any tenant with a recently-closed saga visible on the list for 60 days — closing the saga doesn't return the page to "Ready to send", which is the user-visible requirement.
Inline saga delete in each reset path. Smallest diff, fixes the immediate symptom. Rejected because it doesn't address the architectural drift that caused the bug — the next state source added without a corresponding reset would silently break again.
A "renewal teardown" SQS job. Decouples reset from the request path. Rejected as over-engineering for a synchronous admin operation that only touches a single tenant's records.