0142 — One vendor card per AppFolio account: vendor_appfolio_<account>_<N>

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):

#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.

  1. 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.
  2. 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 then canonicalVendorId still takes only the number).
  3. 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.
  4. 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.
  5. 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

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:

  1. 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 own pms claims (the bare vendor_appfolio_<N> stays refused), and that module's header comment points here.
  2. Whenever engageVendor is wired for AppFolio vendors (not before): its mint branch must choose canonicalVendorId(account, N) when the input carries a pms#appfolio claim, 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