ADR-0060: Preferred vendors — global catalog + per-property roster
- Status: Proposed — build next (Fede: needed for turnovers soon; ADR-0059's NL resolver depends on it)
- Date: 2026-06-17
- Deciders: Fede, Gera
- Parent ADR: ADR-0033 (VendorCompany + VendorMembership model)
- Related:
- ADR-0039 (AppFolio is source of truth for vendors)
- ADR-0051 (Unified PMS reconcile — vendor roster sync)
- ADR-0059 (Vendor dispatch for turnovers — the first consumer of this roster)
vendor-kb/camellia-vendor-kb.md— ~1yr of mined Camellia email → preferred vendor per trade (the seed data)
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:
- single-default — one vendor per trade, no notion of a backup;
- turnover-only — it lives under
turnoverPolicy, invisible to any other workflow; - property-only — no shared, org-level house list;
- unranked — array order is incidental, not a priority or fallback chain.
Two architectural questions sit underneath this and deserve their own decision record, separate from the dispatch mechanics in ADR-0059:
- Where do vendors live relative to a property or org — per-property, or a shared catalog?
- 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.
The global catalog already exists; keep it. Vendors live outside any org or property as root-level
VendorCompanyentities. This is already true — per ADR-0033,VendorCompanyis a root entity with noorganizationId("the counterparty PropFlow pays," a peer ofOrganization; per-PM engagement lives onVendorMembership). Gera's "vendors live outside the org" is not a change to make — it is the architecture we have. No new catalog entity.Preferred vendors are a ranked roster, expressed as a field. Add
preferredVendors: Record<trade, PreferredVendorEntry[]>at two levels —Organization(the house list) andProperty(per-building override). Each per-trade list is ordered by rank: index 0 is the primary, the rest are the fallback chain.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, neverlistVendorsForOrg. 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.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
Option A — Ranked roster field over the existing catalog (RECOMMENDED)
preferredVendors: Record<trade, PreferredVendorEntry[]> on Organization + Property,
read through one resolver. Catalog unchanged (it already has no org).
- Pros: No new entity, no new table access pattern, no migration of the catalog. Matches Gera's "it's just a field / a dictionary." Property-over-org override is a plain merge. Directly seedable from the email-mined KB. Same field serves every workflow, not just turnovers.
- Cons: A map field doesn't index well for reverse queries ("which properties prefer MTech?"). For the roster sizes we expect (≤ ~12 trades × ≤3 vendors), that's a non-issue — reverse lookup is a scan, and we don't need it on a hot path.
Option B — A dedicated PreferredVendor join entity
Single-table rows like PROP#<id> / PREFVENDOR#<trade>#<rank> → vendorId.
- Pros: Queryable both directions; clean audit per entry; scales past thousands.
- Cons: A new entity, new keys, new repository methods, and a migration — for data that
is small and almost always read all-at-once for one property. Violates "bias to simplicity
- reuse." Reach for this only if rosters grow large or reverse-query becomes a real need.
Option C — Keep flat defaultVendors, add priority by array order
Leave the existing field; treat its order as priority.
- Pros: Zero schema change.
- Cons: Still turnover-only, still property-only (no house list), still no per-entry metadata (source, who set it, confidence). Doesn't express Gera's org-level model and can't carry the "seeded from email vs. PM-confirmed" distinction that trust depends on.
Chosen: Option A. It is the smallest change that captures the full model (global catalog
- org/property ranked rosters + need-to-know resolver) and is seedable from evidence today.
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
}
sourceis what makes the roster trustworthy: anemail_miningentry is a guess awaiting PM confirmation; anonboarding_pickormanualentry is confirmed. The dispatch flow can require confirmation before auto-sending to a still-email_miningvendor.- Trade keys are a normalized vocabulary (plumbing, electrical, hvac, painting, flooring,
refinishing, housekeeping, pest_control, landscaping, window_coverings, appliance,
general_handyman, full_turnover), aliased from AppFolio's free-text
VendorCompany.trades. Paint is a known empty key for Camellia (see KB) — the resolver returns nothing and defers to the PM.
Migration from defaultVendors
turnoverPolicy.defaultVendors is the precursor of this field. Migration is gentle:
- Back-compat read: if
preferredVendors[trade]is empty butdefaultVendorshas an entry for that trade, treat the legacy entry as rank 0. - One-time backfill: lift each
defaultVendorsentry intopreferredVendorsas{ rank: 0, source: 'manual' }. - Deprecate
defaultVendorsone 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:
- Now (the foundation): the roster is captured from what the customer already uses, within their own synced catalog. No cross-customer data is touched, so the privacy concern doesn't arise — onboarding recommends nothing.
- Later (the bidding layer): a catalog de-dup pass unifies shared vendors across customers, which is the substrate for network recommendations and bidding (next section). Explicitly a later capability, not part of the preferred-vendor foundation.
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
- The NL vendor assignment ladder in ADR-0059 ("our preferred plumber" / policy default)
reads
resolvePreferredVendors(propertyId, trade)[0]instead of the flatdefaultVendors— one source of truth for "who do we use for X." - The fallback chain (rank 1, 2, …) is the data structure that makes ADR-0059 Phase 2/5 escalation ("email the other two") automatic instead of manual.
- Clara's vendor context stays bounded — a handful of vendors per trade, never the catalog — which keeps prompts small and reasoning sharp (Gera's need-to-know).
- The roster is seedable from evidence (email mining), so a new customer starts with a populated, source-tagged roster rather than a blank slate.
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
- 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."
- 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.
- 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.