ADR-0080 — Voice leasing-context injection: default-on via an automatic per-field data-quality gate, backed by a cached read-model

Context

Voice leasing context-injection bakes availability / prices / fees / per-term pricing into the ElevenLabs personalization prompt at call-start, so Clara can answer a prospect from the prompt instead of round-tripping a live tool mid-call. The live-tool baseline this replaces is slow: get_available_units 4.9s / check_availability 8.3s (measured on conv_4801, recorded in the builder header).

Today the whole feature is gated by a manual env allowlist, VOICE_CONTEXT_INJECTION_PROPERTIES (fail-closed, empty by default), read once in isContextInjectionEnabled(propertyId) (src/lib/integrations/voice/leasing-context-injection.ts:82-89) and checked at the top of buildLeasingContextInjection (line 345). A property is armed by hand only when its optimized leasing prompt actually consumes the injected {{available_units}} / {{tour_slots}} / {{pricing_fees}} / {{term_pricing}} variables.

The founder wants injection default-on — no manual flag — with a cached summary refreshed when data changes, so every leasing property benefits and no call pays the full read bill during the ring.

The trap that makes naive "on for all" wrong. Injection makes Clara proactively voice the injected data. For a property whose rent roll is real that's the whole point. But some properties have a scaffold rent roll: Yale 25 Station's prod METADATA shows 112 units, 103 marked occupied — but that occupancy is placeholder, only ~2 units are really vacant, and only the On-Site pricing is real. isAvailableForLeasing() treats an absent availableForLeasing flag as available (src/lib/domain/properties/computations.ts:53), so naive default-on would have Clara proactively quote fake availability for a scaffold property. That is the exact failure this ADR must prevent.

So the redesign is not "remove the gate." It is "replace the manual gate with an automatic, per-field, data-quality gate that fails closed to the live tool for any field that isn't verifiably real," plus a cached read-model so the default-on read bill isn't paid on every ring.

Decision

Three coupled decisions. Numbers stay deterministic end to end — the #3230 string builders are the only thing between real DDB values and Clara's mouth; no LLM ever sits in the price path (see Alternatives).

D1 — Default-on, gated per-field by an automatic data-quality assessment

Delete VOICE_CONTEXT_INJECTION_PROPERTIES and isContextInjectionEnabled. In their place, buildLeasingContextInjection runs assessLeasingDataQuality(propertyId) and injects each of the four blocks only when that field is independently assessed as verifiably real; otherwise the block renders '' and Clara routes to the live tool for that field — the same fail-closed shape formatPricingFees / formatTermPricing already have.

assessLeasingDataQuality(propertyId): Promise<{
  availability: boolean;  // may Clara proactively voice the vacant-unit list?
  pricing:      boolean;  // may she voice per-unit rents?
  fees:         boolean;  // may she voice pricingDetails?
  terms:        boolean;  // may she voice the per-term ladder?
}>

All inputs the assessment needs are already fetched by computeInjectionBlocks (getOnsitePricingConfig, listUnitPrices, getPropertyKnowledge, getUnits) plus one getLatestSyncLog read — so it fits inside the existing 6s withTimeout envelope for the live-fallback path, and is precomputed for the cached path.

The exact per-field signals (pinned):

The existing per-call safety envelope in buildLeasingContextInjection — the ISO-date guard, the 6s withTimeout, and catch→null — is kept verbatim; it already gives fail-closed-to-live semantics for the whole call.

D2 — A cached injection read-model (VoiceInjectionCache)

Default-on across all leasing properties must not pay computeInjectionBlocks during every ring. The three deterministic, event-driven blocks are cached in one DDB row per property; the personalization webhook reads the cache and falls back to live compute only on miss/stale.

Row shape (modeled on TenantPmsSnapshot / RenewalSnapshot — latest-only, natural-key overwrite = the upsert, single writer, last-write-wins):

Attr Value
PK PROP#<propertyId>
SK VOICE_INJECTION_CACHE (single latest-only row)
entityType VoiceInjectionCache
body propertyId, organizationId, computedAt (ISO), the three rendered blocks (availableUnits, pricingFees, termPricing), and the per-field assessLeasingDataQuality verdicts used to build them
ttl epoch-seconds hygiene TTL (7d), per the repo HISTORY_TTL_SECONDS / scratch convention

Read-with-live-fallback (the read path). The webhook reads the row and treats computedAt older than a 26h staleness bound as a per-field miss → fail closed to live tools for that field. DDB TTL is not trusted for correctness (lazy deletion, up to ~48h) — it is hygiene only; the computedAt check is the real freshness gate. On a full row miss (never computed, cold), the webhook computes live inside the existing 6s envelope exactly as today.

Single writer. One function, refreshVoiceInjectionCache(propertyId), re-derives every field from current DDB state (re-running assessLeasingDataQuality + the three renderers) and upserts the row. LWW is safe because every refresh is a pure function of current state. Called from every invalidation hook (D3) and the nightly backstop.

tour_slots is NOT cached — it stays live. Its source of truth is the Microsoft Graph shared-mailbox calendar, which changes out-of-band whenever a PM edits Outlook directly, with no webhook into PropFlow. A DDB cache invalidated only by PropFlow-side bookings would confidently offer already-taken slots. tour_slots is computed during the ring as today (already burst-cached 60s in-process; schedule_tour re-validates live anyway). The cache row therefore carries only the three data-driven blocks.

D3 — Invalidation: write-through on source events + TTL backstop + nightly refresh

refreshVoiceInjectionCache(propertyId) is fired write-through, fail-soft (a refresh failure must never abort the sync — modeled on emitContactCoverage), at the end of each source-of-truth write, guarded on "rows actually changed":

Source event Hook site Guard
On-Site pricing sync end of syncOneProperty when !dryRun (src/lib/domain/leasing/onsite-pricing/index.ts, after unit-price + availability saves) per-property, after all writes land
Rent-roll / PMS poll (unit status/availability) end of applyEntitySync Phase E (src/lib/domain/leasing/rent-roll/apply-entity-sync.ts) unitsToSave.length > 0 — one seam covers the AppFolio poll AND the email-CSV apply-sync path
Listings sync inside syncListings after successful saveUnits (src/lib/domain/leasing/listings-sync.ts) toSave.length > 0 — all three trigger paths inherit it
PropertyKnowledge / fees data-layer savePropertyKnowledge writer on any knowledge write (covers all three knowledge-writer call sites at once)

TTL backstop + nightly refresh. A dedicated all-properties Temporal schedule, voice-injection-cache-refresh-daily, mirrors the onsite-pricing-daily trio (upsertVoiceInjectionCacheSchedule, ScheduleOverlapPolicy.SKIP, ships PAUSED, deterministic workflowId), scheduled just after the 06:00 UTC On-Site sync so it re-derives from fresh prices. This is the safety net for any write path we missed and for the 26h staleness bound. The refresh activity can register on the existing On-Site or leasing worker rather than minting a new task queue.

Entity classification (per ADR-0027)

Entity Class Naming Derived-from / Rebuilt-by / Drift-tolerance
VoiceInjectionCache derived *Cache suffix from: UnitPrice + Unit + OnsitePricingConfig + PropertyKnowledge (+ SyncLog#listings for the quality verdict) for one property · Rebuilt by: refreshVoiceInjectionCache on each source-write hook + the voice-injection-cache-refresh-daily schedule · Drift tolerance: read path fails closed to live tools once computedAt > 26h, so effective correctness drift ≤ the source-write hook latency, ≤26h worst case

Consequences

What this commits us to.

Amended 2026-08-25 (Yale go-live #1). The gate moved out of the voice module to src/lib/domain/leasing/leasing-data-quality.ts and was renamed (assessInjectionDataQualityassessLeasingDataQuality, assessInjectionQualityassessLeasingQuality) because it is no longer voice-only: the SMS/email/voice tool path (buildVacantUnits) now grades the same verdict. Consequence worth stating — the availability signal's blast radius is now every leasing answer, not just call-start injection, so a true from the lenient signalC branch is what lets a confident "no units available" through. Verified false on both clauses for Yale 25 Station.

Amended 2026-09-03 (source-aware availability). The three availability signals below — (a) listings-SyncLog freshness, (b) the occupied-vs-scraped contradiction detector, (c) the PMS-wired-with-no-listings-reconciler branch — all grade the local rent-roll roster. Since the per-property leasing-source setting shipped (2026-09-02, src/lib/domain/leasing/leasing-truth.ts), a property whose resolved source is 'website' never has that roster read on any prospect-facing path: selectLeasingTruth answers from the scrape alone. Grading such a property on roster evidence therefore judged it on a store that cannot reach the answer, and it failed closed for the wrong reason.

So availability is now dispatched on resolveLeasingSource. In 'website' mode it is isWebsiteScrapeFresh(unitPrices, now) — the latest scrape RUN inside the same 48h PRICING_STALENESS_MS window selectLeasingTruth uses to decide between answering and refusing, imported from leasing-truth.ts rather than restated. Signals (a)/(b)/(c) are not consulted there. In 'rent-roll' and 'manual' mode the three signals below apply verbatim and nothing changes.

This supersedes two statements below that are now false at the property they name. "Yale: zero listings SyncLog rows → fails" and "Verified false on both clauses for Yale 25 Station" described the roster gate, which no longer governs Yale: its LEASING_SETTINGS.leasingSource is pinned to 'website', so its 13h-old scrape is what the gate reads. Verified read-only against prod on 2026-09-03: old rule availability=false, new rule availability=true, 18 apartments offerable. Yale's roster is still never voiced — website mode does not read it at all, which is a stronger guarantee than the contradiction detector was.

One consequence inside the voice block: term_pricing's per-unit named set was the rent-roll ∩ On-Site intersection. In website mode that is the stale store, so the certifying set now follows the source too, and an EMPTY certifiable set falls back to SUMMARY-ONLY instead of an open gate with no unit lines.

What gets easier. Every leasing property gets injection with no manual arming; the per-ring read bill collapses to one cached-row read on the hot path; the redundant per-call reads (getPropertyKnowledge ×3, getProperty again inside getCalendarToken) disappear from the cached path; Yale can safely voice its real On-Site prices/fees today without ever voicing its scaffold availability.

What gets harder / follow-ups.

Migration / safety — this changes live behavior ON MERGE

There is no env flip. The moment this merges, the manual allowlist is gone and every leasing property runs the automatic per-field gate. Fail-closed is what makes that safe. Blast radius per property:

Because every field independently fails closed, the worst case for a property we didn't think about is "Clara uses the live tool" — the pre-#3230 status quo — never "Clara voices fabricated data."

Alternatives considered