0104 — PM action reminders: application approval, lease countersign, renewal countersign
- Status: Accepted (Fede, 2026-07-21) — Superseded 2026-09-16 on the ON-by-default posture, see note below
- Date: 2026-07-20
- Deciders: Fede
- Origin: Voice note 2026-07-20 (Slack #transcripts thread
p1784583687.045849), Trello card #2143 "Add staff reminders/nudges for pending approvals and countersigns"
Superseded 2026-09-16 (Fede, after the hourly backstop paged a not-yet-live customer's leasing inbox 100 times): L1 below ("a real per-property setting, ON by default") is overturned. This is now a per-company opt-in, off by default for every organization — a company must turn it on and may name the email address it wants reminded at (
Organization.pmActionReminders, seeresolvePmActionReminderConfig). The per-property setting this ADR describes still exists and still works exactly as documented below, but only once its owning company has opted in; an opted-out company's properties are skipped whole. Superseded further, same night (Fede, 2026-09-16 night): opting in also applies a clean-slate cutoff —pmActionReminders.enabledAt— so anapplication_reviewwhose received date (pmsApplicationReceivedAt) is BEFORE the company'senabledAtnever starts a cadence, even once the company is opted in. The eligibility rule below is therefore not simply "on for opted-in companies" — it is "on for opted-in companies, for applications received on or after opt-in" (isApplicationEligibleForReminders,resolve-pm-action-reminder-config.ts). Corrected further, round-7 review (2026-09-17): the?? appliedAtfallback is removed from the eligibility check —appliedAtis an observation clock (set when we first see the record), so a sync running after opt-in could float a stale backlog application'sappliedAtpast the cutoff. An application with nopmsApplicationReceivedAtis not eligible for reminders (fail closed). Not amended inline — this note is the correction of record; do not write a new ADR for it.
1. Context
JP (Camellia) asked for three separate, individually-behaving PM email notifications, each with the same cadence — an initial email plus up to 3 reminders at 24h / 48h / 72h, sent to the property manager, for:
- A new lease application comes in — PM needs to review/approve it.
- A lease needs sign / countersign — PM needs to countersign a new lease.
- A renewal — PM needs to countersign a renewal.
Today PropFlow notifies the PM once for some of these events (e.g. renewal escalations via notifyPmPendingReview) but has no reminder cadence for pending PM actions — an unactioned application or an offer sitting in awaiting_countersign goes silent after the first (or zeroth) touch. Camellia staff lose track and tenants wait.
Separately, Fede wants latency metrics captured from day one (not surfaced yet): how long it takes a PM to approve an application, countersign a new lease, and countersign a renewal.
Prior art in the repo this decision reuses (verified 2026-07-20)
- The exact reminder shape already exists once:
tenantConfirmationReviewWorkflow(src/lib/temporal/workflows/tenant-confirmation-review-workflow.ts) — a durable Temporal workflow that notifies the PM, then re-nudges everyreminderIntervalMs(24h default) up tomaxReminders(3 default), trackingremindersSentin workflow state (no DDB sent-flags), waking oncondition(decided || remindNow, interval). - PM recipient resolution:
resolveRecipientsinrenewal-notify.ts—PropertyLeasingSettings.renewalContactEmail→ fallbackProperty.propertyEmail. - Internal-email posture:
sendEmail(src/lib/integrations/email/client.ts) withinternalRecipient: true,operational: true,category: 'transactional',language: englishByProductDecision(...), threading via syntheticreferences/inReplyTofrom the idempotency key — the same posture and visual template as the existing renewal escalation / confirmation-review PM emails. - Copy single-sourcing: one copy module per notification family (
renewal-escalation-copy.ts,confirmation-review-notification.ts) shared by email + any UI surface. - Durability backstop: cohort-walker reconcilers (
renewal-cohort-walker.ts,tour-cohort-walker.ts+ their Temporal schedule upserts inscripts/temporal/upsert-*-schedule.ts) that scan DDB for entities that should have a running workflow and (re)start idempotently. - ADR-0006: new periodic work goes to Temporal (or Lambda/EventBridge), never
instrumentation.ts.
2. Locked decisions (Fede, 2026-07-20)
- L1. No feature flags in the end state. Behavior is controlled by a real per-property setting, ON by default. A temporary allowlist gate may exist during the verification window only, and is removed in the final PR of the rollout — no dangling flag in prod after this is verified and live.
- L2. Reuse existing PropFlow architecture — Temporal workflows/workers, existing PM email senders and branding, existing settings model. No new infrastructure.
- L3. Emails must look and behave like the other internal PM emails (same template posture, plain-English copy, no technical jargon per the customer-facing-language rule).
- L4. Acceptance gate: E2E harness against The Willows (
appfolio-45, the JP-Co AppFolio test property) through the real prod chain. Done = two consecutive clean sweeps (per the proof-loop standard). Camellia goes live only after that gate passes — and then simply is live, because the setting defaults ON. - L5. Latency metrics captured now, published later. Record enough to answer "how long did the PM take" for each of the three action kinds; no dashboard yet.
3. Decision
One parameterized, durable Temporal workflow — pmActionReminderWorkflow(kind, anchorRef, ...) — instantiated per pending PM action, with kind ∈ { application_review, lease_countersign, renewal_countersign }. It is the direct generalization of tenantConfirmationReviewWorkflow.
trigger writers (existing code paths, one line each) cohort walker (backstop, Temporal Schedule)
──────────────────────────────────────────────────── ────────────────────────────────────────────
a) rental-application sync writer scans DDB for eligible-but-unworkflowed
(new application materialized) actions and (re)starts idempotently
b) lease-state writer / renewal awaiting_countersign
c) renewal workflow countersign phase
│ startPmActionReminderWorkflow(kind, anchorRef)
▼
┌──────── pmActionReminderWorkflow (Temporal, existing leasing task queue) ────────┐
│ workflowId = pm-action-<kind>-<anchorRef> (dedup: one LIVE cadence per anchor) │
│ 1. send initial notification (activity, idempotency-keyed) │
│ 2. while (!resolved && reminders < maxReminders): │
│ condition(resolvedSignal || remindNow, intervalMs) // 24h default │
│ → send reminder N (activity) // 24h/48h/72h │
│ 3. on resolve (signal or poll-confirmed): record timing event, done │
└──────────────────────────────────────────────────────────────────────────────────┘
│ resolution = PM acted:
│ application_review → application approved/denied (Prospect.applicationInfo)
│ lease_countersign → Lease.countersignedAt set / status executed
│ renewal_countersign → renewal offer awaiting_countersign → countersigned
▼
PmActionTimingEvent row (append-only) — openedAt, firstNotifiedAt, resolvedAt,
remindersSent, kind, propertyId, anchorRef (metrics source; unpublished)
3.1 Workflow
- One workflow type, three kinds — the cadence, recipient resolution, dedup, and metric stamping are identical; only trigger, resolution predicate, and copy differ. Per-kind copy/predicates live in a small registry module so adding a fourth kind later is data, not architecture.
- Dedup semantics (pinned per PR-4217 review): the invariant is at most one live cadence per anchor, and a new cadence is allowed once the prior one closes. Concretely:
WorkflowIdConflictPolicyhandles the running case (a second trigger for the same anchor attaches/no-ops instead of erroring), andWorkflowIdReusePolicymust NOT beREJECT_DUPLICATE— a closed cadence never blocks a legitimately re-pending anchor (re-issued lease, re-opened application). For actions that recur by design (renewal cycles),anchorRefcarries a cycle discriminator (e.g. the renewal/offer id, not the bare lease id). - Reminder state lives in durable workflow state (
remindersSent,lastReminderAt,phase), exactly like the confirmation-review workflow — no new DDB sent-flags to drift. - Resolution detection: primary = a
resolvedsignal sent from the same writer that observes the state change (application decision ingest, lease-state writer, renewal countersign transition). Backstop = the workflow re-checks the resolution predicate before sending each reminder (an activity that reads current entity state), so a missed signal can never produce a reminder for an already-completed action — this is the fail-closed direction. - Task queue / worker: the existing leasing worker; no new worker or cluster.
- Overrides: operator "send reminder now" signal does not consume the auto-reminder budget (same semantics as confirmation-review).
3.2 Emails
- Sent via
sendEmailwith the internal-PM posture (internalRecipient: true,operational: true,category: 'transactional'), recipient via the existingrenewalContactEmail → Property.propertyEmailresolution generalized to a shared helper. - All sends for one action thread under one inbox conversation (synthetic
referencesfrom the workflow's idempotency key), so 4 emails max appear as one thread, not 4. - Copy in one module (
pm-action-reminder-copy.ts), plain-English, per-kind subject/body/CTA. Same visual template as the existing renewal escalation emails. - The CTA links to the place the PM actually finishes the task, resolved PMS-agnostically (locked, Fede 2026-07-20): a per-kind action-link resolver behind an interface (
PmActionLinkSource-style, same L2 posture as ADR-0098) — the AppFolio adapter supplies the deep link into AppFolio where the approval/countersign happens today; Yardi/RealPage adapters are addable without touching the core. If the PMS adapter cannot produce a link for an action, fall back to the PropFlow detail page (application / lease / renewal), never a dead or generic link. The core copy/workflow modules must not import AppFolio anything.
3.3 Setting (the permanent control; no feature flag)
Extend PropertyLeasingSettings with:
pmActionReminders?: {
enabled?: boolean; // default TRUE — omitted = on
intervalHours?: number; // default 24
maxReminders?: number; // default 3 (→ 24h/48h/72h)
}
- Defaults applied in code; an absent block means fully on with 24h×3. One block governs all three kinds (JP asked for "same behavior ×3"); per-kind toggles are a future extension, not built now.
- Surfaced in the property settings UI next to the existing renewal-contact fields, in plain English ("Remind the property manager about pending approvals and signatures — up to 3 reminders, one per day").
- The workflow reads the setting at each send, so turning it off stops mid-cadence reminders immediately.
3.4 Metrics (captured, unpublished)
Every workflow completion writes one PmActionTimingEvent row: kind, propertyId, anchorRef, openedAt (when the action became pending), firstNotifiedAt, resolvedAt, remindersSent, resolution (approved / denied / countersigned / expired / cancelled). openedAt provenance (pinned per PR-4217 review): sourced from the entity's own state-change timestamp — application received-at, the moment the lease/offer entered awaiting_countersign — never the workflow's start time. This keeps trigger-path and cohort-walker-backstop rows comparable; a backstop-recovered action must not understate PM latency. Time-to-approve and time-to-countersign are derivable queries. No UI, no dashboard, no aggregation job yet.
Entity classification (per ADR-0027)
| Entity | Class | Naming | Spine trace (canonical) OR derived-from / rebuilt-by / drift-tolerance (derived) |
|---|---|---|---|
PmActionTimingEvent |
canonical | bare name | Spine trace: via propertyId + anchorRef (prospect/lease/renewal id); append-only, written once at workflow completion |
(No other new entities — reminder state is Temporal workflow state; the setting extends the existing PropertyLeasingSettings.)
3.5 Rollout (honors L1 + L4)
- Build lands behind a temporary env allowlist (
PM_ACTION_REMINDERS_ALLOWLIST=appfolio-45) checked at trigger time — present only during the verification window. - E2E harness runs against The Willows through the real prod chain (real triggers → real workflow → real email to the test property inbox → resolution → timing event). Sentry sweeps around each run. Done = two consecutive clean sweeps.
- Final PR removes the allowlist entirely. From then on the per-property setting (default ON) is the only control. Camellia is live at that moment.
4. Consequences
- Every pending PM action across all properties gets a nudge cadence by default; properties can opt out (or retune interval/count) per property — no code change, no flag.
- Reminder correctness is delegated to Temporal durability + the cohort-walker backstop, the same trust base as tours/renewals; no new cron scanning sent-flags.
- We start accumulating PM-latency data (time-to-approve, time-to-countersign) from day one, so when the dashboard is built it has history.
- Adding future kinds (e.g. move-out inspection sign-off) is a registry entry + trigger line.
- The initial notification for renewals overlaps with the existing
notifyPmPendingReviewescalation — the build must dedupe/compose so the PM doesn't get two "renewal needs you" emails for one event (reminder workflow owns the cadence; existing one-shot escalations for HITL review remain separate concerns and separate threads).
5. Alternatives considered
- Vercel cron sweeping DDB with
reminderNSentAtstamps — rejected: reinvents state Temporal already keeps durably, adds three new stamp fields prone to drift, and ADR-0006 directs new periodic work away from cron-scan patterns where a workflow fits. - Three separate workflow types — rejected: identical behavior ×3 is a parameter, not an architecture; three types triple the walker/test/schedule surface.
- Extending
tenantConfirmationReviewWorkflowitself — rejected: that workflow's domain (identity claims) and resolution model are different; we reuse its shape, not its code path. - Per-user notification preferences model — rejected for now: no such model exists anywhere in the app; recipients stay property-scoped like every other PM notification. Revisit if multi-PM properties need routing.
- Feature flag as the permanent control — rejected by L1: the permanent control is a customer-visible setting, on by default.
Addendum — 2026-07-22: lease_countersign signal restored via the e-sign "Ready to Countersign" walk
Context. On go-live day (2026-07-21) the cohort-walker backstop's lease_countersign leg guessed "awaiting countersign" from a missing lastLeaseSignedAt and false-emailed a real PM (Yarrow Mockridge / Camellia) about a lease that was already in force. PR #4279 made that leg a structural no-op: absence of a signature field is not evidence a tenant signed, and our lower-tier AppFolio sync sources lastLeaseSignedAt from tenant_tickler signed events that routinely never land on transfer/new-lease mirror rows. That left lease_countersign reminders with no signal — the honest state until a real one existed.
Decision (restored signal). New and transfer leases never appear on /lease_renewals.json, so the Phase-7 renewals countersign feed had no equivalent for them. They flow through AppFolio's e-sign lease-document queue instead. The appfolio-browser-agent runner route POST /api/list-leases-ready-to-countersign (PR #259) walks that "Ready to Countersign" bucket and double-confirms every row against its document parties: a tenant-order party has signed (submitted_at set) AND the PM/countersigner party has not. Rows that fail the check are excluded (reported in skipped), never guessed into the positive list.
A sync-tick consumer (lambda/appfolio-sync/lease-countersign-signal.ts) runs on each lease_states tick (cost-gated to a 5-minute window, mirroring the renewals feed — the route is a full browser walk, not a cheap report fetch). For each confirmed row it maps the row's occupancyId to the PropFlow lease and starts a fire-and-forget cadence via startPmActionReminderCadence:
anchorRef= the e-signesignDocId— the envelope is the cycle discriminator, the lease analogue of the renewal/offer id (§3.1). A re-issued lease is a new envelope, hence a new anchor.entityId= the PropFlow lease id — what the resolution predicate (leaseCountersignResolved) loads. Because the workflow's fail-closed pre-send re-check loads byentityId, the cadence self-closes once the PM countersigns andlastLeaseSignedAtlands, regardless of the esign-docanchorRef. (Note: the active instant-close signal wired in thelease_stateswriter —onLeaseCountersigned, keyed by lease id — does not match theesignDocIdworkflowId, so instant close degrades to close-at-next-reminder-boundary; no false email results because the re-check runs before every send.)openedAt= the row'stenantSignedAt(max tenantsubmitted_at), the real state-change moment — nevernow()(§3.4).
Restart safety. WorkflowIdConflictPolicy.USE_EXISTING makes per-tick re-calls for a running cadence a safe no-op. Because workflowIdReusePolicy is ALLOW_DUPLICATE, a closed cadence could otherwise restart for the same still-in-bucket envelope (e.g. after budget exhaustion) — a nag storm. Two guards prevent it: skip when the matched lease is already countersigned for this envelope (lastLeaseSignedAt >= tenantSignedAt), and skip when a cadence for the anchor already exists and is done.
Fail-soft. Any runner/network/parse error logs a warning and the lease_states tick proceeds untouched. Dependency: requires appfolio-browser-agent PR #259 deployed; until then the route 404s and the consumer logs + skips. Not yet true-positive-verified: the "Ready to Countersign" bucket was empty in prod at build time; an E2E proof through a ZZ TEST lease is pending.
Addendum — 2026-07-24: fourth kind forwarded_question (the team re-remind lane)
Context. ADR-0104 above describes a three-kind system; this addendum records the fourth. Driver: the 2026-07-20 parking-lead case — Clara forwarded a lead's question to the PM by email (forward_to_property_manager), nobody called back, and nothing chased. The follow-up registry's team.pm.action_required scenario was one_shot for exactly this reason.
Decision (extend, don't fork). The lane rides THIS engine rather than a new teamNotificationChaseWorkflow. §3.1's "adding a fourth kind later is data, not architecture" is the load-bearing claim, and it held: the fourth kind is a registry entry (predicate + loader + copy + link) plus a trigger. A parallel PM-reminder workflow would have violated the repo's ONE-SOURCE-OF-TRUTH rule; an independent second-opinion review converged on the same call. Deliberately rejected: migrating this engine onto the per-touch Follow-ups cadence — those configs differ in scope and semantics (property-scoped + re-read mid-cadence here, vs. org-wide + snapshotted at enrollment there), so that is its own ADR, not a rider. The registry scenario therefore graduates one_shot → fixed, not configurable.
Six things about this kind are genuinely new relative to §3 above:
- New entity:
PmEscalationAction(one row per forward,PK=CONV#{conversationId}/SK=PMESCACTION#{id}). This is the kind's loadable entity AND itsanchorRef. - Stop authority =
handledAt, written ONLY by the authenticated "Mark handled" PATCH (/api/pm-escalation-actions/[id],requireUserso an offboarded warm session can't stop a customer's chase).Conversation.statuswas evaluated and disqualified:conversation-manager.ts:1548reopens any non-active thread to'active'on the next inbound message, so the waiting lead texting "any update?" would have read as "the team acted" — the exact failure the lane exists to fix. It also fails the other way: a PM who phones the lead changes no conversation state at all. - Trigger-sent initial notification — a genuinely new notification shape. §3.2's model assumes the workflow sends the initial email; here the forward tool already emailed the PM, so the workflow SKIPS its own initial send via the additive-optional workflow input
initialNotificationAlreadySentAt(which also becomesfirstNotifiedAt, keeping §3.4's latency semantics honest). The first cadence send is reminder #1. Replay-safe withoutpatched(): pre-existing histories never carry the field, so their command stream is unchanged. - Strong-consistency requirement on the start gate. The cadence starts ~1s after the row is written, and a GSI is always eventually consistent — a propagation lag would read
found:false→not_started→ the forward silently never chased. SoconversationIdis threaded trigger → workflow → both activities → loader, making the load an exact primary-keygetItemwith strong consistency. The GSI path remains for bare-id callers (the PATCH route, where eventual consistency is harmless). - No backstop — deliberate, and mitigated. The cohort walker (§ the 2026-07-22 addendum's coverage table) scans only
application_review; this kind is not in it. A failed cadence start therefore orphans the row. Two mitigations, neither of which is a backstop: a re-forward on the same thread reuses the pending row and re-starts its cadence (USE_EXISTINGmakes that a no-op for a live cadence and a self-heal for an orphan), and a failed start raiseslogCritical+ thepm_forward_enrollment_failedbake alert (ACTIONABLE) so a systemic Temporal outage is paged rather than silent. A first-class backstop scan is the tracked follow-up (see the sensor-registryexitcondition) — it needs a new DDB access pattern, since these rows are conversation-partitioned. - Enrollment dedup = reuse-or-mint. Nothing distinguishes a new question from a re-forward of the still-open one, and two paths re-fire the tool (SQS redelivery; a real re-forward after the thread reopens). Minting unconditionally would give the PM two cadences and two banners for one unanswered question, so an existing unhandled action on the conversation is reused instead.
openedAtkeeps the original forward's clock — a re-forward never understates PM latency (§3.4).
Scope boundary. Life-safety escalate_to_human pages (high / gas_emergency) are NOT enrolled — an urgency-tiered response policy is its own design, not a generic 24h cadence. v1 covers routine forward_to_property_manager forwards only.
Verification. Live harness on an isolated Temporal queue against the appfolio-45 bench (scripts/demo/team-remind-harness.ts --live): reminders fire with no initial re-send; a pre-handled action never nudges; and a mid-cadence handledAt stamp stops the cadence with no Temporal signal sent — proving the fail-closed pre-send predicate re-read makes a lost signal harmless. Those two real histories also seed this engine's first replay corpus (pm-action-reminder-workflow-replay-corpus.test.ts). Scope, stated precisely: both fixtures are forwarded_question skip-initial histories, so the corpus replay-gates the shared reminder loop as traversed by this kind — the timer/re-check loop and the skip-initial branch. The three pre-existing kinds' initial-send branch is not yet pinned; a determinism break confined to it would replay green here. Add a live-kind prod history (initial-send path, settings-driven interval) when one exists to close that gap.