ADR-0101 Adversarial Review — Final Report
1. TL;DR
The panel unanimously rejects the incumbent ADR-0101 and recommends a hybrid built on the conversation-link spine: put the on-behalf relationship on the visitor's own Conversation (onBehalfOf), not as a field on the principal's inquiry — the only shape that makes pollution-suppression, the "arrived" alert, and the concurrency story work by construction rather than by prompt-hope or an empty lookup. It borrows the role-discriminant enum and reasoning-based candidate-injection recognizer that all three reframe designs converged on (best articulated in generic-party), the process-tour ordering guard and honest Temporal reminder plumbing from tour-centric, and a corroboration gate on any linkage (from the tour-centric skeptic) as the fair-housing rail. Two requirements survive every option and are non-negotiable: a locked byte-identical PM subject string (people don't read bodies), and the voice/relocator path must be plumbed or scoped out loudly. The founder's "hacky / not generic enough" instinct is fully vindicated — the fix is a small generic "party acting on behalf" concept, not a bespoke expectedVisitor patch and not a platform.
2. Scoreboard (judge-averaged, n=3; weighted totals: Carly fit ×2, genericity ×1.5, recognition ×1.5, PM clarity ×1.5, compliance ×1.5, impl cost ×1 — max 90)
| Option | Carly fit | Genericity | Recognition | PM clarity | Impl cost | Compliance | Total |
|---|---|---|---|---|---|---|---|
| conversation-link | 7.0 | 8.0 | 7.2 | 7.8 | 6.0 | 6.8 | 64.75 |
| generic-party | 5.7 | 8.0 | 7.2 | 7.2 | 4.7 | 5.2 | 57.25 |
| tour-centric | 5.7 | 6.3 | 7.2 | 6.2 | 4.2 | 3.8 | 50.75 |
| ADR-0101 incumbent | 3.3 | 2.0 | 2.2 | 1.2 | 5.8 | 4.2 | 26.75 |
Judge disagreements >2 points (all driven by Judge 3, compliance-ops, weighting compliance + honest impl-cost harder):
- generic-party total: J1 59 / J2 59.75 / J3 53 — J3 docks it for the vacuous first-contact gate (F1) and write-path false-suppression risk (compliance 4 vs 5.5–6).
- tour-centric total: J1 51.5 / J2 54.75 / J3 46 — J3 scores compliance 3 (over-broad deferral = steering surface; co_applicant reopens couples pollution).
- No single criterion diverges by more than 2 across judges; the spread is concentrated in totals via the compliance/impl columns.
3. What broke ADR-0101 under attack
Critiques that SURVIVED cross-examination (fatal to the incumbent):
- D3 collides with a hard code invariant.
schedule_tourrefuses a second Tour whenprospect.tourIdis set (tools-leasing.ts:2267-2297). The canonical Carly already toured virtually, so her inquiry carries atourId— the friend's on-behalf booking either is refused (active_tour_exists) or reschedules, overwriting Carly's virtual-tour row.Touris strictly 1:1 withpersonId. This is the central mechanic and it does not work as written. (Verified against code — survives.) - "Zero pollution by construction" is false — it's prompt-only. The only thing stopping a visitor inquiry is RULE 13, a prompt rule of equal-or-weaker standing than the "NON-NEGOTIABLE" mandatory-save TOOL GATE. The visitor's behavior (emailing the property line to book a tour) is maximally save-shaped — the strongest pressure in the system points at the one person Clara must not save. When it resolves wrong, all eight Appendix-B pollution sites fire. Every judge confirmed this; it is the load-bearing claim and it is overstated.
- The founder's loudest requirement — PM subject line — is entirely absent. One grep hit ("covered by tests"); no design. The only PM surface named is a passive dashboard. Flat miss.
- Severed "arrived" alert. The walk-in path resolves
getProspectByPersonId(visitor)→ null (no inquiry), so the arrival alert is "Jordan has arrived" with no date/unit/deal. Survives. - Recognition rejects the exact cases the founder asked to handle. The D2 anti-spoofing rule ("name-only weak match without corroboration does NOT link") drops first-name-only self-identification — precisely the partial-info reasonable-guess the founder wants. Deterministic substring matching is also a false-positive/steering hazard on common names ("Dana").
- Genericity failure confirmed.
expectedVisitoris over-specified in three dimensions (cardinality=1, role=visitor, direction=principal-first); each breaks on a mainstream case (couples, co-applicants, parent-for-student), not an exotic one. Proliferation forecast (expectedVisitor2,coApplicants,guarantor,accompaniedBy) is real.
Critiques that DIED / were downgraded under cross-examination (do not carry into the build):
- generic-party's "hard gate at 3 writers" — died.
parties[].personId/conversationIdare unset until recognition, so at the visitor's first inbound (the only moment pollution occurs) the matcher is empty; suppression stays prompt-gated (F1). This is exactly why we do NOT anchor on the inquiry-array design. - generic-party's "merge-by-id concurrency safety" — died.
updateProspectFieldsbuilds a whole-attributeSET parties = :array(nolist_append, no version condition); two writers clobber a shared list — worse than the incumbent's single-object overwrite (F3). - tour-centric's "defer on any open party" — died as a compliance rail. Diverting unrelated cold leads to a slower path during an open-party window is itself a fair-housing steering surface, and its arrived-fix secretly needs the GSI it bans.
- The "cross-thread heavy identity machinery / new entity + GSI" strawman in the ADR's Alternatives — correctly dead, but it was never the real competitor; conversation-link needs none of it.
4. Recommended design (concrete spec)
Spine: conversation-link. The on-behalf relationship is a property of the visitor's conversation, written once from that conversation (single-writer, same stampConversationSubtopic → updateItemFields path as the existing tenantMatchType/subtopic markers). No INQUIRY_C_FIELDS dual-write, no GSI, no list-merge concurrency machinery. This is the piece that dissolves the lost-update race and hands the arrived-alert a keyed reverse pointer for free.
Schema
(a) Conversation.onBehalfOf — the linkage of record (from conversation-link):
onBehalfOf?: {
inquiryId: string | null; // principal's ProspectInquiry; null in representative-first cases
principalPersonId?: string;
principalLabel: string; // "Carly" / "my daughter Maya" / ''
role: 'visitor' | 'co_applicant' | 'representative' | 'guarantor' | 'support';
status: 'pending' | 'active' | 'fulfilled' | 'expired'; // soft 30d TTL at read
linkedAt: string;
note: string;
};
No phone/email for anyone → nothing here can trigger outbound (TCPA preserved by construction).
(b) ProspectInquiry.expectedParties — advisory hint array, write-once from the principal's own thread only, never mutated cross-conversation (this is what kills the race). Read only to tighten the recognition candidate list. Role enum borrowed from generic-party.
(c) Tour.onBehalfOf — a render cache populated from the resolved conversation link (not a fuzzy match), so every PM surface renders "Visitor (for Principal)" without a second lookup and the tour-day alert resolves the visitor by back-reference. Replaces ADR-0101's Tour.onBehalfVisitor.
Role discriminant — borrowed from generic-party/tour-centric
visitor | co_applicant | representative | guarantor | support. Drop co_applicant from Phase 1 (J2 + J3 lean): co-applicants belong to the application, not the tour-attendee concept, and excluding them from suppression reopens the Appendix-B couples double-count. Keep the enum value reserved so the schema is future-ready.
Recognition — reasoning-based, corroboration-gated
- Ordering fix (borrowed from conversation-link/tour-centric, verified real): add
recognizeOnBehalf(conversation, message)ininbound-router.tsbeforeprocessTourPipeline(~:616). Today the tour pipeline runs before the agent loop and would mint the visitor → flip them toverified_prospect→ the recognition window never fires. No-op unless the property has a freshexpectedPartieshint, so ~zero cost on the cold path. Gate the full inquiry scan behind a cheap per-property marker sogetProspectsdoesn't run on every coldunknown_caller(tour-centric W1 fix). - Candidate-injection block, not a substring matcher (from generic-party/Critique 2 synthesis): inject 0–2 open candidates with principal first-name pre-resolved off the spine; let Clara reason. Handles: no name given + one candidate → warm greet; names the principal not themselves → match on principal; two candidates → ask which friend; dormant/late → acknowledge + reconfirm.
- Corroboration gate (the fair-housing write-path rail — all three judges insist): 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") written into the reasoning prompt itself.
- Hard suppression gate reads
conversation.onBehalfOf(populated same-turn) — this is the structural advantage over generic-party's empty first-contact matcher.
Honest residual to name for the founder: 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 is the backstop and closes the deterministic process-tour path; the in-loop save_prospect remains prompt-influenced. This is a real ceiling, not a bug we're hiding.
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):
- 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.
Temporal reminders — priced honestly (from tour-centric)
Not "one seam." Reminders must reach the person at the door: attendeePersonId workflow input + all three activities (confirmation, T-24h, T-1h) resolved off the visitor's conversation channel (which may be email, not SMS). The loop-close follow-up (D4) goes to the principal and must fork the payload off the existing post-tour app-link timer (Carly said she isn't ready — no application talk).
Voice
The candidate-injection must be threaded into the voice/ElevenLabs prompt assembly, a different path from the text agent loop, left unverified by every option. The canonical out-of-town relocator usually calls. Decision required (see §5) — plumb it or scope it out loudly; do not claim coverage.
Phased delivery
- Phase 1 — capture + PM subject. Principal-side: RULE 13 rewrite to the locked reply,
update_prospectwritesexpectedPartieshint,Conversation.onBehalfOf+link_on_behalftool (no opaque-id echo — respects the drift-test fence), locked PM subject contract. Evals: Carly turn, capture, ambiguous-ask, true-referral counter-case. - Phase 2 — recognition + linked tour.
recognizeOnBehalfordering guard, candidate-injection block, corroboration gate,schedule_tourreadsonBehalfOf.inquiryIdserver-side,Tour.onBehalfOfrender cache, reminder plumbing (workflow-input + 3 activities), arrived-alert back-reference. Voice plumbed or explicitly scoped. Evals: friend-recognized, no-match (cold unchanged), name-drop-only, common-name-ask, dormant self-identify. - Phase 3 — loop-close. Forked post-tour follow-up to the principal.
5. Open decisions only the founder can make
- Voice recognition — plumb in Phase 2, or scope out of v1 and ship text/email only? Panel lean: must be explicit either way; given the relocator usually calls, plumb it in Phase 2 rather than ship a hole the canonical case falls through — but that's a cost the founder should accept knowingly.
- Keep the
expectedPartieshint on the inquiry (warm "we were expecting you"), or zero inquiry change (cold-but-polite "who are you here for?")? Panel lean: keep it — write-once from the principal's own thread carries no race, and it's the difference between warm recognition and an interrogating question. co_applicantrole — build now, or reserve the enum value and defer? Panel lean: defer (J2 + J3). Co-applicants belong to the application; including them in the tour-attendee suppression reopens the couples funnel-pollution hole.- Representative-first direction (parent-for-student, assistant-for-exec —
inquiryId: null) — build now or schema-ready-only? Panel lean: schema-ready, out of scope for the build. The nullable link makes it representable without new fields; ship it when a real case lands, and state in the ADR that it's explicitly not wired yet rather than implyingexpectedVisitorever covered it.