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.tsJSDoc, the classifier,ConversationThread, the drift guard, andCLAUDE.mdall 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:
- 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
issueTypeisgeneral_inquiry) — has no entity to point at, so the classifier force-fit it towork_order. It is not a work order. work_orderconflates 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
- Voice specialists (
agents/clara/lib/agent/specialists/registry.ts):maintenance_tenant,maintenance_handyman,lease_and_billing,leasing,renewal_inbound,renewal_outbound,turnover_intake,unknown_caller. This is the system's canonical "who handles this call" intent map. - Domain entities (
src/lib/data/types.ts):WorkOrder,Tour,Prospect,Renewal,Turnover,Lease. Conversation.issueType(VALID_ISSUE_TYPES, 13 maintenance playbooks) — too granular for a topic ("running_toilet" and "ac_not_cooling" are bothmaintenance); it stays a maintenance-intake sub-classification, NOT a topic.
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:
tenant_confirmation— stamped when aTenantConfirmation(claimed-tenant PM review) is captured.emergency— stamped whenescalate_to_humanconfirms a life-safety tier (high|gas_emergency), and carried by the emergency-relay trace on the origin conversation (emergency-flow). Red/danger tint; the one alert topic.
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)
- maintenance vs resident_inquiry — does something need to be fixed or
serviced? →
maintenance. Is it a question, info, or a small request? →resident_inquiry. (The light-bulb call →resident_inquiry. "The stovetop light is flickering, can someone fix it" →maintenance.) - leasing vs tour — the inquiry/pipeline is
leasing; a specific showing istour. - billing vs lease — money (rent/balance/payment) is
billing; the contract (terms/documents/policy) islease. - renewal vs lease — the renewal offer workflow is
renewal; the standing contract islease.
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. Thegeneral/otherjunk-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)
src/lib/data/types.ts—OutboundRegardingTypeunion + a JSDoc pointer to this ADR. Lockstep copy inagents/clara/lib/data/types.ts.src/lib/domain/conversations/message-topic-classifier.ts—TOPIC_VALUES(must equal the union) + the boundary + abstain rules in the prompt.src/lib/domain/conversations/topic-labels.ts— the shared topic-copy + color module:REGARDING_LABELS(plain-English display),RELATED_ENTITY_LABELS,TOPIC_TINTS(per-topic brand tint — one source for the thread's segment headers AND the /conversations Topic chips),conversationTopics(distinct set for the thread), andconversationTopicTypes(the recency-windowed set).ConversationThread.tsximportsTOPIC_TINTSfrom here (it used to define it).src/__tests__/conversation-topic-taxonomy.drift.test.ts— asserts the union,TOPIC_VALUES,REGARDING_LABELS, andTOPIC_TINTSare all in lockstep with the 9 topics here, so the four lists can never silently drift.CLAUDE.md— a short pointer in the conversations section.
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:
Conversation.topics: OutboundRegardingType[](@derived), written on every fullsaveConversationin both trees'_unsafe_saveConversation(the Vercel app + the Lambda inbound funnel) viaconversationTopicTypes(messages, { windowDays: CONVERSATION_TOPIC_WINDOW_DAYS })— free from the messages in hand, mirroring thehasInbounddenorm.- Recency-windowed (14 days), anchored on the conversation's LAST activity —
a long thread that turned from Billing (months ago) to Leasing + Maintenance
(recently) carries
[leasing, maintenance]; stale topics drop off. The window constant (CONVERSATION_TOPIC_WINDOW_DAYS = 14) lives intopic-labels.ts, one source for the writers + the backfill. (Distinct from the list's default recency filter of 30 days, which is a client-side row window — not the topic-chip window.) - Surfaced on
ConversationListItemSchema.topicsand the/api/conversationsrow mapping (...c, normalized?? []); the Topic filter's options self-scope to topics present on the recent rows. - Existing rows are re-derived (both
topicsandtopicsAllTime) byscripts/backfill-conversation-topics-alltime.ts(per-property, dry-run default,--mode=apply --confirm-table=<table>), which supersedes the retired windowed-only_backfill-conversation-topics.tsfork.
Consequences
- Positive: topics describe conversations honestly; the maintenance/work-order confusion is gone (topic vs record); the classifier can abstain; the taxonomy is grounded in the system's own specialist + entity vocabulary; one drift guard keeps the four code lists honest against this doc.
- Cost: a one-time data migration of already-tagged outbound messages, and a wider enum touch (both type trees + labels + tints + classifier + guard).
- Out of scope: splitting
maintenancebyissueType(the 13 playbooks stay a separate maintenance-intake sub-classification); a per-message intent field separate from topic (not needed —regardingIdcarries the specific entity).