0104 — PM action reminders: application approval, lease countersign, renewal countersign

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, see resolvePmActionReminderConfig). 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 an application_review whose received date (pmsApplicationReceivedAt) is BEFORE the company's enabledAt never 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 ?? appliedAt fallback is removed from the eligibility check — appliedAt is an observation clock (set when we first see the record), so a sync running after opt-in could float a stale backlog application's appliedAt past the cutoff. An application with no pmsApplicationReceivedAt is 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:

  1. A new lease application comes in — PM needs to review/approve it.
  2. A lease needs sign / countersign — PM needs to countersign a new lease.
  3. 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)

2. Locked decisions (Fede, 2026-07-20)

3. Decision

One parameterized, durable Temporal workflowpmActionReminderWorkflow(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

3.2 Emails

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)
}

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)

  1. Build lands behind a temporary env allowlist (PM_ACTION_REMINDERS_ALLOWLIST=appfolio-45) checked at trigger time — present only during the verification window.
  2. 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.
  3. 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

5. Alternatives considered


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:

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:

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.