ADR-0033 — VendorCompany + VendorMembership: decoupling the vendor row


TL;DR

Today's Vendor row glues three things into one: the company we pay (Mile High Plumbing), the human we text (Carlos Reyes), and the engagement scope (which properties Carlos works on for which PM). That overload breaks the "one row, one concept" rule, blocks multi-contact vendors, and prevents cross-PM vendor reuse. Split it into three first-class entities:

Mirrors Tenant's pattern: Tenant = Person + TenantOccupancyVendor = Person + VendorMembership + VendorCompany. Same architectural shape, applied to the counterparty side.


Context

The audio-brainstorm and Smith's pushback

On 2026-05-25, mid-PR-B work, Gera surfaced an architectural question via WhatsApp audio: "vendor kind of has an overloaded definition where it's like it's not the person but it's the company and i'm thinking does it make more sense to delete vendor and then at the organization level we have a type so we'd have a properties type we'd have a vendor's company type." The transcript is at ~/.claude/scripts/propflow-status/state/transcripts/audio_20260525_132531.txt.

Smith agent (sess_32f807ec, 13:26) pushed back: typing Organization is the wrong axis — Organization is already load-bearing as the SaaS-customer root (the PM company PropFlow bills), and overloading it with a vendor-type variant pulls a bigger refactor than the one being solved. The right axis is splitting the entity itself into Person + counterparty + membership.

This ADR ratifies Smith's split.

Today's Vendor row — the overload

src/lib/data/types.ts:897-945 (post-PR-G):

export interface Vendor {
  id: string;
  personId?: string;          // ← human identity (PR-G — should be on Person spine only)
  organizationId: string;     // ← company-level field (counterparty)
  contactName: string;        // ← human identity (should be on Person)
  company: string;            // ← counterparty name
  trade: string;              // ← counterparty
  phone: string;              // ← human identity (should be on Person/IdentityClaim)
  email: string;              // ← human identity
  isInHouse: boolean;         // ← engagement scope (in-house staff vs external)
  availability: { days, hours };  // ← human/scheduling concern
  specialties: string[];      // ← counterparty
  notifyOnRoutineUpdates?: boolean;  // ← engagement preference
  af?: { vendorId?: number }; // ← counterparty (AppFolio mapping)
}

Three failure modes that the overload causes today:

  1. Multi-contact impossible. Mile High's owner is Carlos; their dispatcher is Lisa. The 1:1 Vendor → Person constraint means we model them as TWO Vendor rows (duplicating company-level fields like W-9 and AppFolio mapping) or pick one as canonical and drop the other from the spine.
  2. Cross-PM vendor reuse impossible. If JP-Co AND a second PM customer both contract with Mile High, we have two Vendor rows for the same counterparty — one per organizationId. The spine work in PR-G partially decoupled the human, but the company stays per-PM.
  3. Person rename/turnover. When Carlos leaves Mile High and joins Front Range HVAC, today's model has no clean shape. We'd either repoint Vendor.personId (loses history) or mint a new Vendor row (duplicates company).

Today's VendorContact row — half-built

src/lib/data/types.ts:5269-5284:

export interface VendorContact {
  id: string;
  personId: string;
  organizationId: string;
  vendorId: string;
  role: string;              // 'owner' | 'dispatcher' | 'tech'
  createdAt: string;
  updatedAt?: string;
}

The role-bridge exists conceptually but is not load-bearingVendor.personId is still the canonical contact pointer in practice, VendorContact rows are barely populated, and there's no propertyIds[] scoping or active-window. The entity-model.md doc already shows VendorContact in the spine diagrams, but the implementation hasn't caught up.

Why typing Organization is the wrong axis

Gera's original audio proposed Organization.type: 'property_management' | 'vendor_company' and moving vendor-staff to PersonRoles scoped to the typed Org. Smith's pushback in three points:

  1. Organization is the SaaS-customer root. Every propflow-prod partition keys on organizationId to mean "this PM customer's data." Reusing the noun for "Mile High Plumbing as a typed Org" would either (a) require backfilling every PM's data with Organization.type='property_management' and adding type filters to every reader, or (b) introduce a second org-namespace and re-derive every claim-scoping rule.
  2. The asymmetry is real. A PM customer is a billing relationship — Stripe, Better Auth, role-based access, multi-property scope. A vendor company is a counterparty — W-9, COI, AppFolio mapping, no auth. Forcing them into one shape via a discriminator hides those facts; modeling them as distinct entities surfaces them.
  3. The pattern that worked for Tenant works here. Tenant is Person + TenantOccupancy + Property — not "Person is a typed Org variant." VendorCompany should mirror that.

Decision

Three entities, atomic responsibilities

VendorCompany (rename of Vendor)

export interface VendorCompany {
  id: string;
  /** Display name of the counterparty (Mile High Plumbing). */
  company: string;
  /** Categorical trade. Drives PM-side routing rules + vendor pickers. */
  trade: string;
  /** Trade specialties beyond the primary category. */
  specialties: string[];
  /** Counterparty mapping to AppFolio (and future PMSes). Cross-PM stable. */
  af?: { vendorId?: number };
  /** Counterparty-level engagement default — does the PM expect this
   *  vendor to be in-house staff (M-F 8-5, single-person crew) or
   *  external? Affects WO routing defaults; can be overridden per
   *  Membership for multi-tier vendors. */
  isInHouse: boolean;
  /** Counterparty-level documentation. Stays on VendorCompany because
   *  W-9 / COI / insurance are properties of the legal entity, not of
   *  any individual contact. */
  w9On?: boolean;
  coiOn?: boolean;
  coiExpiresAt?: string;
  paymentTerms?: 'net15' | 'net30' | 'net45' | 'on_receipt';
  createdAt: string;
  updatedAt: string;
}

What moves OFF: personId, contactName, phone, email, availability, notifyOnRoutineUpdates. Those become per-VendorContact fields.

What stays: company, trade, specialties, af, isInHouse, documentation fields, timestamps. Counterparty facts only.

Person (unchanged)

The spine row. personId: string (required post-ADR-0032). IdentityClaim rows carry phone/email/PMS mappings.

VendorMembership (extension of VendorContact)

export interface VendorMembership {
  id: string;
  /** The human. Required FK to the spine. */
  personId: string;
  /** The counterparty. Required FK to VendorCompany. */
  vendorCompanyId: string;
  /** The PM customer this membership is scoped to. A single Person can
   *  hold memberships at the same VendorCompany under multiple PMs
   *  simultaneously — Mile High's dispatcher Lisa serving both JP-Co
   *  and a future second PM is two rows. */
  organizationId: string;
  /** Role inside the vendor company. */
  role: 'owner' | 'dispatcher' | 'tech' | 'inspector' | 'office_staff';
  /** Which properties (within `organizationId`) this membership covers.
   *  Empty array = all properties for that org. Populated for PMs that
   *  carve up their portfolio (e.g., Carlos serves Camellia only;
   *  Mike serves Yale 25 only). */
  propertyIds: string[];
  /** Active-window — mirrors TenantOccupancy. `endedAt` set when the
   *  Person leaves the vendor (job change, retirement). Soft-archive,
   *  preserves history. */
  startedAt: string;
  endedAt?: string;
  /** Per-membership notification preference. Mirrors today's
   *  `Vendor.notifyOnRoutineUpdates` but scoped to one Person at one
   *  vendor — Carlos (owner) opts in to routine updates, Lisa
   *  (dispatcher) opts out. */
  notifyOnRoutineUpdates?: boolean;
  /** Per-membership availability — mirrors today's `Vendor.availability`
   *  but scoped to one Person. */
  availability?: { days: string[]; hours: string };
  createdAt: string;
  updatedAt: string;
}

VendorContact interface is renamed to VendorMembership type-wide (the existing fields are kept; propertyIds[], startedAt, endedAt?, notifyOnRoutineUpdates, availability are added).

Mirroring the Tenant shape

The architectural symmetry is the load-bearing claim of this ADR:

Domain Spine Counterparty Engagement
Residential lease Person (Property — the unit owner-ish concept lives on Org/Property today) TenantOccupancy — Person + Property + unit + lease window
Vendor contract Person VendorCompany — Mile High VendorMembership — Person + VendorCompany + propertyIds[] + active window

The same primitives apply: withSpineStamp(_unsafe_saveVendorCompany, ensurePersonForVendorCompany) is not a thing — VendorCompany has no human identity to stamp. The spine-stamp pattern applies to VendorMembership (Person + scope), not to VendorCompany. The construction invariant for VendorMembership is the same as for TenantOccupancy: personId: string required, writer pre-pass via ensurePersonForVendorMembership.

Cross-org reachability

A Person held by Mile High's owner (Carlos) can hold a VendorMembership scoped to JP-Co AND a separate VendorMembership scoped to a future second PM, on the same VendorCompany row. Cross-org reachability for the Person walks the union of their VendorMembership.organizationId values — same pattern as PersonRole's scope discriminator (ADR-0018 §4 + ADR-0020 §"Cross-org Person").

Behavioral change — today's Vendor.organizationId field drops. Today's Vendor.organizationId: string is a required field; every Vendor is partitioned by its owning PM. After this ADR, VendorCompany drops organizationId entirely (it becomes a root-level peer of Organization). Per-PM scoping moves to VendorMembership.organizationId. This is not a rename — it's a schema change with three concrete consequences PR-V3 must handle:

  1. Per-PM duplicate Vendor rows collapse to one VendorCompany. Today, Mile High Plumbing serving JP-Co AND a future second PM means two Vendor rows (one per organizationId). PR-V2's drain dedupes those into a single VendorCompany row, replacing the two Vendor rows with one VendorCompany + two VendorMembership rows.
  2. Every vendor.organizationId reader breaks at PR-V3. Anywhere a current caller does vendor.organizationId (queries, scoping checks, etc) must be updated to resolve org via the relevant Membership — typically via getVendorMembership(vendorCompanyId, organizationId) for "is this VendorCompany engaged with this PM."
  3. AppFolio af.vendorId mapping is per-PM today. A single AppFolio account belongs to one PM; the mapping stays per-PM. Post-rename, VendorCompany.af.vendorId becomes a Map<organizationId, vendorId> OR (cleaner) moves to VendorMembership.af.vendorId. PR-V3 picks the shape; documented at that PR's scope.

Assignment semantics (open question — being ratified here)

When a PM "assigns Mile High" to a WorkOrder, the assignment records:

PR-V3 revision: an optional WorkOrder.assignedVendorMembershipId? field was originally specced here to pin a specific contact, but shipped dead — no production path ever wrote or read it (every resolveVendorContact(...) call resolves the default contact). Per the "don't add abstractions beyond what the task requires" rule it was removed before merge; the owner-role-membership fallback IS the assignment behavior. Re-add it only when a real "assign a specific contact" UI/flow exists.

WorkOrder.assignedVendorId (today's field) is renamed to assignedVendorCompanyId in PR-V3; the rename is type-only since the underlying id is stable.


Alternatives considered

Alternative A — Type Organization

Rejected. See §Context "Why typing Organization is the wrong axis."

Alternative B — Keep Vendor as-is, fix multi-contact via array

Vendor.contacts: VendorContact[] denormalized on the row.

Rejected. DDB single-table arrays don't scale past ~10 contacts (item size limit). Reads can't filter by Person without scanning every Vendor. The Membership join row is more flexible and idiomatic.

Alternative C — Keep Vendor as the role, leave VendorCompany unmodeled

Just promote VendorContact to the canonical and use it as the unit of vendor work.

Rejected. The W-9 / COI / AppFolio mapping live somewhere — without VendorCompany those become per-VendorContact fields, which duplicates company-level facts across N contacts. Worse than today's overload.


Consequences

Positive

Negative / cost

Neutral


Rollout

Three PRs after the planning + ADR (this PR-V0):

PR-V1 — extend VendorContact → VendorMembership semantics

Scope:

Test surface: vendor-membership-spine-stamp.test.ts (signal translation truth table).

Backwards compat: None needed — VendorContact wasn't load-bearing yet.

PR-V2 — migrate Vendor.contactName / personId → VendorMembership rows

Scope:

Cutover sequence: dry_run → apply → re-dry-run → merge — same shape as the Tenant drain.

PR-V3 — rename Vendor → VendorCompany + drop organizationId + consumer cleanup

Scope:

Test surface: Updated existing vendor tests + new VendorMembership-aware fixtures.

Sequencing with the PR-A → PR-H spine work

PR-V0 → PR-V2 can run in parallel with PR-B → PR-G once PR-V0 docs land. Vendor has no entity FKs into Conversation/Tour/WorkOrder that block the spine ordering (the FK direction is the reverse — WO references Vendor today, post-rename VendorCompany).

PR-V3 + PR-D merge-conflict caveat. PR-V3 renames WorkOrder.assignedVendorIdassignedVendorCompanyId. PR-D flips WorkOrder.personId?: string: string. Both modify the WorkOrder interface in the same source file (src/lib/data/types.ts). They're behaviorally independent but share the same diff region. PR-V3 must rebase after PR-D to avoid the textual conflict. Sequence: PR-D merges → PR-V3 rebases on the new main → PR-V3 opens.


Drift guards

Guard What it catches Lives at
vendor-no-human-identity-fields.drift.test.ts Anyone reintroducing personId / contactName / phone / email / organizationId to VendorCompany. organizationId is blocked because reintroducing it would silently restore the per-PM partition shape PR-V3 explicitly removes. PR-V3
vendor-membership-required-fields.drift.test.ts New VendorMembership rows missing propertyIds[] ([] empty array still valid; undefined is not). PR-V1
Existing spine-stamp-construction-invariant.drift.test.ts VendorMembership's _unsafe_saveVendorMembership extends the ENTITIES list. PR-V1

Open items (parked, not blocking)


References