0101 — On-behalf visitors: conversation-linked third parties

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:

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:

  1. 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.
  2. 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:

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 stampConversationSubtopicupdateItemFields 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

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

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:

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)

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:

  • expectedParties and onBehalfOf stay contact-free. A conveyed basis needs its own carrier — a real Person + IdentityClaim stamped 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. IdentityClaimVerifiedBy is a closed union with no value for a conveyance — self_declared is wrong (she declared nothing) and the *_inbound values are wrong (the inbound was the principal's, which is the whole distinction — including anonymous_inbound, the nearest shape, which is still minted FROM an inbound she never sent). Adding one forces a trustTier case: 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 because primary decides outbound between two same-type same-tier claims, whether the conveyed claim may be primary is 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's expected_parties gains a contact_as_given input that is never persisted onto the hint (the hint stays contact-free, exactly as the first bullet requires) and instead flows through identity/conveyed-contact → a Person + a conveyed IdentityClaim, then household/conveyance-sync → the one on-behalf writer with basis: '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.ts RULE 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-harness HH-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, on IdentityClaimVerifiedBy, with IdentityClaimSource gaining conveyed alongside it — inbound would 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; isTier1Source returns false, which matters concretely because addClaim rejects a Tier-1 claim with no verifiedAt and a conveyance has no verification gesture to stamp one from.
  • primary is CONDITIONALmayConveyedClaimBePrimary in claim-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 by household/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)

  1. 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.)
  2. expectedParties hint: KEEP — write-once from the principal's thread, warm recognition over interrogation. (Panel lean, unopposed.)
  3. Role enum: ship visitor only; 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.
  4. 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.

  1. ONE-RECORD model, made explicit. A pure on-behalf visitor NEVER gets a ProspectInquiry of 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_prospect refuses to mint on a conversation already carrying a linked onBehalfOf stamp) 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 explicit own_lead:true (RULE 13 path b — the visitor pivots to their OWN search) mints normally.

  2. Corroboration gate restored to the specced OR. Rev-2 shipped an AND-gate (a fresh expectedParties hint 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.)

  3. Voice back IN scope — channel-agnostic recognition. Reverses rev-2 ruling #1. On-behalf recognition + link_on_behalf are 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 visitorREVISED 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 (participantName reads "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.

  4. The D3 "honest residual" is CLOSED. D3 called the in-loop save_prospect suppression 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.

  5. 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:

  1. Broadcast removed — BOTH channels. The SMS/email inbound router no longer calls recognizeOnBehalf/injects onBehalfCandidates into 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.ts and renderExpectedVisitorsContext have NO remaining production caller and are DELETED outright, not left as dead code.

  2. Recognition is now a scoped TOOL lookup, lookup_expected_visitor (agents/clara/lib/agent/tools-leasing.ts, next to link_on_behalf) — channel-agnostic, same as link_on_behalf: bound to the voice Leasing agent (src/lib/tools/leasing.ts catalog entry, channels: ['voice', 'text'], routeSlug lookup-expected-visitor) and dispatched through LEASING_ROUTE_MAP in src/app/api/voice/tools/[tool]/route.ts, exactly like link_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 TTL link_on_behalf's corroboration path already uses. Output is deliberately thin — { matched, principal_first_name?, visitor_name_as_given?, role?, units? } — and NEVER note or 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 snapshot agents/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.

  3. Hints retire at a terminal principal stage, AND can never outlive what link_on_behalf itself would accept. The lookup's liveness filter is isActiveInquiry(inq) && !isTerminalPrincipalStage(inq.stage) — the SAME isActiveInquiry predicate (src/lib/data/dynamo/inquiry-derivation.ts) resolvePrincipalInquiry already filters open inquiries with (bot review finding, 2026-08-25: an earlier version checked stage only and ignored closedAt, so a hint could survive past what a link would accept — fixed before merge), PLUS isTerminalPrincipalStage (src/lib/domain/leasing/on-behalf-hint.tslease_signed, rejected, not_interested), which is deliberately narrower than stale-lead-digest/build-call-list.ts's TERMINAL_STAGES (that set is CALL-SUPPRESSION semantics and also excludes applied/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 what link_on_behalf would ever resolve.

  4. expectedParties[].note is 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 via captureConveyedVisitorParty) — nothing should ever land a raw phone number or address in free text a lookup_expected_visitor scan 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.

  5. 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 expectedParties entries (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

Negative / accepted risks

Phased delivery plan

Phase 1 — capture + PM subject. Small, ships first.

Phase 2 — recognition + linked tour (text/email only).

Phase 3 — loop close.

Each phase is one PR, each with its gauntlet green before merge.

Alternatives considered

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.

Patterns worth borrowing

Compliance rails (drove D6)

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).

  1. PM call list chases a non-prospectsrc/lib/domain/leasing/stale-lead-digest/build-call-list.ts cohorts B (inquired_not_toured, :348), C (ghosted_after_first_reply, :307 — armed immediately because save_prospect stamps claraEngagedAt at mint, tools-leasing.ts:1538), D (:203). No entitySource/on-behalf exclusion; doesn't even filter isSynthetic. A human is directed to phone the friend.
  2. Autonomous 4-touch SMS drip to the friendonProspectEngaged (outreach-trigger.ts:150, fired from conversation-manager.ts:5806) plus the reconcile cron backstop (outreach-reconcile.ts:116). Gated only by per-property autonomousProspectOutreachEnabled (fail-closed); on an armed property the friend gets texted like a lead — TCPA-shaky consent.
  3. Tour conversion corrupted — visitor books, never applies: prospectsWithTourInWindow up, tourConversion down (dashboard/stats/compute.ts:960-1004).
  4. Funnel counts inflatedcontactsInWindow (compute.ts:930), newLeads/leadToTour/leadToLease (reporting-digest build-report.ts:100-164), prospectsCreatedToday (agent-metrics.ts:368).
  5. Active-pipeline gauges inflatedscreeningPipeline (compute.ts:907), activeProspectsCount (agent-metrics.ts:804), pipeline-stage-members "Prospects"/"Tours" badges + drill-downs.
  6. Scheduling-engaged walker nudge (scheduling-engaged-walker.ts:97, same arm gate as #2).
  7. False LEASE_SIGNED promotion (low likelihood)pms/writers/prospect-conversion.ts:140 promotes any advanceable inquiry sharing a personId with a new occupancy.
  8. 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).