0142 — One vendor card per AppFolio account: vendor_appfolio_<account>_<N>
- Status: Accepted
- Date: 2026-10-14
- Deciders: Gera (ruling, 2026-10-14, relayed by the coordinating session after the take-over dry run below). Gera answered alone.
- Supersedes: the bare
vendor_appfolio_<N>canonical id (canonicalVendorId,src/lib/domain/vendors/vendor-write-invariants.ts) for NEW cards; and, for AppFolio vendors, the "card id is MINTED and tied to nothing external" half of the p8-identity design recorded insrc/lib/domain/vendors/vendor-identity.ts(see Relation to the p8-identity design). - Related: ADR-0033 (card vs membership), ADR-0039 (AppFolio owns vendor facts), PR #19686 (the adopt-lane fix), PR #19696 (the dry-run repair report).
Context
A vendor card's id was the bare AppFolio vendor number, vendor_appfolio_<N>. AppFolio hands
numbers out per database, and three customer databases (Western Slope, JP&Co, Situs) reuse the
same numbers, so each number named one shared card for three unrelated businesses.
On 2026-10-08 Situs's first vendor sync met cards with no account stamp and ADOPTED them: it renamed
each card to Situs's vendor and stamped it situsgroup. Western Slope's and JP&Co's own syncs then
refused those cards as foreign. Measured read-only on prod on 2026-10-14
(scripts/vendors/repair-vendor-account-takeover.ts):
- 1,151 cards taken over: 368 linked to Western Slope, 783 linked to JP&Co and its sandbox company
(both on the
jpcodatabase). Western Slope and JP&Co share zero cards. - The contact person differs on every one of the 1,151 links, and every affected number is present, under a different business name, in the company's own AppFolio vendor list.
- Riding on those cards: 3,085 memberships, 598 work orders (17 open), 783 contact snapshots.
#19686 stopped new take-overs. Repairing the existing ones was blocked on one question: with one id per number, giving Western Slope its vendor back takes the card from Situs.
Decision
A vendor card mirrors one vendor in one AppFolio database. Its canonical id is
vendor_appfolio_<account>_<N>, where <account> is the normalized database label
(normalizeVendorAccountId) and <N> the AppFolio vendor number.
- New cards take the account-qualified id.
canonicalVendorId(accountId, afVendorId)is the one function that spells it; the new-card invariant and the resource-layer guard ask it. - Existing cards keep their ids. Every lookup by id still resolves a legacy
vendor_appfolio_<N>card, and writes to an existing legacy card stay legal. Nothing is renamed in place. No feature flag: the new shape is the only shape for a new card from the merge of the implementing change (until thencanonicalVendorIdstill takes only the number). - Two companies on the SAME database share one card (JP&Co and its sandbox company, both
jpco). The per-account card is shared by design inside an account, as it is today. - The taken-over cards stay Situs's. Western Slope and JP&Co get new cards built from their own AppFolio vendor lists, and their links, work orders and building vendor lists move onto them. A repair never un-stamps a card.
- A FOREIGN match stays a refusal until the repair has run. Teaching the sync to mint its own card on a FOREIGN match would create the 1,151 cards and their memberships unattended, on the next hourly tick, which is a prod backfill only a founder may approve. Whether FOREIGN later mints its own card is a follow-up decision.
Consequences
- Repair (
repair-vendor-account-takeover.ts --apply): per affected company, create the new card (name from the company's own roster;tradecarried over; a rename carried over only if it predates 2026-10-08), move memberships, engagements, building rosters, crew lists and turnover defaults through the vendor-merge primitives, and re-point work orders. Every changed row is journalled with its before-state. Onpropflow-prodthe apply is a founder's press (a--founder-runflag, added with the write path; today the script's--applyonly refuses), and Gera approves the exact run with fresh dry-run counts. - Contact details are re-read, not copied. The old card's contact snapshot may be Situs's; the new
card's snapshot comes from the company's own AppFolio through the normal client (the
per-vendor
syncVendorFromPmsfor that company; the unattendedvendor_contactsweep was retired 2026-10-19). - The 61 unstamped cards (same 2026-10-14 dry run:
stamp backfill: 61 unstamped af-linked cards) are stamped by the existing backfill (stamp-vendor-accounts.ts), as a separate, separately approved step. - Mixed id shapes persist until every legacy card is migrated. Code must not parse meaning out
of an id; it reads
af.accountIdandaf.vendorId. Migrating the remaining legacy cards is not required by this decision. isCanonicalVendorId-style checks recognize both shapes where the question is "does this id name an AppFolio vendor by construction" (the refusal direction).
Relation to the p8-identity design
src/lib/domain/vendors/vendor-identity.ts records a design in which the card id is minted and the
AppFolio number is only a claim row keyed pms#appfolio#<account>#<N>, and ruling f5aa79286
keeps one identity across two databases when a reconciliation proves they are the same business.
As of this ADR that design is dark: no sync path calls engageVendor or mintVendorId. This
decision replaces the minted id for AppFolio vendors with a deterministic one built from the SAME
tuple the claim key names, so a claim head and the card id agree by construction.
The p8 code that contradicts this is changed with the implementation, not left to collide.
assertVendorCardIdentity in vendor-identity.ts requires a new card to carry a minted id and
treats vendor_appfolio_<n> as the forbidden shape; vendor_appfolio_<account>_<N> fails
isMintedVendorId. As it stands, engageVendor never takes a caller's card id: on a mint
decision it always uses mintVendorId(), so wiring it for AppFolio vendors unchanged would quietly
mint ULID cards instead of the ids this decision requires. Two edits keep it consistent:
- With the implementing change: the assertion is narrowed so a new card is accepted with
EITHER a minted id OR the canonical
vendor_appfolio_<account>_<N>named by one of its ownpmsclaims (the barevendor_appfolio_<N>stays refused), and that module's header comment points here. - Whenever
engageVendoris wired for AppFolio vendors (not before): its mint branch must choosecanonicalVendorId(account, N)when the input carries apms#appfolioclaim, and the assertion then accepts only that id for such a card. Until then the door stays mint-only and dark. Cross-database "same real business" reconciliation is not a default any more; it would be a deliberate merge of two per-account cards through the existing merge flow.
Alternatives considered
- One shared card per number, with a name per company (option 2). Rejected: the card's
trade, specialties and contact snapshot would stay one set of facts for two or three different businesses, every place that speaks or printscompany(the voice agent'svendor_name, notice emails) would have to read a per-company overlay, and the number would remain a false identity that the next writer can collide on. It only relabels the collision. - Keep the bare id and refuse forever. Leaves Western Slope and JP&Co without their vendors.
- Minted ids with claim rows (p8-identity). Sound, but dark and larger; option 1 reaches the same per-account identity with a deterministic id and no new lookup on the sync path.