0096 — Unit price-change events and grounded price-drop follow-ups

Context

On 2026-07-16 we dropped market rents at Camellia (studios $1,150 → $1,000, 1-beds $1,350 → $1,200). The 15-minute occupancies sync picked the new numbers up, so Clara quotes the new price when a prospect asks. But nothing in the system knows a drop happened — three gaps:

  1. The old price is destroyed on sync. computeEntitySyncDelta (src/lib/domain/leasing/rent-roll/entity-delta.ts) diffs marketRent old→new on every cycle and even renders a human label (['marketRent', 'Market Rent', …]), but the delta is only used to decide whether to write. Once applyEntitySync persists the unit, the previous rent is gone. "Was this unit reduced recently?" is unanswerable from our data.
  2. Cadence follow-ups cannot mention pricing at all. The ADR-0061 §D6 outreach copy generator (generate-followup-message.ts) hard-forbids $ amounts: the generator has no grounded pricing input, so any dollar figure in its output is by definition fabricated and trips the anti-fab fallback. Correct rule, but it means the single most effective re-engagement message — "the unit you asked about just got cheaper" — is impossible to send.
  3. Live conversations don't volunteer the drop. Clara's availability tools return current Unit.marketRent with no change history, so she can't proactively tell a prospect who inquired at the old price that it went down.

A price drop is one of the strongest re-engagement signals in leasing, and we take prospects through a 5-touch cadence (ADR-0061) where touches 2–5 currently say little more than "still interested?". This must be a generic capability — any property, any PMS (AppFolio today, Yardi per the adapter pattern), any future consumer (cadence, live chat, re-engagement UI) — not a Camellia one-off.

Decision

Introduce unit price-change events: detect marketRent changes at the canonical sync-writer layer, persist them as append-only records, and expose a query helper that downstream consumers use as grounded pricing context. Then let the outreach cadence and Clara's live leasing context consume it.

1. Detection — hook the entity-sync apply path (PMS-agnostic)

When applyEntitySync applies a unit update whose delta includes marketRent, emit a UnitPriceChange record. Hooking the canonical delta/apply layer — not the AppFolio client — makes this free for Yardi/RealPage the day their syncs flow through the same writer (ADR: adapter pattern is mandatory). Manual edits via PropFlow UI (if/when units become editable) go through the same writer and are captured identically.

2. Query helper — the single read path

getRecentPriceDrops(propertyId, { sinceDays, unitIds?, bedrooms? }) in src/lib/domain/leasing/. Filters to direction === 'drop', collapses multiple changes per unit to net oldest-old → newest-new (a $1,150→$1,100→$1,000 sequence reads as one $150 drop), and drops units no longer vacant/listed. Consumers never query the raw records directly.

3. Consumer: cadence follow-ups (the ask)

In the prospect-outreach activity (src/lib/temporal/activities/prospect-outreach.ts), when building FollowUpContext for a touch, look up price drops from the last 14 days relevant to the prospect — drops matching desiredBedrooms, else the property's cheapest dropped unit. (An "inquired-unit-first" tier was considered, but inquiries don't carry a unit reference today; add that tier when they do.) If one exists, pass a new grounded field:

priceDrop?: { unitNumber: string; newRent: number; oldRent: number };

Prompt + anti-fab changes in generate-followup-message.ts:

Mention-once rule: a given prospect hears about a given unit's drop once. The workflow records priceDropMentioned: { unitId, atTouch } in its state; later touches with the same drop get no priceDrop field. A new, further drop on the same unit may be mentioned again.

Compliance: a price mention makes the touch marketing content (PEWC), not purely informational. The cadence already requires an active, property-scoped SMS consent record and honors STOP + quiet hours (this activity is the TCPA stop-suppression guard's home), and inquiry-sourced express consent covers marketing about the inquiry context. No new consent surface — but the info-vs-marketing comment in §D6 gets updated to say pricing is allowed only via the grounded field.

4. Consumer: live conversation context

Inject recent drops (same helper, 14-day window) into Clara's leasing context so the availability/quoting path can say "that unit was just reduced from $1,150" instead of quoting $1,000 with no framing. Read-only context; no tool-schema change required in the first cut.

5. Explicit non-goals

Entity classification (ADR-0027)

Entity Class Naming Spine trace (canonical) OR derived-from / rebuilt-by / drift-tolerance (derived)
UnitPriceChange canonical bare name Append-only observation; the pre-change rent is unrecoverable from any other source once the Unit row is overwritten, so it cannot be a rebuildable derived view. Traces via propertyId/unitId; no Person linkage.

Consequences

Alternatives considered