0096 — Unit price-change events and grounded price-drop follow-ups
- Status: Proposed
- Date: 2026-07-16
- Deciders: Fede
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:
- The old price is destroyed on sync.
computeEntitySyncDelta(src/lib/domain/leasing/rent-roll/entity-delta.ts) diffsmarketRentold→new on every cycle and even renders a human label (['marketRent', 'Market Rent', …]), but the delta is only used to decide whether to write. OnceapplyEntitySyncpersists the unit, the previous rent is gone. "Was this unit reduced recently?" is unanswerable from our data. - 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. - Live conversations don't volunteer the drop. Clara's availability tools return
current
Unit.marketRentwith 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.
- Keyed under the property partition:
PK=PROP#<propertyId>,SK=PRICE_CHANGE#<unitId>#<detectedAtISO>— one query serves "all changes for this property since T". - Fields:
unitId,unitNumber,oldRent,newRent,direction: 'drop' | 'increase',source(sync source, e.g.'appfolio'),detectedAt, plus denormalizedbedrooms/unitTypeso consumers can match prospect preferences without a second read. - TTL 180 days. Detection is best-effort: an emit failure logs and never aborts the sync (same posture as the contact-coverage metric).
detectedAtis when we saw it (sync granularity, ≤15 min lag), not when the PM clicked save in AppFolio. Good enough for every consumer below.
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:
- The prompt may mention the drop only from this field, stated plainly ("Unit 206 just came down to $1,000/mo").
- The anti-fab
$-check becomes a whitelist: dollar amounts exactly matching the providedpriceDropfigures are allowed; any other$amount still trips the fallback. Only the dollar-amount branch of the guard is relaxed — the sibling concession shapes in the same regex ("% off", free-month, move-in special, and their Spanish variants) remain unconditionally forbidden. With nopriceDrop, behavior is byte-identical to today. - Eval coverage: promptfoo cases in
evals/for (a) mentions the grounded price, (b) never invents a price withoutpriceDrop, (c) Spanish variants (ADR-0089).
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
- Price increases are never volunteered. Detected and stored (
direction: 'increase'— useful for audit/analytics), but no consumer surfaces them proactively. - No PM-facing "price history" UI in this ADR (the records make it possible later).
- No automated re-triggering of completed cadences on a drop. Prospects whose 5-touch cadence already exhausted are a real audience for "the price dropped" — but waking finished workflows is a separate decision with its own consent/cooldown questions. Follow-up ADR if we want it.
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
- One extra conditional write per unit whose rent changed, per sync cycle — negligible (rent changes are rare relative to 15-min cycles).
- The anti-fab whitelist is the riskiest edit: it relaxes a safety rail that exists
because of real fabrication incidents. Mitigations: exact-match-only whitelist,
byte-identical behavior when
priceDropis absent, and regression evals for the no-priceDroppath. - Yardi/RealPage get this for free the day their unit syncs flow through
applyEntitySync; until then the capability is AppFolio-scoped by construction, not by design. - Sets the precedent that sync deltas can emit durable domain events. Future candidates (availability changes, concessions) should reuse the same shape rather than invent parallel mechanisms.
- Implementation lands in 2–3 PRs: (1) detection + records + helper with tests, (2) cadence consumer + anti-fab whitelist + evals, (3) live-context injection.
Alternatives considered
previousMarketRent+rentChangedAtfields on Unit. Smallest possible change, but holds only the latest transition (multi-step drops lose the true old price), couples history to the mutable Unit row, and gives no audit trail. Rejected: the event record is barely more work and strictly more capable.- Diff the scraped public listings page (
listings-sync.tsalready scrapes it). Rejected: the page is cached upstream (observed hours-stale on 2026-07-16 — the incident behind this ADR), covers only listed units, and scraping prices couples a revenue-relevant signal to markup fragility. - Let the LLM infer the drop from conversation history ("you were quoted $1,150 before"). Rejected: prior quotes aren't reliably structured in history, and it directly conflicts with the anti-fabrication posture — pricing must come from a grounded system field, never model inference.
- A separate price-watcher job. Rejected: a second reader of AppFolio data with its own schedule and failure modes, when the sync delta already computes the diff we need. Violates the simpler-architecture default.