0055 — Renewal UI must reflect workflow state, not infer it
- Status: Accepted
- Date: 2026-06-11
- Deciders: Fede (ratified 2026-06-11)
- Trello: implementation + cleanups to be carded on ratification
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:
status ∈ APPROVED_STATUSES— a set that conflates "PM approved" with "lifecycle bucket" and includes the terminal statesDECLINEDandRENEWEDnext to activePREPARING;offerSent = !!lease.preparedOffer.renewalOfferId;- the presence/staleness of a
browserAgentTrace(which flushes only at end-of-run — a current Lambda-callback design choice — so an in-flight run is unobservable from it); - a client-side wall-clock (
STALL_THRESHOLD_SEC = 600) overlastRunStartedAt ?? outreachSentAt.
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
RenewalSagarows — 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:
- 25 of the 37 come from a single lease (
lease_cb93d6f8…) that accumulated 25 duplicateEXECUTEDsagas — one renewal, not 25. - 1 is a gauntlet synthetic test artifact polluting prod (untagged Gauntlet eval renewals lack
isTest/isSynthetic— should be excluded, not counted as a real defect). - A secondary data-quality issue compounds it: some tenants have duplicate saga rows under a bare numeric
leaseIdvs alease--prefixed one (e.g.909vslease-909); the bare-ID row resolves no lease record, forcingofferSent=falseand guaranteeing a phantom.
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):
- [H2 · CONFIRMED]
APPROVED_STATUSES(page.tsx L64-71) includes terminalDECLINED/RENEWED; the phantom-step gate (L352) has no terminal-status guard — onlyPREPARE_FAILEDis suppressed. → Split the set intowasEverApprovedvsisActiveMidCycle, and gate the agent-running step on!isTerminal(status). - [H8 · CONFIRMED] Both phantom-active insertions (
agent-runningL352,trace-waiting-patchL388) lack terminal guards, andcomputeHeroState(L738-755) returns'running'onactiveCount>0before the terminal'complete'checks. → AddisTerminalearly-exits; check terminal status beforeactiveCount. - [H4 · CONFIRMED] The
&& !offerSentclause ontraceIsStale(L337-342) is a documented patch over the same inference model. → Deletable once H2/H8 add the terminal guard. - [H9 · CONFIRMED]
noticeFiledThisCycleuses a string-date heuristic (lastObservedNoticeAt >= outreachSentAt, L1396-1406) because there is no saga-projected NTV flag — yetrenewal-view.tsalready projectssaga_ntv_received. → Consume the projected event; drop the heuristic.
Accumulated legacy to delete in the same pass (saga-migration debris):
- [H15 · CONFIRMED]
deriveRenewalStatus— unused, superseded bygetRenewalView(comment at renewal-view.ts L171-173 says so). Delete + remove the barrel export. - [H11 · legacy]
isRecentlyRenewed—@deprecated, reads no-longer-written tenant fields, 3 eslint-disables, nosrc/call sites. Delete. - [H12 · CONFIRMED] Triple backward-compat shims (
RENEWAL_STATUS_NORMALIZE+ two maps) for 6 legacy DDB status strings. Scan to confirm none live, then delete; add a test asserting the legacy values no longer map. - [H13 · legacy] / [S1 · legacy]
RENEWAL_INTENDS/ list pills derived from thesignedThisCycledate-window heuristic withTODO(saga-migration)eslint-disables. Replace with the workflow-event-projectedsignedThisCycleonce the saga-migration status cleanup lands.
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.
- 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 byrecordWorkflowPhase; stop discarding it at the API boundary); - an explicit
agentActivefact — 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 (
EntityActivityEventrows atLOG#renewal#<sagaId>), rendered directly.
- the workflow
buildTimeline()becomes a pure renderer of recorded phase + events + outcome. "Clara is preparing…" shows iffagentActive; "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.- Delete the inference machinery + the confirmed legacy rather than grow it: the
APPROVED_STATUSESrun-state gating (H2),traceIsStale+!offerSent(H4), the clientSTALL_THRESHOLD_SECstopwatch, 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
- Commits us to the workflow recording two things it currently only half-records: an explicit activity-liveness flag and a durable per-renewal event log. Both have existing homes (
RenewalSnapshot, theLOG#renewal#…rows) — wiring, not new infrastructure. - Easier: new status/field combinations can no longer reopen this bug class — there is nothing to re-derive. The page stops depending on end-of-run traces for liveness.
- Harder / follow-up: pre-cutover and
MIGRATED_TO_WORKFLOWsagas have no event log — the renderer must fall back to "render terminal outcome only, no synthetic forward steps." A regression test must assert, as a truth table over (status ×offerSent× trace × elapsed), that no terminal state ever renders an active or stalled step. Two adjacent data-quality cleanups surfaced and should ride along: the duplicate bare-vs-prefixedleaseIdsagas, and the gauntlet synthetic rows polluting prod (untagged Gauntlet eval renewals). All of these follow-ups should be captured on the implementation Trello card created at ratification. - Stopgap allowed: the narrow terminal-status guard (H2/H8) may ship first, same-day, explicitly labeled temporary. Its removal trigger is the structural change landing under the implementation card — not merely "this ADR exists" — so the stopgap can't become permanent.
Alternatives considered
- (A) Add
DECLINED/INTENT_TO_VACATEto the suppression gate. The minimal patch and the fourth reactive special-case onbuildTimeline(). Rejected as the structural answer (acceptable only as a labeled stopgap) — it doesn't stop the next combination from misrendering. - (B) Move the same heuristics server-side. Relocates the guessing without grounding it in workflow truth. Rejected.
- (C) Render an event-sourced timeline from the workflow's recorded state (this decision). The only option where the UI cannot disagree with the workflow, and it lets us delete the heuristic + legacy surface instead of growing it.