ADR-0033 — VendorCompany + VendorMembership: decoupling the vendor row
- Status: Accepted — implemented via PR-V1 (#1425), PR-V2 (#1429), PR-V3 (#1437); merged 2026-05-26.
- Date: 2026-05-25
- Deciders: Gera (audio brainstorm) + Smith agent (architectural pushback at sess_32f807ec)
- Supersedes: none
- Extends: ADR-0018 (Person identity model), ADR-0020 (Person as universal human spine), ADR-0032 (Spine-stamp as construction invariant)
- Companion planning doc:
docs/planning/spine-redesign/spine-migration-plan.md
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:
- VendorCompany (rename of today's
Vendor) — the counterparty. Carries W-9, COI, insurance, payment terms, AppFolio mapping. No human-identity fields. - Person (unchanged) — the human. On the spine via the Person/IdentityClaim model. (Implementation note: at ADR-authoring time the human was stamped by a
vendor-spine-stamp.tsadapter; that adapter was deleted in PR-V3 since VendorCompany carries nopersonId— the human now links exclusively through VendorMembership viavendor-membership-spine-stamp.ts.) - VendorMembership (extension of today's
VendorContact) — the join row. CarriespersonId, organizationId, vendorCompanyId, role, propertyIds[], startedAt, endedAt?. MirrorsTenantOccupancy's shape: Person + scope + active-window.
Mirrors Tenant's pattern: Tenant = Person + TenantOccupancy ⇒ Vendor = 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:
- Multi-contact impossible. Mile High's owner is Carlos; their dispatcher is Lisa. The 1:1
Vendor → Personconstraint 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. - Cross-PM vendor reuse impossible. If JP-Co AND a second PM customer both contract with Mile High, we have two
Vendorrows for the same counterparty — one perorganizationId. The spine work in PR-G partially decoupled the human, but the company stays per-PM. - 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-bearing — Vendor.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:
Organizationis the SaaS-customer root. Everypropflow-prodpartition keys onorganizationIdto 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 withOrganization.type='property_management'and adding type filters to every reader, or (b) introduce a second org-namespace and re-derive every claim-scoping rule.- 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.
- 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:
- 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. - Every
vendor.organizationIdreader breaks at PR-V3. Anywhere a current caller doesvendor.organizationId(queries, scoping checks, etc) must be updated to resolve org via the relevant Membership — typically viagetVendorMembership(vendorCompanyId, organizationId)for "is this VendorCompany engaged with this PM." - AppFolio
af.vendorIdmapping is per-PM today. A single AppFolio account belongs to one PM; the mapping stays per-PM. Post-rename,VendorCompany.af.vendorIdbecomes aMap<organizationId, vendorId>OR (cleaner) moves toVendorMembership.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:
WorkOrder.assignedVendorCompanyId: string— the counterparty. The specific contact is not stored on the WO; it resolves at dispatch time via the VendorCompany's default contact (the membership withrole: 'owner', falling back torole: 'dispatcher', then any active membership) throughresolveVendorContact.
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 (everyresolveVendorContact(...)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
- Multi-contact vendors for free. N VendorMembership rows per VendorCompany; each Person is canonically identified.
- Cross-PM vendor reuse. One VendorCompany row per real-world counterparty. PR-V3 deduplicates today's per-PM Vendor rows where they exist.
- Person turnover modeled cleanly. Carlos leaves Mile High → set his Membership.endedAt; mint a new Membership at Front Range. History preserved.
- Documentation lives in one place. W-9 / COI on VendorCompany, not duplicated across contacts.
- AppFolio mapping stable across the migration.
af.vendorIdstays on VendorCompany; no rewiring of the L4 sync path. - Mirrors a proven pattern. Tenant's Person + TenantOccupancy shape has worked through PR-G and the construction-invariant flip in PR-A. Reusing it for Vendor lowers the cognitive load.
Negative / cost
- Migration touches multiple consumer surfaces.
/vendorsUI,/vendor-quotes, WO routing assignment, AppFolio sync writer. Each PR in the V1/V2/V3 sequence carries one surface's worth of churn. Dropped before merge — shipped dead (no writer/reader), removed per the no-speculative-abstraction rule. See the Assignment-semantics revision note above.assignedVendorMembershipIdadds optional FK to WorkOrder.- AppFolio sync precedence rule needed. When AppFolio's vendor record has one contact email/phone and PropFlow has three Memberships, which one mirrors back? Default: the
owner-role Membership; tie-break by most recentstartedAt. Documented in PR-V3. - Drift guard needed. A new test pinning that
VendorCompanydoesn't reintroduce human-identity fields. Mirrorslegacy-field-elimination.drift.test.ts's shape.
Neutral
- The construction-invariant work (ADR-0032) is unaffected. VendorMembership picks up the same
_unsafe_saveVendorMembership+ drift guard treatment as Tenant/Conversation/Tour/WO. PR-A's primitives apply directly. Vendor.personId?field drops out implicitly. Once VendorMembership owns the Person link,Vendor.personIdis dead. PR-V3 deletes the field as part of the rename.
Rollout
Three PRs after the planning + ADR (this PR-V0):
PR-V1 — extend VendorContact → VendorMembership semantics
Scope:
- Rename
VendorContacttype →VendorMembership(type-only; field shape stays compatible). - Rename
PersonContext.activeVendorContacts: VendorContact[]→activeVendorMemberships: VendorMembership[](types.ts:5290). The read-shape is stubbed empty-array today, so no consumer wiring needed beyond the type-name rename. - Add
propertyIds: string[],startedAt: string,endedAt?: string,notifyOnRoutineUpdates?: boolean,availability?to the interface. DocumentpropertyIds: []as the "all properties in this org" sentinel via a JSDoc on the field. - Add
src/lib/domain/identity/vendor-membership-spine-stamp.tsadapter (signal translation for Person stamping via VendorMembership creation). - No data migration yet — existing VendorContact rows compile against the new type with the added fields undefined.
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:
- Drain script
scripts/drain-vendor-contact-migration.ts— for every Vendor row withpersonIdset, mint a VendorMembership row carrying that link +role: 'owner'(default) + the Vendor'snotifyOnRoutineUpdates+availability. Idempotent re-run. Update(Superseded as implemented: PR-V1 introduced a newvendor-spine-stamp.tsto write VendorMembership instead of stamping the deprecatedVendor.personId.vendor-membership-spine-stamp.tsadapter for the join row, and PR-V3 deletedvendor-spine-stamp.tsentirely — VendorCompany has nopersonIdto stamp. The "soft-deprecated, read-tolerantVendor.personId" approach below was likewise superseded by PR-V3's hard drop of all human-identity fields, pinned byvendor-no-human-identity-fields.drift.test.ts.)- Update reads —
getVendorById/listVendorsno longer surfacecontactName/phone/emailfrom the Vendor row; consumers resolve via the primary VendorMembership. - The Vendor type still carries
personId?,contactName, etc. — soft-deprecated but read-tolerant. Drift guard counts new Vendor rows with these fields populated; CI fails on new writes that hit them.
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:
- Rename
Vendortype →VendorCompanyrepository-wide. - Drop
Vendor.organizationIdfrom the type. Today's required field becomes absent — per-PM scoping moves toVendorMembership.organizationId. Everyvendor.organizationIdreader site must be updated to resolve via Membership (typicallygetVendorMembership(vendorCompanyId, organizationId)or a Membership-scoped query). PR-V2's drain dedupes per-PM duplicate Vendor rows into a single VendorCompany row + multiple Memberships. - Drop
Vendor.personId,contactName,phone,email,availability,notifyOnRoutineUpdatesfrom the type. - Rename
WorkOrder.assignedVendorId→assignedVendorCompanyId(the originally-specced optionalassignedVendorMembershipId?was dropped before merge — see the Assignment-semantics revision note). Rebase requirement: this PR rebases after PR-D to avoid a textual conflict on the WorkOrder type interface (PR-D flipsWorkOrder.personId?→ required in the same diff region). - Resolve AppFolio mapping shape —
Vendor.af.vendorId: numberis per-PM today. PR-V3 picks one of two shapes (documented before code lands): (a)VendorCompany.af: Map<organizationId, vendorId>keyed by PM, or (b)VendorMembership.af.vendorIdmoved entirely to the membership. Default proposal is (b) — the AppFolio mapping is fundamentally "this PM's AppFolio knows this vendor as id X," which is membership-scoped. - Update
/vendorsUI — render the company + list of memberships with role/property scope. Per-membership "set primary contact" toggle. - Update vendor-quotes pipeline —
Quote.vendorId→Quote.vendorCompanyId(type rename; same id values). - Update WO routing — vendor picker UI now lets PM pick "Mile High Plumbing" then optionally "Carlos (owner)" / "Lisa (dispatcher)" / "any tech."
- Update AppFolio sync writer — mirrors VendorCompany only; the AF contact field maps to the
owner-role Membership (precedence rule documented here). - Update entity-model.md §"All entities — classification index" + diagrams to use the post-rename names (today's
Vendorrows in the diagrams + index table becomeVendorCompany; the "Why Vendor is a root" paragraph in §"The spine pattern" is updated to reflect the new cross-org model). - Drift guard added:
vendor-no-human-identity-fields.drift.test.ts— fails CI if anyone tries to addpersonId/contactName/phone/email/organizationIdback to the VendorCompany interface.organizationIdis in the blocked-fields set because reintroducing it would silently restore the per-PM partition shape this PR explicitly removes.
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.assignedVendorId → assignedVendorCompanyId. 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)
- Bulk import shape. Today PMs upload CSVs of vendors with contact + company in one row. Post-migration, the import splits each row into 1 VendorCompany + 1 VendorMembership. UX work needed in
/vendors/import— tracked separately. - Reporting on "vendor activity." Today's reports query
Vendorby id; post-rename they queryVendorCompany. The behavioral fields (WO count, response time) belong on VendorCompany or VendorMembership? Most analytics are at the company level → VendorCompany. Per-contact behavioral fields land on Membership. Decided in PR-V3. - Voice agent vendor lookup. Clara's
get_available_vendorstool returns Vendor today. Post-rename it returns VendorCompany + the available (i.e., currently-active per Membership) contacts. Tool catalog update in PR-V3.
References
- Audio brainstorm:
~/.claude/scripts/propflow-status/state/transcripts/audio_20260525_132531.txt - Smith agent response:
~/.claude/scripts/propflow-status/logs/session-sess_32f807ec.jsonl(text + TTS atstate/tts/01342e7f09b8.ogg) - Planning doc:
docs/planning/spine-redesign/spine-migration-plan.md - ADR-0018: Person identity model
- ADR-0020: Person as universal human spine
- ADR-0032: Spine-stamp as construction invariant
- Today's Vendor type:
src/lib/data/types.ts:897-945 - Today's VendorContact type:
src/lib/data/types.ts:5269-5284