0055 — Renewal UI must reflect workflow state, not infer it

Context

A PM looking at a declined renewal at Camellia (unit #606, lease 2ce545f6) saw the page simultaneously say the renewal was closed (the header read "… declined the renewal" / "Notice to vacate pending in resident portal") and that an agent was mid-run: "Clara is preparing the renewal in the PMS — Agent started Jun 9 5:00:16 PM — steps will appear here once the run finishes," Progress "Stalled — taking longer than usual," Elapsed 1d 20h. No agent was running; the renewal was declined via the emailed-NTV pipeline (saga NTV_RECEIVED, closed).

This is the latest instance of a structural mismatch: the renewal detail UI infers what the workflow is doing from a handful of derived proxy fields, instead of rendering the workflow's actual recorded state.

The architecture already says the UI should be reflecting, not inferring. Renewals run on Temporal (ADR-0025); the workflow is the sole writer of renewal lifecycle state, and the DynamoDB RenewalSaga / RenewalSnapshot is the durable read-model the workflow writes through (ADR-0047). The RenewalSnapshot the workflow writes already carries a phase (awaiting_pms | outreach | awaiting_response | awaiting_countersign | escalated | done) and an outcome. But the read API (getRenewalForTenant) collapses that to a single RenewalStatus scalar and discards the phase, and the detail page (buildTimeline() in …/leasing/renewals/[leaseId]/page.tsx) then reconstructs a step-by-step narrative from proxy scalars:

The phantom "agent is preparing" step is therefore injected from the absence of evidence (wasApproved && !liveTrace && !offerSent, with the only suppression being PREPARE_FAILED). So a terminal renewal that never recorded a PMS offer is structurally indistinguishable from one still being prepared — and because that injected step makes the client wall-clock tick, after 10 minutes it relabels the closed renewal "Stalled." The false "Stalled" is a consequence of the phantom step, not a second opinion about liveness.

buildTimeline() already carries prior reactive patches for this same bug class — the PREPARE_FAILED suppression, and a && !offerSent guard whose own comment documents a previous runaway-timer bug. DECLINED → "Stalled" is the next cell in that table. The project's renewal-architecture guidance names this anti-pattern directly: "every reactive-transition fix in the last six weeks has been a symptom of the architectural mismatch — prefer fixing the upstream signal over adding another special-case." A fourth special-case (excluding DECLINED from the gate) fixes the page tonight but does not make it stop guessing.

Method note. Everything below was checked against Camellia's complete prod renewal history — 97 RenewalSaga rows — and every code claim was adversarially re-verified against source by an independent agent before being asserted here. Claims the verification down-graded are listed in Not asserted so this ADR doesn't over-state.

Verified incidence (all 97 Camellia renewals)

37 of 97 saga rows (38%) misrender today — all 37 carry the phantom agent-running step; 34 of those also show the false Stalled banner. Confined entirely to terminal renewals with no recorded offer: renewed 27/54, declined 10/22, and 0 in needs-attention (0/10), active (0/7), and migrated (0/4). Every one of the 37 has offerSent = false and no live trace — confirming the trigger is absence of a confirmed offer, not "declined" specifically. The 60 clean-rendering rows all have offerSent = true, a live trace, or PREPARE_FAILED.

Honest denominator. The 97 is a saga-row count; the user-facing surface is the detail page (keyed by leaseId). The row count is heavily skewed:

Deduplicated, the bug affects roughly 8–12 distinct real renewal pages. The unit-#606 case (lease_2ce545f6) is the freshest real case (~1.5 days old) — proof this is not just stale historical data: a new NTV_RECEIVED declined renewal with no AppFolio offer misrenders on its very first page view.

Renewal (anonymized) State offerSent Renders
Camellia #606 (2ce545f6) declined false ❌ "declined" + "Clara is preparing… Stalled 1d 20h"
Camellia (lease_0aff103e) renewed (INTENT_TO_RENEW) false ❌ phantom + false-stalled (~34d old)
Camellia #407 renewed true ✅ "Renewal complete," Progress 100%, "Agent not run yet"
Camellia #124 escalated true (+ trace) ✅ real steps, "Stopped — needs your attention"

Verified defect mechanism + legacy to remove

Confirmed against source (verdict in brackets). Each carries a // file:line and a removal recommendation in the audit output; summarized here.

The misrender mechanism (the real defect):

Accumulated legacy to delete in the same pass (saga-migration debris):

Not asserted (verification down-graded these)

So this ADR stays honest: [H3] the client stopwatch and the server PREPARE_TIMEOUT_MS/isStalePreparing gate watch different phases — not competing definitions of one concept. [H6/H10] the "EC2-era artifact" framing is stale — the EC2 web host was terminated (ADR-0008); the trace is Lambda-written and flush-at-end is a current design choice (the inference-from-absence problem is real and is fixed by H2/H8, not by "cleaning up EC2"). [H5] the diverging approvalAt anchors are intentional/documented (reset on retry). [H7] the false-stalled timer is conditional (offerSent=false), not unconditional. [H14] eligibility checks any tenant contact email (not just signer). [H16/S6] the "Renewed pill + Proposed-rent card" sync-lag and the INTENT_TO_VACATE→"Not renewing" dashboard label are intentional, documented design — not bugs.

Decision

The workflow read-model is the single source for renewal UI. The detail timeline renders recorded workflow state; it does not re-derive it.

  1. Surface real workflow state in the read-model. Extend the workflow-owned projection (RenewalSnapshot + getRenewalView) so the API returns, alongside the scalar status:
    • the workflow phase (already written by recordWorkflowPhase; stop discarding it at the API boundary);
    • an explicit agentActive fact — true only when the workflow reports an in-flight PMS-preparation activity, written at activity start and cleared at activity end/failure. Liveness becomes a positive signal the workflow asserts, never the negative space left by "no trace + no offer." (This is the direct answer to the unobservable-from-traces problem, independent of EC2/Lambda.)
    • the ordered event/step history the workflow already has a home for (EntityActivityEvent rows at LOG#renewal#<sagaId>), rendered directly.
  2. buildTimeline() becomes a pure renderer of recorded phase + events + outcome. "Clara is preparing…" shows iff agentActive; "Stalled" comes only from the workflow's own timeout; terminal states (DECLINED/RENEWED/INTENT_TO_VACATE) render their recorded outcome and never synthesize a forward-looking step.
  3. Delete the inference machinery + the confirmed legacy rather than grow it: the APPROVED_STATUSES run-state gating (H2), traceIsStale+!offerSent (H4), the client STALL_THRESHOLD_SEC stopwatch, the trace-for-liveness dependency (H8), the NTV date-heuristic (H9), and the dead status helpers (H11/H12/H13/H15/S1).

A future reader can tell this decision is violated if any renewal UI surface decides "what is the agent doing" from APPROVED_STATUSES membership, trace presence/absence, renewalOfferId presence, or a client-side elapsed timer — instead of from a workflow-written phase / agentActive / event row.

Entity classification

Entity Class Naming Derived-from / rebuilt-by / drift-tolerance
RenewalSnapshot (extended, not new) derived *Snapshot suffix from: Temporal renewalWorkflow history · rebuilt by: recordWorkflowPhase on each transition · drift tolerance: 0 (single-writer, written through on transition)

No new entity — agentActive, the surfaced phase, and the event rows are additions to the existing workflow-owned derived projection.

Consequences

Alternatives considered