Design session · working document
PM notifications & lease signals — current state, weaknesses, options
1 · What the PM's inbox actually received (the motivating evidence)
One new lease (Carly Sprague, unit 207, signed 7/27, starts 8/10) generated three countersign notifications — two from AppFolio, one from Clara at 12:15 AM. Meanwhile reminder cadences fired at 1:00 AM two nights running. All times Denver.
| When | From | Assessment | |
|---|---|---|---|
| Thu 7/23 | Clara | Lease ready for your signature — Champlin | initial, sent at event time |
| Fri 1:00a | Clara | Still waiting on your signature — Champlin | 1 AM send cadence preserves armed time-of-day |
| Fri 5:32p | AppFolio | Lease ready to be countersigned — 207 | PMS already notifies the same inbox |
| Sat 1:00a | Clara | Still waiting on your signature — Champlin | weekend leak timer armed before the 7/24 weekend-fix deploy |
| Sat 4:50p | Clara | Still waiting on your signature — Nwosu | weekend leak same cause |
| Sun | — | no countersign traffic | weekend rule held |
| Mon 12:15a | Clara | Lease ready for your signature — Sprague | midnight send 3rd notice for one lease |
| Mon 9:00–9:01a | Clara | 3 deferred reminders (Warren, Champlin, Nwosu) | weekend deferral worked as designed |
| Mon 10:42a | AppFolio | Lease ready to be countersigned — 207 | PMS re-notify, still pre-countersign |
| Tue 12:03a | Clara | New lease signed — Sprague / Bene (executed PDFs) | midnight send — correct email (new pipeline, first ever), wrong hour |
2 · Architecture as it exists today
flowchart LR
subgraph AF[AppFolio - Camellia tier]
RR[rent_roll and tenant_directory]
TK[tenant_tickler - LeaseSignDate]
RS[renewal_summary - CountersignedDate]
RA[rental_applications]
AE[AppFolio notification emails to PM inbox]
end
subgraph SYNC[Sync Lambda - EventBridge]
OCC[occupancies - 15 min]
LS[lease_states - 1 min]
PC[prospect_conversions - 5 min]
RW[renewals - 1 min]
RAP[rental_applications - 5 min]
end
subgraph STATE[Domain state - DynamoDB]
O[TenantOccupancy - active, pending, mtm, former]
L[Lease - lastLeaseSignedAt]
P[ProspectInquiry stage]
end
subgraph NOTIF[Notification engines]
PMA[PM-action cadences - Temporal, initial plus reminders]
SW[Execution-notice sweep to outbox to PDF workflow to drain]
RN[Renewal workflow D3 notices]
end
INBOX[PM inbox - camelliaapts]
RR --> OCC --> O
TK --> LS --> L
TK -. signed evidence .-> PC
RA --> RAP --> P
O --> PC --> P
RS --> RW --> RN
P --> SW
O --> PMA
L -. resolves cadence .-> PMA
PMA --> INBOX
SW --> INBOX
RN --> INBOX
AE --> INBOX
Time policy today: reminder timers only get weekend deferral (Sat/Sun → Mon 9:00 property-local, computed when the timer is armed). Initial notifications, execution notices, and everything else send at event time, any hour, any day.
LeaseSignDate as signed evidence (fail-closed; unsigned pending held — verified against real unsigned unit 208). Result: first-ever new_move_in execution notices sent with executed PDFs (Sprague 207, Bene 308).3 · Weaknesses
W1 · no time-of-day policy Emails at 12:15 AM and 1:00 AM CLOSED (reminders) — live-proven 7/28
Initial notifications send the moment the triggering event lands in our sync (Sprague: occupancy row minted 12:13 AM → email 12:15 AM). Reminders preserve the armed time-of-day forever (Champlin: 1:00 AM Thu-armed cadence → 1:00 AM Fri, 1:00 AM Sat). The weekend rule has no concept of nighttime, and covers only the reminder leg.
Evidence: inbox timeline above; occupancy createdAt 06:13:02Z vs email 06:15Z. Closed: the reminder leg is fixed by O1 (PR #4769, merged + live-proven); midnight initials remain by design after the O2 decision.
W2 · PMS echo Clara duplicates notifications AppFolio already sends
AppFolio emails the property inbox at every countersign-pending event. Clara's initial notification restates it — unit 207 got three notices for one signature. The suppress-duplicate seam already exists (initialNotificationAlreadySentAt, used by forwarded questions) but countersign doesn't use it.
Evidence: Fri 5:32 PM (AppFolio) + Mon 12:15 AM (Clara) + Mon 10:42 AM (AppFolio), one lease.
W3 · FIXED 7/28 Countersign cadences couldn't self-resolve for signed-before-start leases
lease_states (the writer that stamps lastLeaseSignedAt, which is what resolves a countersign cadence) read the active-only occupancy view — the same bug class as #4730, one consumer over. A pending lease never got its signed date, so Clara could keep nudging the PM about an already-executed lease. Fixed in two merged PRs: #4758 (writer view → signed view, with the NTV emit path pinned as deliberate after a zero-exposure live count) and #4765 (the occupancy-ref map behind getTenantExternalOccupancyId had the same active-only hole — caught by post-deploy live verification, not by unit tests).
Prod proof (2026-07-28): Sprague's lease stamped lastLeaseSignedAt=2026-07-27, Bene's 2026-07-20, on the first sync ticks after deploy — the stamp that was structurally impossible before. Residual: signers with no Lease row at all still can't stamp (that's W4/O4).
W4 · ingestion gaps Future occupancies only sync by luck — root cause found CLOSED by O4 (PR #4774)
Rueda (506) and Suarez (604) have no occupancy rows, so their prospects can't promote and their execution notices are blocked upstream of everything above. Root cause (verified 7/28): the occupancies sync iterates rent_roll rows only, and AppFolio's rent_roll does not reliably include Future occupancies — a live probe shows all five future signers (Sprague 1018, Champlin 1019, Suarez 1020, Rueda 1021, Zuchowski 1022) present in tenant_directory with Status="Future" + OccupancyId + MoveIn, while rent_roll shows their units as Vacant-Rented, Tenant: null. tenant_directory is only used as a phone/email enrichment join keyed off rent_roll rows, so directory-only Future rows are never candidates. Sprague/Bene got rows only because rent_roll transiently carried them on 7/27 (both minted 06:13:02Z; Sprague's updates ceased after the 15:43Z tick when she vanished from rent_roll again) — which is why the bug looks intermittent.
Evidence: live rent_roll + tenant_directory probes 7/28; tenant-row provenance (tenant_<uuid> ids, createdAt 2026-07-27T06:13:02Z, Sprague updatedAt frozen at 07-27T15:43Z). The transient-window explanation for why rent_roll briefly carried them is inferred from those timestamps, not observed directly. Closure proof (7/28 21:13Z): first post-deploy sync tick minted pending rows for all four directory-only signers — 208/1022, 322/1019, 506/1021, 604/1020 — Rueda and Suarez included; Camellia now carries six pending occupancies for the Aug 1 arrivals.
W5 · arm-time-only deferral Pre-armed timers fire through policy changes CLOSED — live-proven 7/28
Deferral math ran only when a timer was armed, and nothing re-checked the calendar at fire time. That's why Saturday leaked two reminders after the weekend fix deployed Friday evening — and why any future policy would inherit the same one-cadence-cycle blind spot. O1's fire-time gate closes this: the pre-send re-check reports the deferral fresh, so a timer armed under an older policy holds at fire and lands at office-open. The live harness proof targets exactly this scenario (hours flipped mid-sleep).
W6 · single fragile signal Tickler LeaseSignDate is the only new-lease signed source
Camellia's AppFolio tier exposes no other signature state for new leases (07/24 endpoint probe). If the tickler window misses a row, the prospect silently sits in pendingWithoutEvidence — a counter that legitimately contains unsigned leases, so it can't be alerted on as-is.
W7 · volume shape Every action is its own email thread
A busy day produces N separate notification threads (3 reminders + 1 lease-ready + 2 lease-signed + weekly report inside 24h this week). There is no batching or digest option; each cadence is oblivious to the others.
W8 · confirmed Transfer countersign reminders label the wrong unit — and can't self-resolve
Confirmed (7/28). Champlin's transfer destination (occ 1019 / unit 322) is absent from our spine — a Future occupancy, i.e. exactly W4 — so the countersign-signal consumer's occupancy-id join misses and its occupant-name fallback maps the e-sign row to her existing lease on 315 (the only Champlin lease we hold). The reminder copy derives its unit from that lease's unitId → "Unit 315". Same anchoring also means the cadence can never self-resolve: when she and the PM sign the 322 lease, the sign event has no 322 lease row to stamp (W3), and the 315 lease's lastLeaseSignedAt (2020-12-03) never advances past openedAt. W8 is downstream of W3+W4, not a separate copy bug.
Evidence: prod — Champlin's only occupancy row is 315/active (pmsRef 576), only lease is 315 (signed 2020-12-03); tenant_directory shows occ 1019/322/Future; lease-countersign-signal.ts name-fallback path + pm-action-kinds.ts unit derivation from lease.unitId.
4 · Options (for this design discussion)
O1 · DONE Office-hours send window on reminders PR #4769 merged 7/28 · prod-proven live
Decision (Fede 7/28): window = the property's officeHours record — which lives on the KNOWLEDGE row, its own DDB item, a seam the review caught being read wrongly twice — with a Mon–Fri 9:00–18:00 property-local fallback when unset. Correction on record: Camellia has stored hours (Mon–Fri 9:00–17:00, Sat 13:00–16:30, Sun closed; earlier "unset" claims came from misplaced probes), so the gate honors her real 5 PM close; scope = reminders only — initials and execution notices keep sending at event time. Built as two layers, both activity-side: arm-time placement (officeHoursAdjustedDelayMs, supersedes the weekend rule) and a fire-time gate (delayUntilOfficeOpenMs at the pre-send re-check) that catches timers armed before a policy change — fixing W5's blind spot. Replay-safe (additive field, corpus green). Live proof: new office-hours command in pm-reminders-harness — a real 1h cadence, hours flipped mid-sleep, reminder must hold at timer-fire and land at office-open; ran green 7/28 (sweep 2, 4/4): fire 23:48:58Z into flipped-closed hours → gate held (0 deliveries, remindersSent=0), deferred send landed 23:58:00Z at office-open, exactly one reminder, hours+settings restored. (Sweep 1 failed on missing harness fixtures — unit + QA recipient cleaned away by a prior sweep — and the hardened cleanup restored everything despite the failure; setup re-ran, sweep 2 green.)
O2 · DROPPED PMS-echo suppression — rejected 7/28
Decision (Fede 7/28): remove this concept entirely — Clara always sends her initial, even when AppFolio notifies the same inbox for the same event. Duplicate-with-AppFolio is accepted behavior. Consequence on record: with initials un-gated and never suppressed, midnight initial sends (the 12:15 AM Sprague class) remain by design; the future lever, if it ever bothers the team, is widening the O1 gate to initials — not suppression. The initialNotificationAlreadySentAt seam stays only where forwarded questions already use it.
O3 · DONE 7/28 lease_states widened to the signed view merged + prod-proven
Shipped as #4758 + #4765 (see W3 for the full account, including the second wiring hole that only live verification caught). Countersign cadences now self-resolve for signed-before-start leases via the existing onLeaseCountersigned hook. The "Lease rows minted where missing" caveat moved to O4 — leases with no row at all are the W4 cohort.
O4 · DONE Future arrivals: a post-pass writer, not synthetic rent_roll rows PR #4774 merged 7/28 · prod-proven
Goal: mint pending occupancies (+ Lease rows) for tenant_directory Status="Future" occupancies that rent_roll doesn't carry — unblocking Rueda/Suarez notices and collapsing W8 at the root.
Architecture decision: a separate post-pass writer (syncFutureArrivals) running after the rent_roll apply, NOT synthetic rent_roll units. The pipeline read proved the obvious approach unsafe: the entity delta is unit-number-keyed and a future-status row is a negative signal in it — a synthetic row for a transfer target would archive the unit's sitting tenant and terminate their lease (the exact Champlin case). The post-pass reconciles by pmsOccupancyRef only, writes through the canonical saveTenants seam (pending derivation from future leaseStart, spine anchor invariants, co-tenant dual-write all inherited), and touches no Unit rows, no existing tenants, no existing leases.
Key spec points: fail-safe candidate gates (skip if in rent_roll / ref already synced / leaseStart ≤ today — the guard against inventing active tenants / no OccupancyId / anchorless per ADR-0032, deferred with a warn); Lease rows minted with personId populated immediately (rent copied only on OccupancyId match); the pending→active handoff already exists (the #4730-era ref reconciliation) — the writer just has to feed it a correctly-ref'd row; co-signers via coTenantsByTenantId with the real Blue defect fixed (co_tenant rows get pmsOccupancyRef/appfolioTenantId stamped — the id==personId shape is deliberate and stays); stale-pending detection warn-only (the env-flagged retirement write was dropped in review per hot rule 13 — the write ships in a follow-up justified by the warn data); kill switch + per-tick counters (minted/skipped/deferred/stale, steady state = minted 0). A narrow transfer-handoff guard in the apply path (ref-first tenant resolution) ships as its own reviewable piece.
Verification (all done): 37 unit tests over the pure planner + writer (incl. the transfer-immutability case: the sitting tenant's row byte-identical before/after) and the Willows-gated live harness (future-arrivals-harness.ts) — 12/12 green pre-merge and re-run green after review fixes: mint/defer/idempotency (run 2 mints 0), transfer-into-occupied unit, anchorless signer deferred, and the go-Current handoff reusing the same tenant id (pending→active). Prod closure same day — see W4. Review also hardened it: co_tenant refs fill forward on signal-absent resyncs, all eight skip reasons are compile-enforced counters, and the tenant-id fallback declines under its own visible reason. Tracked follow-up: ref-first tenant resolution in the apply path's update branch (two-rows-one-unit transfer settle) + document that getActiveTenants excluding pending is the load-bearing reason minted arrivals can't be emitted as rent_roll removals.
O5 Morning digest (optional, larger)
Collapse a property's pending-action state into one 9:00 AM email (new since yesterday, still waiting, resolved) instead of N threads. Changes the product shape of W7; worth deciding only after O1/O2 land, since they alone cut most of the noise.
O6 Evidence observability
Split pendingWithoutEvidence into "unsigned (expected)" vs "signed in PMS but no evidence row" — the second is alertable and covers W6's silent-miss case (needs the tickler row's presence/absence distinguished from its sign-date being null).
5 · Test harness — status after the 7/28 decisions
The red characterization branch fede/pm-email-business-hours did its job: each decision had a failing test waiting, and the fixes shipped with real coverage on main.
- H1 (W1): green on main + live-proven — the two incident instants (00:15 Mon → 9:00; 1:00 AM + 24h → 32h) are green tests in
reminder-schedule.test.ts+ 7 workflow-gate tests (PR #4769, merged), and the live probe proved the deployed chain end to end. Scope note: per the O1/O2 decisions the gate covers reminders, not initials. - H2 (W3): flipped green + merged — lives on main as
lease-state-signed-view.test.ts(behavior + regression pin + the deliberate NTV case) and the extended 1c-wiring drift guard. - H3 (W4): green on main — flipped with O4 (PR #4774).
Live prod proofs (all Willows-gated, manifest-tracked): scripts/prospect-conversion-harness.ts (the #4730 fix, rerunnable); scripts/pm-reminders-harness/run.ts office-hours (the O1 fire-time gate — ran 7/28, sweep 2, 4/4 green); scripts/future-arrivals-harness.ts (O4 — 12/12, run twice). The W3 fix was additionally proven by direct prod observation (the sign-date stamps landing).
Every target has flipped green on main — the fede/pm-email-business-hours harness branch is retired (deleted 7/28).