ADR-0080 — Voice leasing-context injection: default-on via an automatic per-field data-quality gate, backed by a cached read-model
- Status: Proposed
- Date: 2026-07-07
- Deciders: Fede (+ Gera, Jose)
- Extends: #3230 (On-Site price overlay + fees + per-term injection). The deterministic renderers it shipped (
formatAvailableUnits/formatPricingFees/formatTermPricing) are load-bearing here and are kept verbatim.
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):
fees—getPropertyKnowledge(propertyId)?.pricingDetails != null. Same object the liveget_pricing_detailstool reads → zero added risk. Absent →formatPricingFeesreturns'', prompt uses the live tool.pricing—- On-Site property:
OnsitePricingConfig.enabled === trueANDlistUnitPricesnon-empty AND freshness proven by maxUnitPrice.scrapedAtwithin the staleness window. (Do not useOnsitePricingConfig.lastScrapedAt— it is declared but never written; Yale's prod row doesn't carry it.) Join-misses already fail tonull, neverUnit.marketRent(the #3230 overlay contract, line 126). - Non-On-Site property:
trueiffProperty.pmsSourceis set (the capability field that already gates push-to-PMS; Camellia ='appfolio', Yale = absent). The liveget_available_unitstool quotes the sameUnit.marketRent, so injecting it adds no risk over the live fallback.Property.lastSyncedAtmust NOT be used as a freshness signal — Camellia's is 2.5 months stale while its data syncs fine; it is not a per-field freshness stamp.
- On-Site property:
terms— rides the On-Sitepricingsignal (terms = pricingfor On-Site properties). For non-On-Site propertiesterms = falsealways — no per-term source exists, andformatTermPricingalready renders empty there.availability— the decisive one. There is no existing "rent roll is verified" flag in the schema, and every candidate we checked fails:Property.capabilityStage(the #3191 G-1 mechanism) — Yale's prod row was never staged, so a gate on it treats Yale as live today. Also manual, which the founder explicitly does not want.SyncLog source:'rent-roll'— only the sandbox synthetic builder writes it; no production signal.- vacancy-count / placeholder-tenant-id heuristics — no threshold or marker separates scaffold from a genuinely hot property; rejected as fragile.
The decision:
availabilityis assessed automatically from two real, property-generic signals, OR-ed — and if neither holds, availability injection fails closed to the live tool. We do not put availability behind a manual per-property flag; a clean automatic signal exists, so we use it:availability = (a) getLatestSyncLog(propertyId, 'listings') is fresh AND status ok OR (b) OnsitePricingConfig.enabled === true AND zero occupied-vs-scraped contradictions- (a) Live listings reconciler freshness. The 15-min listings-sync Lambda
writes a
SYNC_LOG#listings#<ranAt>row per property. A fresh ok row provesavailableForLeasingis being actively reconciled from a live source → availability is real. Camellia: latest row minutes old → passes. Yale: zero listings SyncLog rows → fails. (Note:LISTINGS_CONFIGis a Camellia-only hardcoded map atlistings-sync.ts:23-25— a multi-tenant violation to fix separately; the SyncLog read itself is property-generic and is what we gate on.) - (b) Scaffold-contradiction detector (On-Site properties). Join
UnitPrice.isAvailablerows to localUnitby apartment number. Any apartment the On-Site scrape reports available whose localUnit.statusisoccupiedis a contradiction that proves local occupancy is scaffold (the On-Site sync only reconcilesavailableForLeasingon already-vacant units; it never flips a fake-occupied row). Prod Yale today: 9 On-Site-available apartments vs. only 2 locally vacant-and-available → 7 contradictions → availability = false. The day a real rent roll lands and the contradictions clear, availability auto-heals on — no human action.
Under this rule: Camellia passes (voices real availability), Yale fails closed (availability + terms route to live tools; only its real On-Site pricing and fees inject), and any new property with neither reconciler fails closed — the correct default. Residual honesty note: Yale's current injected availability, were it allowed, would be real-but-incomplete (understating 9→2), i.e. under-quoting, not the fake-over-quoting risk — but we fail it closed anyway because "incomplete real" is still not "verified real."
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.
- Injection behavior now changes on merge, not on an env flip (see Migration).
- One new single-writer read-model + its refresh hooks must be kept in lockstep with the four source-of-truth writers; the nightly schedule is the backstop if a hook is ever missed.
Amended 2026-08-25 (Yale go-live #1). The gate moved out of the voice module to
src/lib/domain/leasing/leasing-data-quality.tsand was renamed (assessInjectionDataQuality→assessLeasingDataQuality,assessInjectionQuality→assessLeasingQuality) because it is no longer voice-only: the SMS/email/voice tool path (buildVacantUnits) now grades the same verdict. Consequence worth stating — theavailabilitysignal's blast radius is now every leasing answer, not just call-start injection, so atruefrom the lenientsignalCbranch 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
availabilitysignals 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:selectLeasingTruthanswers 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
availabilityis now dispatched onresolveLeasingSource. In'website'mode it isisWebsiteScrapeFresh(unitPrices, now)— the latest scrape RUN inside the same 48hPRICING_STALENESS_MSwindowselectLeasingTruthuses to decide between answering and refusing, imported fromleasing-truth.tsrather 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.leasingSourceis pinned to'website', so its 13h-old scrape is what the gate reads. Verified read-only against prod on 2026-09-03: old ruleavailability=false, new ruleavailability=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.
- The
assessLeasingDataQualitysignal set is now product-load-bearing — if we onboard a PMS/property whose availability isn't reconciled by listings-sync and isn't On-Site, availability injection stays off for it until we add a signal (correct fail-closed default, but it means "turn availability on" = "wire a real reconciler," not "flip a flag").
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.
LISTINGS_CONFIGCamellia hardcoding (listings-sync.ts:23-25) is a multi-tenant violation surfaced by signal (a); fix separately so listings-sync (and thus signal (a)) generalizes.- Doc + drift-guard updates in the same PR: remove the
VOICE_CONTEXT_INJECTION_PROPERTIESrow from the CLAUDE.md "Voice runtime env vars" table; keepvoice-agents-context-wiring.drift.test.tsgreen (the four{{var}}consumption points are unchanged — only the gating changes).
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:
- Camellia (
pmsSource: 'appfolio', listings-sync reconciling every 15 min):feeson,pricingon (marketRent, same as live tool),availabilityon via signal (a). Net effect: Clara injects real data she previously only got by calling live tools mid-call — a latency win, no correctness change (same values, same source). - Yale (scaffold rent roll, real On-Site pricing, no listings SyncLog):
feeson,pricing/termson via the On-Site freshness signal,availabilityfails closed — 7 occupied-vs-scraped contradictions trip signal (b), and there's no listings SyncLog for signal (a). Clara voices real On-Site prices and fees, and routes availability to the live tool. The scaffold is never voiced as fact. Auto-heals to on the day a real rent roll clears the contradictions. - Test bench (
appfolio-45) / any other property: neither reconciler → every field fails closed to live tools = today's disarmed behavior. No regression.
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
- Naive default-on for all fields, all properties. The whole reason this ADR
exists. Would make Clara proactively voice Yale's scaffold availability
(
isAvailableForLeasingtreats absent flag as available). Rejected — it voices fake data. - Keep the manual env allowlist, just widen it. Doesn't meet the founder's "no manual flag" requirement, and it's exactly the fail-mode where someone arms a property whose rent roll is scaffold. Rejected.
- Availability behind a manual per-property
Propertyflag (the ADR-mandated fallback if no clean automatic signal existed). We do have a clean automatic signal (listings-SyncLog freshness OR zero On-Site contradictions), so we chose automatic over manual. The manual flag stays in reserve only if both automatic signals are ever deemed insufficient for a specific onboarding. - Use
Property.capabilityStageas the availability gate. Yale was never staged (prod row has nocapabilityStage), so it would read as live today — and it's manual. Rejected on both counts. - An LLM that summarizes availability/pricing into the prompt. Rejected
hard. The #3230 renderers are exact deterministic string builders; putting a
model between the real
$2,030UnitPrice and Clara's mouth invites a$2,030 → $2,300hallucination on a number a prospect will hold us to. The price path is deterministic by design — this ADR preserves that invariant. - Cache
tour_slotstoo, invalidated on PropFlow bookings. Rejected — the calendar changes out-of-band in Outlook with no webhook, so the cache would confidently offer taken slots.tour_slotsstays live. - Rely on DDB TTL for cache freshness. Rejected — DDB TTL deletion is lazy
(up to ~48h), so a stale row could be served long past its intent. The
computedAt> 26h read-path check is the correctness gate; TTL is hygiene only.