ADR-0060: Preferred vendors — global catalog + per-property roster


Context

Vendor dispatch (ADR-0059) has to answer one question before it can send anything: which vendor handles this trade for this property? Today the only answer is a flat field — PropertyKnowledge.turnoverPolicy.defaultVendors: { trade, vendorId }[]. That field is:

Two architectural questions sit underneath this and deserve their own decision record, separate from the dispatch mechanics in ADR-0059:

  1. Where do vendors live relative to a property or org — per-property, or a shared catalog?
  2. How does a property express "these are my vendors for each trade," with a primary and fallbacks, so an agent can act on it without scanning a thousand-row catalog?

The mental model (Gera, voice notes 2026-06-17)

This ADR encodes the model Gera articulated. The key claims, verbatim in spirit:

"Vendors should live outside the organization of a property. If plumbing-ABC is one of the best plumbers in Denver and serves several of our buildings, a per-property model forces duplicates. That doesn't make sense."

"But now you have a thousand vendors and it's harder for Clara to see things. So Clara is on a need-to-know basis — she should only know the preferred vendors for plumbing, electric, handyman, cleaning, for this property."

"At onboarding we let people pick from existing vendors we can rate. If you have your vendors, we can find them from your email. If you're missing some, these are the ones we recommend because we run them on other properties — though we probably don't say which properties."

"Data model: at the org level or the property level, a field of preferred vendor. Maybe a dictionary — a list of electrician vendors, because you can have several. A list where the first one is always priority, and if it doesn't work you email the other two. The big catalog is just a domain of vendors; Clara should only look at the property's preferred vendors."

Fede's refinement (voice reply, same day) — two corrections that shape this ADR:

"Preferred vendors should be first-class. Camellia has ~10; we might have 800 in the system — doesn't matter. We should maybe not even show them or let Clara access the catalog. Clara should just be: plumbing issue → this is the plumber. That keeps it simple. I'm going to need this for turnovers pretty soon."

"Let me switch the mental model a bit on onboarding. Every property already has 100% preferred vendors. Nobody comes in saying 'I don't know my vendors, recommend me some' — that's not real life. They'll have at least one plumber, one electrician they already work with. So we should ask them what those are — bring them from the PMS, a Google Doc, an Excel sheet, a Post-it (how Camellia keeps them), or scan their email and they confirm. Several ways to bring them in."

"That preferred-vendor layer is enough for ~90% of what we do. Vendor bidding comes later — an upsell and a behavior change: 'you have a preferred vendor, but we could recommend others or bid it to 2–3 options.' Foundation first; recommendations/bidding in a few months, or when a client asks."

The net of Fede's correction: the capture path is the onboarding default (customers already have their vendors), and recommend-from-catalog is demoted to a later bidding layer — which also dissolves the "don't reveal which properties" privacy worry from the onboarding flow, since onboarding no longer recommends anything.

The evidence this is the right shape

The Camellia vendor KB — built by mining ~1 year of camelliaapts@jp-co.com email (vendor-kb/camellia-vendor-kb.md) — already produces exactly this structure: one confirmed preferred vendor per trade, with a confidence signal and one notable gap.

Trade Preferred vendor Confidence
Plumbing MTech Mechanical HARD
Pest control Anchor Pest Control HARD
Bathtub/tile refinishing Miracle Method Denver HARD
Carpet/floor cleaning Metropolitan Building Maintenance HARD
Landscaping / junk haul Cardenas Landscape HARD
Window coverings Royal Textile Products HARD
Paint none confirmed GAP

This is not a hypothetical schema — it is the literal output of "find them from your email," and it is the seed data for Camellia's roster on day one.


Decision

Four decisions, all additive — they extend the existing model rather than replace it.

  1. The global catalog already exists; keep it. Vendors live outside any org or property as root-level VendorCompany entities. This is already true — per ADR-0033, VendorCompany is a root entity with no organizationId ("the counterparty PropFlow pays," a peer of Organization; per-PM engagement lives on VendorMembership). Gera's "vendors live outside the org" is not a change to make — it is the architecture we have. No new catalog entity.

  2. Preferred vendors are a ranked roster, expressed as a field. Add preferredVendors: Record<trade, PreferredVendorEntry[]> at two levelsOrganization (the house list) and Property (per-building override). Each per-trade list is ordered by rank: index 0 is the primary, the rest are the fallback chain.

  3. A resolver is the need-to-know boundary. resolvePreferredVendors(propertyId, trade) merges property-over-org and returns only the preferred subset — never the catalog. Agents (Clara, the dispatch flow) read the resolver, never listVendorsForOrg. Per Fede, Clara needs no catalog access at all — the resolver is her only vendor surface. The catalog exists for control and switching, not for the agent to browse: the LLM context holds 1–3 vendors per trade, not a thousand.

  4. Onboarding captures the roster — it does not recommend one. Every property already has the vendors it uses; the onboarding job is to capture them, not suggest them. Several ingestion paths: (a) PM enters them directly, (b) import from the PMS, (c) scan the customer's email and detect them → PM confirms (the Camellia KB is this path). Recommending additional vendors from our network — and bidding — is a deliberately later layer (see below), not the onboarding default.

High-level architecture

The catalog is shared and wide; the roster is per-property and narrow; the resolver is the one-way valve between them. Everything an agent sees comes through the resolver.

flowchart TD
    subgraph Catalog["🌐 Global vendor catalog — shared, root-level (ADR-0033)"]
        VC1["VendorCompany
MTech Mechanical"] VC2["VendorCompany
Anchor Pest"] VC3["VendorCompany
Miracle Method"] VCN["… 1000s of vendors
(no organizationId)"] end subgraph Org["🏢 Organization roster (house list)"] ORG["preferredVendors:
{ plumbing:[MTech], pest:[Anchor] }"] end subgraph Prop["🏠 Property roster (per-building override)"] PRP["preferredVendors:
{ cleaning:[MetroBM, Sparkle] }"] end RES{{"resolvePreferredVendors(propertyId, trade)
property-over-org · sorted by rank"}} subgraph Consumers["Need-to-know consumers"] CLARA["Clara / LLM
(reasons over 1–3 vendors)"] DISP["Vendor dispatch
(ADR-0059)"] end ORG -. "references by id" .-> VC1 & VC2 PRP -. "references by id" .-> VC3 Org --> RES Prop --> RES RES -->|"only the preferred subset"| CLARA RES -->|"primary + fallback chain"| DISP Catalog -. "catalog is NEVER read directly by an agent" .-x CLARA

Resolution and the fallback chain

Gera's "first one is priority; if it doesn't work, email the other two" is the rank order. The resolver returns the whole ordered list; the primary drives the first dispatch, and the tail is the escalation chain that ADR-0059 Phase 2/5 walks when a vendor declines or goes silent.

flowchart LR
    A["Trade needed
e.g. plumbing"] --> B{"Property roster
has this trade?"} B -- yes --> C["Use property list"] B -- no --> D{"Org roster
has this trade?"} D -- yes --> E["Use org list"] D -- no --> F["Unresolved →
PM names the vendor
(NL resolver, ADR-0059)"] C --> G["Sort by rank"] E --> G G --> H["#1 primary →
dispatch now"] H --> I{"Accepted?"} I -- yes --> J["Done"] I -- "declined / no reply" --> K["#2 fallback →
dispatch next"] K --> I

Options considered with trade-offs

preferredVendors: Record<trade, PreferredVendorEntry[]> on Organization + Property, read through one resolver. Catalog unchanged (it already has no org).

Option B — A dedicated PreferredVendor join entity

Single-table rows like PROP#<id> / PREFVENDOR#<trade>#<rank> → vendorId.

Option C — Keep flat defaultVendors, add priority by array order

Leave the existing field; treat its order as priority.

Chosen: Option A. It is the smallest change that captures the full model (global catalog


Data model (the one schema decision)

The roster entry is intentionally small — a reference into the catalog plus the metadata that ranking and trust require:

interface PreferredVendorEntry {
  vendorCompanyId: string;   // FK → global catalog (VendorCompany.id)
  rank: number;              // 0 = primary; ascending = fallback order
  source: 'email_mining' | 'onboarding_pick' | 'recommended' | 'manual';
  note?: string | null;      // "primary for occupied-unit work"
  addedAt?: string;          // ISO
  addedBy?: string | null;   // userId (null = system-seeded)
}

// On Organization (house list) AND Property (per-building override):
preferredVendors?: Record<string /* trade key */, PreferredVendorEntry[]>;
erDiagram
    ORGANIZATION ||--o{ PREFERRED_VENDOR_ENTRY : "house list (per trade)"
    PROPERTY ||--o{ PREFERRED_VENDOR_ENTRY : "override (per trade)"
    PREFERRED_VENDOR_ENTRY }o--|| VENDOR_COMPANY : "references by id"
    VENDOR_COMPANY ||--o{ VENDOR_MEMBERSHIP : "per-PM engagement + contact (ADR-0033)"
    PREFERRED_VENDOR_ENTRY {
        string vendorCompanyId
        int rank
        string source
    }

Migration from defaultVendors

turnoverPolicy.defaultVendors is the precursor of this field. Migration is gentle:

  1. Back-compat read: if preferredVendors[trade] is empty but defaultVendors has an entry for that trade, treat the legacy entry as rank 0.
  2. One-time backfill: lift each defaultVendors entry into preferredVendors as { rank: 0, source: 'manual' }.
  3. Deprecate defaultVendors one release after backfill; keep it readable until then.

Catalog scope — the one honest gap vs. the full vision

Gera's model imagines one catalog shared across all customers (a great Denver plumber recommendable to any client). The entity model already permits this (VendorCompany has no org). But today vendors enter per-customer via AppFolio sync (ADR-0051), so two customers both using MTech get two separate VendorCompany rows. True cross-customer sharing needs a catalog de-duplication/merge step (match by name + email + phone, or EIN) that does not exist yet.

Honest position, staged:

This keeps the near-term simple and avoids claiming cross-customer sharing we haven't built.


Onboarding — capturing the roster (worked example)

Onboarding captures the vendors a customer already has; it does not recommend new ones. The ingestion paths — direct PM entry, PMS import, or scan-the-email-and-confirm — all land in the same roster. The Camellia KB is the email-scan path made concrete: ~1yr of mail → a draft roster the PM confirms. HARD-confidence vendors become email_mining primaries pending confirmation; the paint gap captures nothing and prompts the PM to name a painter.

Trade Seeded vendor rank source Result
Plumbing MTech Mechanical 0 email_mining Primary, PM confirms
Pest control Anchor Pest Control 0 email_mining Primary, PM confirms
Refinishing Miracle Method Denver 0 email_mining Primary, PM confirms
Housekeeping Metropolitan Building Maint. 0 email_mining Primary, PM confirms
Landscaping Cardenas Landscape 0 email_mining Primary, PM confirms
Window coverings Royal Textile Products 0 email_mining Primary, PM confirms
Painting (none) Gap → onboarding asks the PM to name a painter

The PM's confirmation at onboarding flips source from email_mining to manual and locks rank — the same "PM confirms before it's truth" gate the NL resolver uses at dispatch time.


Vendor bidding & recommendations — a later layer

The preferred-vendor roster covers ~90% of what dispatch needs: known trade → known vendor. Recommending additional vendors, or bidding a job to 2–3 options, is a deliberately later capability — an upsell and a behavior change, built in a few months or when a client asks, on top of the preferred-vendor foundation, never instead of it (Fede's sequencing call).

When it lands, recommendation candidates can come from three sources: vendors we run on other properties (gated behind the cross-customer catalog de-dup above, and the privacy rule — surface a vendor and an aggregate signal, never which properties use them), online discovery, or vendors the customer adds to the bid. Until then, an unresolved trade defers to the PM (ADR-0059's NL resolver), not to an auto-recommendation.

flowchart LR
    F["Preferred-vendor foundation
(this ADR — ~90% of dispatch)"] --> NOW["Now: known trade →
known vendor → dispatch"] F -. "later / on request" .-> BID["Bidding & recommendations layer"] BID --> S1["Network vendors
(needs catalog de-dup)"] BID --> S2["Online discovery"] BID --> S3["Customer-added bids"] S1 & S2 & S3 --> OUT["Recommend 2–3 →
bid out → PM picks"]

This sequencing is a decision, not an omission: foundation first.


Consequences

What this makes true

Risks and mitigations

Risk Mitigation
Auto-seeded (email_mining) vendor is wrong source flag; dispatch requires PM confirmation before auto-send to an unconfirmed entry
Trade-key drift between roster, WO, and AppFolio trades One normalized trade vocabulary + alias map from AppFolio trades
Paint (and any empty trade) has no vendor Resolver returns empty → NL resolver / PM-names-vendor path (ADR-0059); never silently picks a wrong trade
Reverse query ("who prefers MTech?") is a scan Acceptable at roster scale; revisit Option B only if it becomes a hot path
Recommending a vendor leaks which properties use them Privacy rule: surface aggregate signal only, never the property list

Open questions

  1. Org vs. property default placement — when both have an entry for a trade, property wins (decided). Should an org entry be mergeable into a property list (append as fallback) rather than fully overridden? Proposed: property fully overrides for v1; revisit if customers want "org primary, property backup."
  2. Cross-customer catalog de-dup — when do we build the merge step that makes the catalog truly global across customers? Out of scope here; gates network recommendations + ratings.
  3. Ratings — Gera's "vendors we can rate." Not modeled here. A future rating/signal on the catalog entry (not the roster) would inform recommendation ordering.

Summary

Vendors are a shared, root-level catalog (already true, ADR-0033). A property's working set is a ranked, per-trade roster held as a field on Organization and Property, read through one resolver that is the need-to-know boundary — Clara sees the resolver, never the catalog. Onboarding captures the vendors a customer already has (PM entry / PMS import / email-scan-and-confirm) rather than recommending new ones; recommendations and bidding are a later layer on top of this foundation. The roster's rank order is the dispatch-and-escalation chain that ADR-0059 consumes. The change is purely additive — no new catalog entity, one new field, one resolver. Foundation first.