0101 — On-behalf visitors: conversation-linked third parties
- Status: Proposed — rev 4 (2026-08-25); see Rev-4 amendment below for
the incident-driven remediation (broadcast removed, tool lookup, terminal-
stage retirement, note phone-scrub). See Rev-3 amendment below for
the binding founder rulings (specced-OR gate restored, one-record model,
voice back in scope, D3 residual closed, hint demoted to advisory). The
Decision below is the
conversation-link hybrid recommended unanimously by the 15-agent
adversarial review (
0101-adversarial-review.md, scored 64.75/90 vs 26.75/90 for the rev-1 incumbent), adopted with founder scoping on 2026-07-17: role ships asvisitoronly and voice is out of v1 (text/email recognition only). The rev-1expectedVisitordesign is preserved under Alternatives; its fatal flaw (active_tour_existscollision, code-verified) is documented in the review §3. - Date: 2026-07-16 (rev 1), 2026-07-17 (rev 2)
- Deciders: Fede — design direction + scoping accepted 2026-07-17; merge of this ADR is his explicit call.
Context
An out-of-town prospect (Carly, Camellia, 2026-07-16) toured two studios by video call, then offered to pass our contact info to a local friend who would visit in person on her behalf before she decides. Clara's actual reply had four defects: it handed off the marketing website as the contact channel, framed contact around calling the office, made zero attempt to learn who the friend is, and re-pitched the application to a prospect who had just said she isn't ready.
The deeper gap is structural, not conversational. When the friend later texts or emails the property line, the system has no way to know they are Carly's on-behalf visitor:
- The friend's identifier mints a skeleton Person at the envelope layer
(
agents/clara/lib/messaging/resolve-person.ts:125) before Clara ever replies, but no ProspectInquiry exists until Clara callssave_prospect(agents/clara/lib/agent/tools-leasing.ts:1509). Identity resolution for a cold sender returnsunknown_caller(agents/clara/lib/agent/conversation-manager.ts:2984-3016). - The leasing prompt already tells Clara to note an expected referral in
free-text
notesand to acknowledge a self-identified referred lead (agents/clara/lib/agent/clara-leasing.ts:171-184), but notes are not queryable at inbound time and nothing matches the arriving friend to the originating prospect. Recognition today depends entirely on the friend volunteering "Carly sent me" — and even then, nothing links the records.
So the friend gets the cold-lead script, their visit is invisible to Carly's record, and the follow-up that actually converts — telling Carly how the visit went and asking about next steps — never happens.
Two distinct intents must not be conflated:
- On-behalf visit — "my friend will look at it for me" — the visitor is the prospect's eyes; the deal is the prospect's. This is the Carly case.
- True referral — "my sister is also looking" — a genuinely new lead who deserves their own inquiry with referral attribution.
Locked conversational target (approved 2026-07-16)
On the on-behalf turn, the contact being shared is this email thread (that's what the prospect has and means); the property texting line rides along as optionality, framed for texting, never as a call-the-office errand; the website is never the handoff. Purpose is acknowledged, the visitor's name is requested, and there is no application talk:
"Of course! Feel free to pass along this email — your friend can reach me right here and I'll get their visit set up. If texting's easier for them, we're also at (844) 510-1007. I'll make sure they see 306 and 506 for you. What's their name, so I know to expect them?"
Constraints set by the reviewer:
- No over-engineering. No new entity, no new GSI, no speculative structure. Extend what exists; ship the one case we have.
- The friend books their own visit by contacting us directly; we do not relay scheduling through the prospect.
Decision
The on-behalf relationship lives on the visitor's own Conversation — not as a field on the principal's inquiry. This is the only shape where pollution suppression, the "arrived" alert, and concurrency work by construction (single-writer conversation stamp) rather than by prompt-hope or a cross-conversation list merge. See review §3 for why the inquiry-field and party-array shapes fail.
D1. Conversation.onBehalfOf is the linkage of record
Written once from the visitor's conversation via the same
stampConversationSubtopic → updateItemFields path as the existing
tenantMatchType/subtopic markers. No INQUIRY_C_FIELDS dual-write, no
GSI, no list-merge machinery.
onBehalfOf?: {
inquiryId: string | null; // principal's ProspectInquiry; nullable is
// schema-room for representative-first cases
// (parent-for-student) — explicitly NOT wired yet
principalPersonId?: string;
principalLabel: string; // "Carly" / '' — as reasoned from context
role: string; // v1 ships 'visitor' ONLY (founder trim,
// 2026-07-17). Open string by design; possible
// future values (representative, guarantor,
// support) are one-line additions with zero
// migration — do not pre-build behavior for them.
status: 'pending' | 'active' | 'fulfilled' | 'expired'; // soft 30d TTL at read
linkedAt: string;
note: string;
};
No phone/email fields for anyone → nothing here can trigger outbound (TCPA preserved by construction; see D6).
D2. ProspectInquiry.expectedParties — write-once advisory hint
Captured from the principal's own thread only (via update_prospect, one
new optional input), never mutated cross-conversation — that write-once rule
is what kills the lost-update race. It stores what the prospect told us
(name-as-given, note, units, timestamp) and is read only to tighten the
recognition candidate list and warm the greeting ("we were expecting you")
vs. an interrogating "who are you here for?". Rides the existing ADR-0035
dual-write and must be mirrored in both trees
(agents/clara/lib/data/dynamo/leasing.ts +
src/lib/data/dynamo/inquiry-derivation.ts).
D3. Recognition — reasoning-based, corroboration-gated, text/email only in v1
- Ordering guard:
recognizeOnBehalf(conversation, message)runs ininbound-router.tsbeforeprocessTourPipeline(~:616) — today the tour pipeline would mint the visitor and flip them toverified_prospectbefore any recognition window fires. No-op unless the property has a freshexpectedPartieshint (cheap per-property marker gates the inquiry scan), so ~zero cost on the cold path. - Candidate injection, not substring matching: inject 0–2 open candidates (principal first-name pre-resolved) into the prompt and let Clara reason — handles no-name-given + one candidate (warm greet), names-the-principal (match), two candidates (ask which), dormant/late (acknowledge + reconfirm).
- Corroboration gate (the fair-housing write-path rail): never suppress a mint or link on a bare common first name. Require the sender to name the principal OR a unit from the note before ANY linkage. On weak signal: ask, never silent-link; mint normally + annotate. Uniform-treatment instruction ("same info, pricing, availability, speed, matched or not") is written into the reasoning prompt itself.
- Hard suppression gate reads
conversation.onBehalfOf(populated same-turn) — the structural advantage over an inquiry-side matcher that is empty at the visitor's first contact. - Channels: text/email only. Founder decision 2026-07-17: voice is out of v1. A friend who calls the leasing line gets today's normal new-prospect voice flow — nothing breaks, the linkage is simply not made. The locked reply hands the friend this email thread + the texting line and never invites a call, so voice is off the promised path. Revisit after Phase 2 proves the pattern in text/email.
Honest residual: no option achieves a hard code gate on save_prospect
at the visitor's first inbound inside the agent loop. The corroboration-
gated conversation-link read closes the deterministic process-tour path; the
in-loop save_prospect remains prompt-influenced. Ceiling, not a hidden bug.
D4. Tour booking, reminders, and loop-close
- The visitor's tour books against the principal's inquiry —
schedule_tourresolvesonBehalfOf.inquiryIdserver-side from the visitor's conversation (no opaque-id echo through the model — respects the drift-test fence). Stage transitions fire on the principal's inquiry, where they are true: the deal IS advancing. Tour.onBehalfOf— a render cache populated from the resolved conversation link (not a fuzzy match) so every PM surface shows "Jordan (for Carly)" without a second lookup, and the arrival alert resolves the visitor by back-reference.- Reminders reach the person at the door:
attendeePersonIdworkflow input + all three reminder activities (confirmation, T-24h, T-1h) resolve the visitor's conversation channel (which may be email, not SMS). Priced honestly: this is workflow-input + three activities, not "one seam". - Loop-close goes to the principal. After the visit, the follow-up ("your friend came by yesterday — happy to answer anything that came up") forks the payload off the existing post-tour application-link timer and is addressed to the principal's conversation — no application talk (Carly said she isn't ready).
- The visitor gets no ProspectInquiry unless they express their own
leasing intent ("actually I'm looking too") — then the normal flow applies:
save_prospect, their own inquiry, referral attribution.
D5. PM communication — locked, subject-first
One byte-identical canonical subject string reused across all three PM
surfaces (send-pm-tour-confirmation.ts, the Outlook event event-copy.ts,
the arrival alert). PMs read subjects, not bodies:
- Named visitor:
In-person tour: Jordan (for Carly) — Camellia · Thu 2pm - No name given:
In-person tour: visitor for Carly — Camellia · Thu 2pm - Arrival alert:
Arrived: Jordan (for Carly) — Camellia · Thu 2pm tour
Body: prospect card shows "Jordan, visiting for Carly"; Carly stays the deal contact. The PM understands same inquiry, third party at the door from the subject alone.
D6. Compliance rails (from Appendix A research — non-negotiable)
No outbound to the visitor, ever, until they contact us.SUPERSEDED 2026-08-02 — see the note below this list. Retained verbatim because the reasoning still governs everything except the conclusion: TCPA consent attaches to the individual, not the number: a prospect handing over a friend's number creates NO consent to text or call that friend, and the operator — not the prospect — carries the liability. The entire flow is inbound-shaped by design: the prospect shares OUR contact; the friend's own first inbound message is their consent for that channel. Clara must never offer to "reach out to" the friend, and neitheronBehalfOfnorexpectedPartiescarries phone/email fields — nothing to auto-contact. Explicit prompt rule + gauntlet case.- Uniform treatment. Recognition changes record-linkage and greeting context only — never the information, pricing, availability, tone, or responsiveness offered. Per HUD's 2024 AI guidance, differential treatment by an automated agent is steering regardless of intent. The gauntlet asserts the visitor gets the same substantive answers a cold lead would.
REVISED 2026-08-02 (Fede's ruling). The first bullet above — "no outbound to the visitor, ever" — no longer holds. The 2026-08-02 on-behalf incident was a visitor who was announced, never contacted, and arrived at the door as a stranger to the system that booked her. The ruling: she gets the SAME tour confirmation, T-24h, T-1h and follow-up a prospect gets, on her own channel, on a consent basis recorded as conveyed through an intermediary — never relabelled
direct.What the revision does not change, and these are the rails that carry the compliance weight now:
expectedPartiesandonBehalfOfstay contact-free. A conveyed basis needs its own carrier — a realPerson+IdentityClaimstamped with that provenance, so the contact is auditable, attributable and revocable by the person themself. A phone number sitting in an advisory hint is none of those things, which is why the number is not simply moved there.- Conveyance is an EVENT, and it must be captured as one. The authorizing act is the requester volunteering her number while booking a tour for her; today that act is discarded at capture, which is the structural finding. The claim minted from it carries the conveyed provenance and the first message on it must carry an opt-out.
- That provenance does not exist yet, and adding it is a compliance decision.
IdentityClaimVerifiedByis a closed union with no value for a conveyance —self_declaredis wrong (she declared nothing) and the*_inboundvalues are wrong (the inbound was the principal's, which is the whole distinction — includinganonymous_inbound, the nearest shape, which is still minted FROM an inbound she never sent). Adding one forces atrustTiercase: Tier 1 needs a verification gesture she never made, Tier 2 is observed-on-inbound which she never was, so a conveyed number is Tier 4 — declared but not proven. And becauseprimarydecides outbound between two same-type same-tier claims, whether the conveyed claim may beprimaryis the question that actually governs whether she is reachable. Decide both here, in the open, rather than in whoever writes the writer.- Uniform treatment (the second bullet above) is untouched, as is D3's rule that recognition is reasoning-based and never a name substring match.
STATUS (2026-08-05). Both halves have now shipped, and the sentence "that provenance does not exist yet" above is history rather than present tense — retained because the reasoning that produced the decision is the point.
The CARRIER — PR #5430:
conveyed_by_intermediary(Tier 4),IdentityClaimSource.conveyed,mayConveyedClaimBePrimary,HouseholdMember.endpoint, and household-based tour recipient resolution with per-party at-most-once send claims.The CAPTURE — this PR:
update_prospect'sexpected_partiesgains acontact_as_giveninput that is never persisted onto the hint (the hint stays contact-free, exactly as the first bullet requires) and instead flows throughidentity/conveyed-contact→ a Person + a conveyedIdentityClaim, thenhousehold/conveyance-sync→ the one on-behalf writer withbasis: 'conveyed_contact'. Clara is instructed never to ASK for the handle: capture is only ever of something volunteered. Capture is STORAGE — it adds no send path, and every message still runs the dispatcher's consent, suppression and quiet-hours gates.Two sentences in the retained bullet ARE reversed, and are called out here because "retained verbatim" would otherwise imply they stand. "The friend's own first inbound message is their consent for that channel" — no longer the only basis; a captured conveyance is a basis before she writes in. And "Clara must never offer to 'reach out to' the friend" — this one is still the shipped posture (
clara-leasing.tsRULE 13 has Clara share this thread, with no outbound path) and stays in force until stage 3 lands the capture; it is superseded in principle, not yet in the prompt. Nothing about the conversational rule changes on the strength of this note alone.The parity claim in the rev-3 changelog (§ voice, "identical across channels") is unaffected: the posture is revised, its channel-uniformity is not.
The acceptance spec for all of this is
src/__tests__/household-harnessHH-16 (the visitor with no record and no channel) and HH-17 (the unrecognized inbound). Those scenarios are the gate; this note exists so the gate and the ADR do not say opposite things to whoever implements it.
LANDED 2026-08-05 — the two open decisions, answered. The gate is green (HH-16 and HH-17 are
today; the harness reports 17 pass / 0 fail / 0 pending), so the questions the note above deliberately left in the open are recorded here as answers rather than left to be reconstructed from a diff.
- The provenance value is
conveyed_by_intermediary, onIdentityClaimVerifiedBy, withIdentityClaimSourcegainingconveyedalongside it —inboundwould be the same mislabel one level down, because the inbound the claim was born on belonged to somebody else.- Tier 4, as the note reasoned.
trustTier's exhaustive switch forced the classification;isTier1Sourcereturns false, which matters concretely becauseaddClaimrejects a Tier-1 claim with noverifiedAtand a conveyance has no verification gesture to stamp one from.primaryis CONDITIONAL —mayConveyedClaimBePrimaryinclaim-selection.ts. A conveyed claim may be primary only when the person holds no other live claim of that type: that is the case it exists for (a human for whom the conveyed handle is the only channel, where refusing would mean capturing a consent basis and never using it), and it may never displace a channel she established herself, which is the shape a mistyped or malicious hand-over takes. The read side enforces the same posture independently — the V5 cascade ranks Tier 4 last — so neither half is load-bearing alone.- The channel hangs off the household MEMBER, not off a prospect row (
HouseholdMember.endpoint, derived byhousehold/party-endpoint.ts). A party to a deal may have no row at all, so a row-sourced endpoint could never have been produced for her.- The reversed sentence in the retained bullet is now reversed in the prompt too, not only in principle: the capture has landed, so RULE 13's no-outbound posture is superseded in fact. Outbound to her rides the EXISTING tour dispatchers with every consent, suppression, stop-check and quiet-hours gate applied per party — a conveyed basis buys her a place in the fan-out, not an exemption from any gate.
D7. Prompt: RULE 13 distinguishes the two intents
The leasing prompt's referral guidance is rewritten around the two-intent
model: on-behalf visit (email-thread-primary + text-line-optional handoff,
purpose acknowledgment, name capture → expectedParties, zero application
talk, no gender assumptions, no save_prospect for the visitor) vs. true
referral (new lead; capture + referral attribution). When the phrasing is
ambiguous, Clara asks which it is.
Founder rulings on the review's open decisions (2026-07-17)
- Voice: OUT of v1 — text/email only. Callers fall through to today's cold flow. (Review §5.1; panel leaned plumb-it — founder accepted the scope cut knowingly.)
expectedPartieshint: KEEP — write-once from the principal's thread, warm recognition over interrogation. (Panel lean, unopposed.)- Role enum: ship
visitoronly; the field is an open string; all other values (incl.co_applicant) are future one-liners, not reserved pre-built structure. "Why over-complicate this" — genericity comes from the field existing, not from pre-enumerating roles nobody has hit. - Representative-first (
inquiryId: null): schema-room only, explicitly not wired. Ship it when a real case lands.
Rev-3 amendment (2026-07-18, founder rulings)
After Phase-2 prod stress runs, the founder settled five points. This section is the binding record; where it conflicts with rev-2, rev-3 wins.
ONE-RECORD model, made explicit. A pure on-behalf visitor NEVER gets a
ProspectInquiryof their own — "they were never a prospect in the first place." Their tour rides the PRINCIPAL's inquiry (D4 re-key); the deal is ONE record accumulating history. Enforced two ways in #4079: a PREVENT guard (save_prospectrefuses to mint on a conversation already carrying a linkedonBehalfOfstamp) and an ERASE path (a race-minted visitor inquiry, created before the stamp existed, is deleted — never closed — under strict no-real-history guards). Escape hatch: an explicitown_lead:true(RULE 13 path b — the visitor pivots to their OWN search) mints normally.Corroboration gate restored to the specced OR. Rev-2 shipped an AND-gate (a fresh
expectedPartieshint naming the visitor AND the visitor naming the principal), which made LLM capture load-bearing — the fragility the founder rejected. Rev-3 restores ADR-D3's OR: a visitor naming a principal that UNIQUELY resolves against the property's OPEN inquiries is enough to link (single signal, no hint required). The deterministic ≥2-candidate ambiguity refusal from #4075 stays as the safety rail — now on the label axis: two open inquiries of the named first name ⇒ refuse and ask which; exact full-name and the advisory hint (unit-narrowed) are the only tie-breakers. No match ⇒ no link (cold name-drop). (This PR.)Voice back IN scope — channel-agnostic recognition. Reverses rev-2 ruling #1. On-behalf recognition +
link_on_behalfare NOT text/email-only; a caller who says "I'm calling to set up a visit for my friend Carly" is recognized and linked on the same server-side gate. The uniform-treatment and TCPA rails (no outbound to the visitor— REVISED 2026-08-02, see the note under D6; no info disclosure) are identical across channels. The parity claim itself survives the revision: whatever the outbound posture is, it is the same on every channel. (Voice plumbing PR.)Voice parity caveat — tie-breaker only, NOT annotation-only: the gate's primary resolution keys on the caller-supplied principal label matched against the property's open inquiries, so the common case (one open inquiry for the named principal) fully LINKS on voice and the booking re-key fires, even though the caller's own name is unknown. What voice loses is only the visitor-name corroboration signal (
participantNamereads "Unknown Caller"), which is a tie-breaker. Voice degrades to annotation-only (inquiryId: null) in exactly two edges: (i) 2+ open inquiries share the principal's first name and no unit-narrowed hint breaks the tie, or (ii) the principal's inquiry name reads empty and only a visitor-naming hint could recover it. Fail-safe and fair-housing-clean in both edges.The D3 "honest residual" is CLOSED. D3 called the in-loop
save_prospectsuppression a prompt-influenced ceiling. #4079 makes it deterministic: the tool itself refuses to mint for a linked visitor, and a race artifact is erased. It is no longer an accepted residual.Hint demoted to advisory-only, exactly as D2's original text describes: it tightens the recognition candidate list, breaks a label tie, and drives the warm "we were expecting you" greeting — it never gates the link. (This PR.)
Rev-4 amendment (2026-08-25, incident remediation — Fede-ruled)
Incident (2026-08-24): the inbound-recognition broadcast (D3/Phase-2,
recognize-on-behalf.ts called from inbound-router.ts §3b) scanned ALL of a
property's ProspectInquiry rows on every unstamped SMS/email leasing turn and
pasted up to 2 candidates — including a hint's raw free-text note — into
Clara's context as <system_context>, regardless of who was actually
messaging. A resident's guest-card note ("[visitor name], connected to another
resident + her phone number") leaked into an UNRELATED prospect's thread and
reached that prospect in an outbound email.
The invariant (Fede, 2026-08-25): a hint about person X is only ever surfaced in a conversation whose sender named X or claims to visit on X's behalf — never inferred from context, never pushed unasked. Edge cases get a safe default (return nothing), not a mechanism.
What changed:
Broadcast removed — BOTH channels. The SMS/email inbound router no longer calls
recognizeOnBehalf/injectsonBehalfCandidatesinto Clara's context (inbound-router.ts§3b deleted;conversation-manager.ts's corresponding render block deleted). The voice personalization webhook (src/app/api/voice/personalization/route.ts) no longer scans the property's hints during the ring or emits{{expected_visitors}}either — that broadcast was architecturally identical (every caller with no verified tenant record got 0-2 candidates' free-text notes, regardless of what they said) and was found and removed in the same PR (bot review, 2026-08-25) rather than deferred.recognize-on-behalf.tsandrenderExpectedVisitorsContexthave NO remaining production caller and are DELETED outright, not left as dead code.Recognition is now a scoped TOOL lookup,
lookup_expected_visitor(agents/clara/lib/agent/tools-leasing.ts, next tolink_on_behalf) — channel-agnostic, same aslink_on_behalf: bound to the voice Leasing agent (src/lib/tools/leasing.tscatalog entry,channels: ['voice', 'text'], routeSluglookup-expected-visitor) and dispatched throughLEASING_ROUTE_MAPinsrc/app/api/voice/tools/[tool]/route.ts, exactly likelink_on_behalf. Clara calls it ONLY when the sender/caller has herself claimed an on-behalf connection — named a resident/applicant she's touring for, or given her own name to check against an expected visitor. It matches on either axis (principal name, visitor name, or both), using the same first-name key + soft 30-day TTLlink_on_behalf's corroboration path already uses. Output is deliberately thin —{ matched, principal_first_name?, visitor_name_as_given?, role?, units? }— and NEVERnoteor any contact field. No input, no match, an unresolved ambiguity (≥2 distinct principals, or one principal with ≥2 fresh parties and nothing to pick between them), or a stale/closed-inquiry hint all take the same safe default:{ matched: false }. RULE 13's text prompt guidance and the voice Leasing prompt's ON-BEHALF VISITOR section are both reworded to describe the lookup explicitly (Clara is NOT handed any pre-loaded list of who's expected) rather than an ambient context block. Known gap: the voice tool projects into the catalog and the per-tool HTTP route, which is what the live ElevenLabs agent sync (scripts/sync-specialist-agent.ts, run by CI on push to main) derives its tool bindings from — so the new tool ships to the live agent automatically on merge, same as any other new catalog tool. The static reference snapshotagents/clara/lib/voice-agents/leasing.config.json(a point-in-time export, NOT the sync's source of truth) was not hand-updated with the new tool's entry; it will read as one merge behind until the next sync run refreshes it, which is cosmetic only.Hints retire at a terminal principal stage, AND can never outlive what
link_on_behalfitself would accept. The lookup's liveness filter isisActiveInquiry(inq) && !isTerminalPrincipalStage(inq.stage)— the SAMEisActiveInquirypredicate (src/lib/data/dynamo/inquiry-derivation.ts)resolvePrincipalInquiryalready filters open inquiries with (bot review finding, 2026-08-25: an earlier version checked stage only and ignoredclosedAt, so a hint could survive past what a link would accept — fixed before merge), PLUSisTerminalPrincipalStage(src/lib/domain/leasing/on-behalf-hint.ts—lease_signed,rejected,not_interested), which is deliberately narrower thanstale-lead-digest/build-call-list.ts'sTERMINAL_STAGES(that set is CALL-SUPPRESSION semantics and also excludesapplied/approved; those two are NOT terminal here — an approved applicant's family visiting before move-in is still a live, expected visit). The lookup's candidate set is therefore a provable SUBSET of whatlink_on_behalfwould ever resolve.expectedParties[].noteis scrubbed of phone numbers (7+ digits — a bot review finding, 2026-08-25, caught the original 8-digit-minimum regex missing a bare 7-digit local number) and email addresses at write (update_prospect's handler,tools-leasing.ts). This field is model-authored free text for the property team, not the contact-capture path (contact_as_given, unchanged, its own attributable/revocable path viacaptureConveyedVisitorParty) — nothing should ever land a raw phone number or address in free text alookup_expected_visitorscan reads back. Accepted false positive: an 8-digit ISO date in a note also gets redacted — a lost date is a strictly cheaper mistake than a leaked phone number.A single principal with multiple expected visitors never guesses which one (bot review finding, 2026-08-25). Looking up by principal name alone, when that principal has ≥2 fresh
expectedPartiesentries (e.g. she's expecting both a sister and a coworker), used to return whichever party sorted first — silently handing back the WRONG visitor's name and unit. The handler now treats "one principal, ≥2 candidate parties, no visitor name to pick between them" the same as the ≥2-distinct-principals case: safe default,{ matched: false }, never a guess.
Entity classification
No new entity. Conversation.onBehalfOf is derived state on the existing
canonical Conversation (single-writer stamp); expectedParties is a
derived-state hint on the existing ProspectInquiry (rides ADR-0035
dual-write); Tour.onBehalfOf is a render cache derived from the
conversation link. Spine trace: visitor Conversation → onBehalfOf.inquiryId
→ principal's inquiry → personId. No new naming, no new table rows, no new
GSI.
Consequences
Positive
- The friend who texts/emails in is greeted with context, not the cold-lead script; their visit advances the principal's deal instead of minting a fake one; the post-visit follow-up goes to the decision-maker.
- Pollution suppression is anchored on a same-turn structural read
(
conversation.onBehalfOf), not solely on a prompt rule; no visitor inquiry means no call-list entries, no drip enrollment, no metric inflation, no exclusion flags in future consumers (Appendix B evidence). - Concurrency-safe by construction: the linkage of record has exactly one writer (the visitor's own conversation); the inquiry hint is write-once from the principal's thread.
- No AppFolio exposure: PMS prospect sync is strictly inbound, so nothing here can leak outbound regardless.
- ADR-0097 fence untouched: all writes are tool-driven from live conversations, outside the fenced email-signal modules.
Negative / accepted risks
- The in-loop
save_prospectsuppression remains prompt-influenced (the honest residual in D3). The deterministic process-tour mint path IS closed by the ordering guard + structural read. - A friend who calls gets no recognition in v1 (founder-accepted scope cut). Degrades to today's behavior.
- Recognition is a reasoning judgment — corroboration-gated to fail toward mint-normally + annotate, never silent-link. False negatives degrade to the cold flow; the existing "REFERRED LEADS ARRIVING" prompt rule still catches self-identification.
- Reminder routing crosses records (tour on the principal's inquiry, reminders to the visitor's channel): workflow-input + three activities, covered by tests in Phase 2.
expectedParties/onBehalfOfexpiry (30 days →expired) is a soft TTL enforced at read time; an expired entry simply stops matching.expectedPartiesrides the ADR-0035 dual-write and must be mirrored in both trees.- The stage-demotion bug in
process-tour.tsis adjacent but out of scope — separate fix, already shipped as PRs #3993/#4018.
Phased delivery plan
Phase 1 — capture + PM subject. Small, ships first.
PropertyContext.textNumberplumbing (done in worktree; email-primary framing).- RULE 13 rewritten to the two-intent model; on-behalf turn = the locked target reply.
update_prospectgains optionalexpected_partiesinput →ProspectInquiry.expectedParties(D2, dual-write mirrored).Conversation.onBehalfOfschema +link_on_behalfstamp path (D1) — server-side resolution, no opaque-id echo.- Locked PM subject contract (D5) landed in the three PM surfaces behind the presence of a resolved link.
- Evals: Carly-pattern turn, capture turn, ambiguous-intent (Clara asks), true referral, explicit-apply counter-case. Bridged to the shipped RULE 13.
- Unit tests: prompt-block rendering, tool-input threading, dual-write projection, subject-string lock (byte-identical across surfaces).
Phase 2 — recognition + linked tour (text/email only).
recognizeOnBehalfordering guard ininbound-router.ts(beforeprocessTourPipeline), candidate-injection block, corroboration gate.schedule_tourresolvesonBehalfOf.inquiryIdserver-side;Tour.onBehalfOfrender cache; reminder plumbing (attendeePersonIdworkflow input + 3 activities); arrival-alert back-reference;expectedPartiesstatus flip on arrival.- Evals: friend-recognized, friend-with-no-match (cold flow unchanged), name-drop-only (corroboration ask), common-name ambiguity, dormant self-identify, visitor-develops-own-intent.
- email-stress multi-turn scenario covering Carly-turn → friend-inbound → linked booking on the test property (the on-behalf harness built 2026-07-17 is the pre-code gate for this phase).
Phase 3 — loop close.
- Forked post-visit follow-up to the principal (rides the existing post-tour timer path with an on-behalf payload — no new scheduler, no application talk).
- Eval: follow-up goes to the principal, references the friend's visit, no pressure framing.
Each phase is one PR, each with its gauntlet green before merge.
Alternatives considered
- Rev-1 incumbent:
expectedVisitorfield on the principal's inquiry + no-inquiry visitor + tour on the prospect's record viaon_behalf_oftool input — rejected by the adversarial review (26.75/90). Fatal:schedule_tourrefuses a second Tour whenprospect.tourIdis set (tools-leasing.ts:2267-2297), and Carly already toured virtually — the friend's booking is refused or overwrites her tour row. Also: pollution suppression was prompt-only, the PM subject contract was absent, and the single-slot/role-fixed field failed mainstream genericity cases. Full autopsy in review §3; its consumer-pollution evidence (Appendix B) still underwrites the no-visitor-inquiry rule carried into D4. - generic-party (
parties[]array on the inquiry) — scored 57.25/90; rejected: the matcher is empty at the visitor's first contact (suppression stays prompt-gated) and the whole-attribute list write is a lost-update race worse than a single-object overwrite. - tour-centric (defer-on-open-party + tour-anchored linkage) — scored 50.75/90; rejected: deferring unrelated cold leads during an open-party window is itself a steering surface; its arrived-fix needed the GSI it banned. Its ordering guard and honest reminder pricing were adopted.
- Dedicated
expect_visitortool + standalone record — rejected by reviewer as over-engineering. - Free-text notes only (status quo) — rejected: not queryable at the inbound hook; nothing links the visit back to the prospect.
- Clara books the friend's tour through the prospect — rejected: turns the prospect into a scheduling relay across time zones.
- Text-first contact handoff — rejected: the contact being shared is this email thread; the texting line is optionality, not the headline.
- Cross-thread heavy identity machinery (new entity + resolver stage + GSI) — rejected as disproportionate at current volumes.
Appendix A — Competitive landscape (deep research, 2026-07-16)
Multi-source adversarially-verified sweep across EliseAI, Funnel, Knock (RealPage), Yardi RentCafe, AppFolio, Colleen, PERQ, BetterBot, Rentgrata, plus leasing-ops and compliance sources. All findings below survived 3-vote verification at high confidence.
Nobody models this. It's white space.
- No leasing platform reviewed has a first-class concept for a third party touring on behalf of a lead — no attendee-vs-lead distinction, no linked visitor record, no outcome flowing back to the original prospect. Knock's own flexible-touring material enumerates virtual/video/in-person/ self-guided only (Knock blog).
- Referral tooling that does exist points the wrong direction: Rentgrata is resident→prospect ambassador chat with move-in-gated referral credit (how it works); Knock's cross-property referral is agent-initiated, not lead-initiated (Knock CRM). Prospect-initiated "I'll send my friend" is unmodeled everywhere.
- Meanwhile the behavior itself is established renter practice: sight-unseen guides explicitly recommend sending a trusted friend/family member to tour in the lead's stead (Brick Underground, corroborated by RentCafe/Rent.com/Forbes). Out-of-town relocators — the Carly case — are the canonical scenario.
Patterns worth borrowing
- The persistent per-renter guest card is the industry's anchor record — Funnel is "architected with a single guest card for each renter" (Funnel); Knock's "360 guest card" shows portfolio-wide history. Attaching the visitor to the principal's record matches where the industry is heading, vs. minting a parallel lead.
- Rentgrata's linkage model (persistent relationship record, attribution
resolved on downstream conversion) is the closest analogue to our
hint → link →
fulfilled→ loop-close lifecycle. - Leasing-ops guest-card practice (who was assisted, what was shared, when — AAOA) confirms the visitor belongs as a timestamped annotation on the lead's record, with follow-up routed to the decision-maker.
Compliance rails (drove D6)
- TCPA: consent attaches to the individual, not the number. A lead supplying a friend's number creates no consent to contact the friend (FCC 2015 Declaratory Ruling; N.L. v. Credit One, 9th Cir. 2020; Osorio v. State Farm), and the business placing the call/text — not the lead — bears liability (MS Law Group, ActiveProspect). Self-provision is the only consent path (FCC 92-443): the friend texting us first IS the consent event. Our inbound-only design is not just simpler — it's the only compliant shape.
- Fair housing: HUD's May 2024 AI guidance applies the FHA to automated agents; differential information/availability/tone/response-speed is steering even without intent (24 CFR §100.7; SafeRent $2.275M disparate-impact settlement, Nov 2024). Recognition must not change substance (LetHub analysis).
Net read: the capability is real white space with genuine relocator demand, no competitor precedent to copy, a clear industry-aligned anchor (the prospect's own record), and compliance constraints that happen to force exactly the inbound-only design chosen in D3/D6.
Appendix B — Consumer-pollution sweep (2026-07-16, code-verified)
What happens if an on-behalf visitor IS minted as a plain inquiry, ranked worst-first. All findings verified at the cited sites; this is the evidence behind the no-visitor-inquiry rule (D4).
- PM call list chases a non-prospect —
src/lib/domain/leasing/stale-lead-digest/build-call-list.tscohorts B (inquired_not_toured, :348), C (ghosted_after_first_reply, :307 — armed immediately becausesave_prospectstampsclaraEngagedAtat mint,tools-leasing.ts:1538), D (:203). No entitySource/on-behalf exclusion; doesn't even filterisSynthetic. A human is directed to phone the friend. - Autonomous 4-touch SMS drip to the friend —
onProspectEngaged(outreach-trigger.ts:150, fired fromconversation-manager.ts:5806) plus the reconcile cron backstop (outreach-reconcile.ts:116). Gated only by per-propertyautonomousProspectOutreachEnabled(fail-closed); on an armed property the friend gets texted like a lead — TCPA-shaky consent. - Tour conversion corrupted — visitor books, never applies:
prospectsWithTourInWindowup,tourConversiondown (dashboard/stats/compute.ts:960-1004). - Funnel counts inflated —
contactsInWindow(compute.ts:930),newLeads/leadToTour/leadToLease(reporting-digestbuild-report.ts:100-164),prospectsCreatedToday(agent-metrics.ts:368). - Active-pipeline gauges inflated —
screeningPipeline(compute.ts:907),activeProspectsCount(agent-metrics.ts:804), pipeline-stage-members "Prospects"/"Tours" badges + drill-downs. - Scheduling-engaged walker nudge (
scheduling-engaged-walker.ts:97, same arm gate as #2). - False LEASE_SIGNED promotion (low likelihood) —
pms/writers/prospect-conversion.ts:140promotes any advanceable inquiry sharing apersonIdwith a new occupancy. - AppFolio leak: NON-RISK — PMS prospect sync is strictly inbound
(
pms/writers/guest-card.ts:18-32); no outbound guest-card writer exists.
Also verified: no existing prospect-side relationship/role concept to reuse
(TenantOccupancyRole and Conversation.tenantMatchType are on other
entities); entitySource is NOT set at mint and readers default it to
'clara', so no existing source filter can exclude a visitor; the Tour model
is strictly 1:1 with the booking prospect's personId with no attendee
concept (types.ts:4861-4919).