ADR-0055 — Conversation message topic taxonomy

Status: Accepted (2026-06-13) Supersedes the ad-hoc 6-value OutboundRegardingType enum.

This ADR is the single source of truth for Message.regardingType. Any change to the topic list, the boundary rules, or the labels/tints starts here — the code points back to this file (types.ts JSDoc, the classifier, ConversationThread, the drift guard, and CLAUDE.md all reference it).


Context

Every conversation message can carry a regardingType — "what is this message about." It was introduced for outbound provenance ("this Clara send is about work order CAM-594") with a 6-value entity-name enum:

work_order | renewal | tour | turnover | lease | prospect

Two problems surfaced when the "maestro" classifier (ADR-adjacent, the message-topic-classifier) began backfilling the field on inbound / conversational messages:

  1. The values are entity names, not topics. A real thread — tenant calls asking "what light bulb does the stovetop use?", office says "we stock spares, we'll drop one off" (the conversation's own issueType is general_inquiry) — has no entity to point at, so the classifier force-fit it to work_order. It is not a work order.
  2. work_order conflates the topic with the record. A maintenance conversation and a formal WorkOrder record are different things; using the same token for both is confusing.

We want a topic vocabulary that (a) describes what a conversation is about at the domain level, (b) is granular enough to keep distinct domains apart (a tour is not a renewal), (c) is grounded in the system's existing vocabulary — the production voice specialists and the domain entity types — rather than invented, and (d) lets the classifier abstain (leave a message untagged) when nothing fits, instead of over-tagging.

Existing vocabularies this taxonomy is grounded in


Decision

Message.regardingType is the domain topic the message pertains to. The specific entity it points at, when one exists, is carried separately by regardingId / regardingLabel (e.g. topic maintenance + regardingLabel CAM-594). Topic ≠ entity.

The 9 topics

The taxonomy is a living list — it grows as real conversations surface gaps a calibration batch can't ignore (that's how vendor was added — see below).

Topic Grounded in Lands here
maintenance maintenance_* specialists + WorkOrder a repair or service the unit needs; ties to a WorkOrder when one exists
leasing leasing specialist + Prospect prospect inquiries, availability, pricing, applications
tour Tour entity + tour router path scheduling / confirming a property showing — distinct from leasing
renewal renewal_* specialists + Renewal renewal offers, term changes, accept/decline
turnover turnover_intake + Turnover move-out / move-in, condition capture, unit prep
billing lease_and_billing specialist rent, balance, payments, late fees, deposits
lease Lease entity + lease_and_billing lease terms, documents, policies, occupancy / roommate changes
resident_inquiry the resident_services concept (the gap) a tenant question or small request that is not a repair: packages, amenities, parking, hours, building/community questions, "what TYPE of bulb does this take"
vendor the inbound router's vendor destination (handleVendorMessage) + the maintenance_handyman specialist a vendor / supplier / contractor / delivery person coordinating (deliveries, scheduling, confirming a job) — a third party, not a tenant/prospect. Added 2026-06-14: the calibration batch caught delivery calls being force-fit to turnover.

Structural (non-classifier) topics

Two topics render + filter exactly like the nine above but are never assigned by the message-text classifier — they are event-stamped by a structural signal and kept sticky across every message-based re-derivation (STICKY_TOPICS), so a recompute never drops them:

Both are deliberately absent from TOPIC_VALUES (the classifier's candidate list) — conversation-topic-taxonomy.drift.test.ts pins exactly this split (TOPIC_VALUES == the enum minus the structural topics).

Boundary rules (how the classifier decides)

The abstain rule (no general/other bucket)

SUPERSEDED by ADR-0095 (2026-07-15): the classifier becomes forced-choice with an honest none ("No topic") catch-all + self-describing dispositions. The general/other junk-drawer ban below STANDS (and is now drift-test-enforced); only the abstain half is replaced. The rest of this ADR (taxonomy, boundary rules, labels) remains authoritative.

A message is tagged only when it clearly pertains to one of the 9 topics. A pure greeting / "thanks" / "ok" folds into the active topic of its surrounding run (unchanged from the prior rule). A thread that genuinely has no domain topic is left untagged — there is deliberately no general or other catch-all, because that becomes a junk drawer. With resident_inquiry as a real bucket, untagged becomes rare.


Migration from the old enum

regardingType is already persisted on outbound messages under the old names. The rename is a one-time, scriptable data migration (entity tie regardingId unchanged):

Old value New value
work_order maintenance
prospect leasing
renewal / tour / turnover / lease unchanged
(new) billing, resident_inquiry, vendor

scripts/_migrate-regarding-topics.ts performs the flip (dry-run default, --apply --confirm-table). See "Where this is enforced" for the drift guard.


Where this is enforced (the documentation is known throughout the codebase)

Conversation-level denormalization — Conversation.topics

Topics are a per-message field, but the message-less /conversations list needs them per conversation (a Topic column + multi-filter, plus entry-point presets: Maintenance → [maintenance], Leasing → [leasing, tour]). Re-reading messages for every list row is too expensive, so the distinct topic set is denormalized onto the conversation meta row:


Consequences