0147 — One address index for every channel
- Status: Accepted
- Date: 2026-10-19
- Deciders: Gera, Fede (team thread 2026-10-18; Fede: "a proper deep dive and re architecture as it's a core system flaw for distributed apartment buildings with different addresses"; Gera, 2026-10-19: learned aliases need confirmation first; duplicates match the building's unit)
- Plan: ws-emergency-rearchitecture, workstream B
Context
Five separate code paths match a spoken or written address to a home: phone maintenance calls, prospect calls, texts, email and leads, and staff tools. They use three tokenizers and five loads of the company's homes, and only some read a unit's own street. A unit whose street differs from its building's (Western Slope: 57 units in 4 buildings; Situs: 246 units in 13 buildings) is findable by email but, today, by text in only 6 of 63 Western Slope cases. #20946 patched the phone maintenance path alone, two hours after an emergency call was missed because of it. The unit street is saved only as a side effect of the leasing listings sync, so JPCO has none at all. Numbers come from the read-only scoping in the plan (prod reads, 2026-10-19).
An alias store (PROPALIAS#, src/lib/data/dynamo/property-aliases.ts) already supports street and
nearby-street aliases. It is nearly empty, and only the name matcher reads it.
Decision
- One index per company, one resolver.
src/lib/domain/properties/address-index/builds an index from building addresses, unit-level streets, unit labels, address ranges and lists, and aliases.resolveAddress(index, input, { mode, knownUnitId?, dialledPropertyId? })is the only way any channel matches an address to a home.modeisspoken(voice, where the model has extracted the street and unit) orprose(texts, email, leads and staff tools, where the address is found in free text). - It never answers a flat no. It returns a best guess, a confidence (exact, likely, close, guess,
off_roster), ranked candidates, and the next step for the conversation (confirm, did-you-mean,
ask-unit).
off_rostermeans the address is not one of the company's homes under any field: the caller records the address as spoken and continues (a callback note, or the emergency path), never a dead end. Callers still finish on a property and unit for a work order. An emergency never waits on this resolver: it escalates whatever the confidence (ADR-0145, in review in #21131). Onlyexactmay file a work order without a person confirming.likely,closeandguesscarry their candidates to Clara's confirm or did-you-mean step, or to a person, before anything is filed. - The search order is fixed: property name or alias, then exact key, then street number or a
street without its type or direction, then nearby or sister streets, then a close spelling
(never on the house number), then geocoding to the nearest home. The geocode step has a distance
cutoff (a set number of meters, chosen and tested in its slice): beyond it, the answer is
off_roster, not aguess, so an address that truly isn't one of the company's homes is never pinned to the nearest one. - Aliases are produced at ingest. A propflow-ingest step builds aliases from AppFolio unit streets,
from streets people actually used in conversations, guest cards and work orders, and from nearby
streets.
pmsandgeoaliases are active immediately.learnedaliases aresuggesteduntil a person confirms them. The same step flags duplicate or inconsistent building names, and one address claimed by two records, for the customer to resolve. - Unit streets are synced for every company through the PMS adapter in the regular property/unit refresh, and cleared when the PMS stops reporting them. Domain code does not read AppFolio fields directly.
- Ambiguity is never resolved by accident. When one address belongs both to a unit inside a building and to a standalone home record (Ember Estates, 25 units; the DU portfolio, 15 units), the building's unit wins (Gera, 2026-10-19). Records the PMS marks inactive leave the index through the PMS's own inactive flag, never by matching text such as "DO NOT USE". The duplicates are flagged to the customer at onboarding (decision 4). Any other two-record collision returns "likely" with both candidates until a rule is recorded. A "likely" with two candidates never files a work order on its own (see decision 2): it carries both candidates to a person.
- A build guard fails any matching of homes outside
address-index/. - Phone maintenance moves first. The first slice moves the phone maintenance path (the one that missed the 2026-10-18 emergency) onto the index, and the build guard lands in the second. Email, which already reads unit streets, moves later. JPCO's unit streets (0 of 232 stamped today) are filled when the stamp moves into the PMS adapter (count from the same plan scoping as the Context).
Consequences
- Each existing matcher is retired in the PR that moves its last caller (maintenance and leasing
resolveSpokenHome,companyHomeRoster, the address half ofmatchRequestedHome). - Fixes reach every channel at once instead of one path at a time.
Alternatives considered
- Patch each matcher as misses are found. Rejected: that is how #20946 fixed one path of five.
- Let the model guess from a full roster in the prompt. Rejected: not reproducible or testable, and wrong guesses are silent. Geocoding is used only as the last, labeled step.