ADR-0037: Treat PMS moveOutDate transition as the canonical formal-NTV trigger

Context

A live renewal at Camellia Apartments (tenant in unit 218) surfaced a structural gap in the NTV state machine on 2026-05-27. The PM captured a move-out in AppFolio after the tenant handed in notice in person; the AppFolio adapter mirrored the move-out date into PropFlow's abstract PMSLeaseState.moveOutDate; the tenant's renewal saga never advanced; the row disappeared from every scope of the renewals page (Active, MTM, and Resolved). The trigger origin in this incident was AppFolio, but the gap and the fix both sit at the PMS-abstract layer (src/lib/domain/pms/writers/lease-state.ts consumes AsyncIterable<PMSLeaseState> with a pmsType: PMSType discriminator — see ADR-0034 Layer 3), so this decision applies uniformly to every current and future PMS adapter that feeds the abstract writer.

Three independent gaps chained together to produce that disappearance. The first two break the formal-NTV path; the third hides the symptom from operators who would otherwise notice.

Gap 1 — pms.lease.ntv_filed only fires on noticeDate: null → set. syncPropertyLeaseStates (src/lib/domain/pms/writers/lease-state.ts:136) emits the NTV event exactly once per tenant per cycle: when lastObservedNoticeAt transitions from null to a set value. The comment at that emit site explicitly anticipates a later "PM processes the formal move-out" step but does not act on it:

// NTV trigger: noticeDate transitioned null → set (tenant just submitted
// notice via the portal or PM-entered). moveOutDate may still be null at
// this point — it arrives later when the PM processes the formal move-out.
// Use the lease end as the best available move-out estimate in that case.
if (!prevNotice && nextNotice) { ntvEvents.push({ … moveOutOfficial: nextMoveOut !== null }); }

When noticeDate is already populated from an earlier sync (portal NTV, tickler poll, or PM data-entry that touched only the notice field), the later PM-capture step that sets moveOutDate produces no event. In the ADR-0037 case the noticeDate was set on 2026-04-30; the PM's "Started move out" action on 2026-05-27 set moveOutDate = 2026-05-31 with noticeDate unchanged, so no NTV emitted, no consumer fired, and the saga received no signal. The same shape applies to any PMS adapter that surfaces these two fields on separate sync passes.

Gap 2 — declineRenewalOnNTV can't reach legacy sagas. Even when an NTV event does fire, signalNtvIntent (src/lib/domain/automations/handlers/decline-renewal-on-ntv.ts:41) calls signalTenantIntent(openSaga.id, …), which relies on the Phase-6 invariant saga.id === workflow.workflowId — the workflow handle is looked up by saga id, and the call throws WorkflowNotFoundError when no workflow exists. The current implementation catches that error and swallows it (:59-64), then stamps reasonForNonRenewal on the tenant and returns successfully — leaving the saga parked at its open state forever.

This is fine for the steady-state case (every saga opened by Temporal has a workflow), but real prod still carries legacy sagas opened by non-Temporal code paths — e.g., Clara's verbal-no recording (openedBy: 'system', no signalWithStart at open time). The ADR-0037 case's saga (opened 2026-05-21 in state INTENT_TO_VACATE, no closedAt, no Temporal handle) is exactly this shape.

Gap 3 — getRenewals list filter drops MTM tenants on notice unconditionally. The MTM branch of getRenewals (src/lib/data/dynamo/leasing.ts:751, mirrored in src/lib/data/store.ts:1506) continues whenever unit.status === 'notice', before checking for the visibility signals (reasonForNonRenewal, active saga) that the non-MTM branch right below honors:

if (isMonthToMonth) {
  if (!unit || unit.status === 'vacant' || unit.status === 'vacant_rented' || unit.status === 'notice') continue;
} else if (!tenant.archivedAt && !hasActiveOrRecentSaga(tenantSagas, now)) {
  // …leaseEnd window check (skipped when archived OR saga active/recent)
}

So even if Gaps 1 and 2 were closed and the saga were NTV_RECEIVED, an MTM tenant whose unit is on notice would still vanish from the renewals page entirely — including from the Resolved scope that exists to render exactly this disposition.

Why an ADR before code. The fix path the user picked ("treat 'Started move out' as formal NTV — touches the AppFolio→saga sync path") collides with a load-bearing architectural invariant established by ADR-0025 and PR #1249: the saga writer is mint-and-read-only. Every runtime state transition must flow through a Temporal signal. The naive shape — "if the workflow doesn't exist, write NTV_RECEIVED to the saga record directly" — would re-introduce the direct-state-write surface PR #1249 deliberately retired and put the system back in the multi-writer drift territory ADR-0016 documents. Recording the right pattern as an ADR now ensures future NTV-like signals (deposit refund, lease-amendment, transfer-out — all upcoming under ADR-0034 turnover orchestration) follow the same shape instead of re-litigating it under deadline.

Decision

PMSLeaseState.moveOutDate: null → set is the canonical "formal NTV" event for PropFlow — observed via the PMS-abstract sync writer, not the AppFolio adapter directly. It joins the existing noticeDate: null → set transition as a second emit path on the same pms.lease.ntv_filed event, and the NTV consumer reaches legacy sagas via Temporal signalWithStart rather than via direct saga-state writes. The fix has three load-bearing parts; all three live at the PMS-abstract / cross-PMS layer; none of them touch the saga writer or introduce a new state-mutation path on the renewal-saga storage interface.

1. moveOutDate: null → set is a second emit path for pms.lease.ntv_filed

syncPropertyLeaseStates collapses both transitions into a single emit guard:

const noticeTriggered  = !prevNotice  && nextNotice;
const moveOutTriggered = !prevMoveOut && nextMoveOut;
if (noticeTriggered || moveOutTriggered) {
  ntvEvents.push({ …, moveOutDate, moveOutOfficial: nextMoveOut !== null });
}

One emit per tenant per sync, regardless of whether one or both fields transitioned in that pass — so a PM who captures the notice and move-out in the same AppFolio session doesn't produce two identical bus events. The trigger sits in the abstract writer, so any PMS adapter that surfaces moveOutDate on its PMSLeaseState rows (AppFolio today; any future PMS that joins the adapter layer) fires the same event without per-PMS branching. The single LeaseNTVFiledEvent shape — no new event type — wires up to every existing consumer (declineRenewalOnNTV, handleNTVFiled → turnover auto-create, the reasonForNonRenewal stamp) automatically. moveOutOfficial reflects whether the formally-processed move-out date is on the row (today's semantics, preserved): true when the PM has run the move-out, false when only a tenant-stated estimate is available.

eventId derivation at the consumer (declineRenewalOnNTV): the workflow signal id keys off whether the event represents a PM-formal capture or a tenant-stated notice — ntv-filed:${tenantId}:moveOut:${moveOutDate} when moveOutOfficial, else ntv-filed:${tenantId}:notice:${noticeDate}. Cross-sync this keeps a notice-only event and a later PM-capture event as distinct workflow signals, so a workflow that received the first signal but stayed alive (rare — most workflows transition to declined immediately) still processes the second. Within a single sync the consolidated emit means only one signal fires, regardless of how many fields transitioned. The workflow's vacate-signal handler is idempotent on signal identity; handleNTVFiled's turnover creation is idempotent on tenantId + moveOutDate. Duplicates are bounded end-to-end.

2. NTV consumer reaches legacy sagas via signalWithStart

declineRenewalOnNTV passes startIfMissing options to signalTenantIntent, threading the workflow's initial inputs through signalWithOptionalStart (src/lib/temporal/client.ts:165). The Temporal SDK then atomically either signals the existing workflow (idiomatic case) or starts a fresh workflow with the vacate signal as its first input (legacy-saga case). The fresh workflow's tenant-intent handler runs through the same declined terminal-outcome path as a workflow that processed the signal mid-run; the workflow records the phase via recordWorkflowPhase; the RVIEW projection picks it up; the UI shows DECLINED on the next read.

This change preserves the Phase-6 invariant intact: no direct write to the saga record from the NTV consumer. The saga still transitions to NTV_RECEIVED only via the workflow path that already owns saga writes. The only difference is that the workflow may start late (the startIfMissing codepath) rather than at saga-open time. For sagas whose workflow already exists this is a no-op behavior change — USE_EXISTING returns the live handle and the signal lands normally.

The workflow init payload is built from event.data (tenantId, propertyId, leaseId, moveOutDate) — every field the consumer already has in scope. No new lookups, no extra reads.

3. getRenewals MTM branch admits resolution-eligible rows

The MTM branch of getRenewals gates the unit-status skip on the same visibility signals the non-MTM branch already honors:

if (isMonthToMonth) {
  const visibleByDisposition =
    Boolean(tenant.reasonForNonRenewal) || hasActiveOrRecentSaga(tenantSagas, now);
  if (!visibleByDisposition &&
      (!unit || unit.status === 'vacant' || unit.status === 'vacant_rented' || unit.status === 'notice')) continue;
}

For the JSON dev store (no saga wiring), only the reasonForNonRenewal half of the gate applies — matches the JSON store's existing pattern (it already has no saga visibility logic in the non-MTM branch). This is a strict superset of today's filter: every row that surfaces today still surfaces; rows that have a saga or reasonForNonRenewal and were previously dropped now reach the projection. The projection then renders the appropriate state (DECLINED, INTENT_TO_VACATE, etc.) from the saga, so the Resolved scope renders the row when the saga is terminal-NTV.

Entity classification

No new entities. Reuses LeaseNTVFiledEvent (src/lib/domain/pms/events/types.ts), RenewalSaga state NTV_RECEIVED (already terminal, closedReason: 'ntv_received'), and the existing signalWithOptionalStart plumbing.

Consequences

Commits us to:

Becomes easier:

Becomes harder:

OPENED-state legacy-saga edge case (caveat on "advances to declined through the normal path"). The Phase 0 guard in renewal-workflow.ts already maps the common blocked saga states to terminal outcomes via BLOCKED_STATE_TO_OUTCOME — notably INTENT_TO_VACATE → 'declined' and NTV_RECEIVED → 'declined', which cover the prompting case (Clara verbal-no saga). But a saga in OPENED state — outreach opened, not yet past prepare — has no entry in that map. When signalWithStart bootstraps a workflow for such a saga with tenantIntent: vacate pre-loaded, Phase 1's condition() predicate doesn't consult state.intent (it only watches pms() transitions), so the workflow waits the full prepare timeout and exits prepare_failed rather than declined. The row still terminates with a recorded outcome — operationally this is strictly better than the pre-ADR behavior (signal swallowed, saga parked forever) — but the disposition label is wrong for that narrow shape. Closing it is one branch in the Phase 0 else block (if (state.intent?.kind === 'vacate') return exitWithOutcome('declined')); deferred to a follow-up so this PR stays one-concern.

Follow-ups (explicitly out of scope here):

Alternatives considered

  1. Direct saga-state write from the NTV consumer when the workflow signal fails. Rejected — re-introduces the direct-state-write surface PR #1249 deliberately retired (the saga writer is mint-and-read-only per ADR-0025 Phase 6). Would put the system back in multi-writer drift territory documented by ADR-0016.

  2. Pre-flight migration script that runs the Phase-B saga→workflow migration for any open sagas without workflows, then signal normally. Rejected as the primary fix — addresses the existing backlog but doesn't self-heal future cases (any future code path that opens a saga without signalWithStart re-creates the gap). May still run as an opportunistic cleanup after this ADR's PR, but not on the critical path.

  3. Presentation-only fix in getRenewalView that projects DECLINED when tenant.reasonForNonRenewal + tenant.leaseMoveOutDate are both set, regardless of saga state. Rejected — creates state drift between the saga (says INTENT_TO_VACATE) and the projection (says DECLINED), which is exactly the multi-source-of-truth condition ADR-0016 exists to prevent. The saga should reflect reality; the projection should read it faithfully.

  4. A new event type (pms.lease.move_out_captured) distinct from pms.lease.ntv_filed. Rejected — the consumer-side behavior is identical (signal vacate intent to the workflow, stamp reasonForNonRenewal, auto-create turnover), so a parallel event type would mean either two identical consumers or a translation layer. The existing event's payload already carries moveOutOfficial to distinguish the source — that's the right seam.

Test plan (for the implementation PR that follows)