0083 — regarding ties are topic-scoped, not always work-order refs

Context

Message.regardingType is the domain topic a message pertains to (ADR-0055, the 9/11-value taxonomy). The specific entity it points at, when one exists, is carried separately by regardingId / regardingLabel — e.g. topic maintenance + regardingLabel CAM-594. ADR-0055 states this plainly ("Topic ≠ entity") but stopped short of a per-topic map of WHICH entity table the id/label pair actually points at, because at the time only maintenance ever stamped one (PR #2320, the outbound-provenance work). Every consumer of regardingId/regardingLabel was written against that single-topic reality and, reasonably, assumed any regarding tie was a work-order reference.

That assumption broke on 2026-07-08: PR #3293 started stamping regardingId: tour.id / regardingLabel: tour.unitNumber on tour-topic messages, giving tour its own entity tie for the first time. Three independently coded consumers, each written under the WO-only assumption, hit the new data three different ways:

  1. src/lib/domain/conversations/wo-thread-filter.ts (messagePertainsToWorkOrder, listWorkOrderRefs) — consumed by ConversationThread.tsx's standalone conversation page. A tour-tied message made listWorkOrderRefs return the tour's raw UUID as a "work order," which grew a bogus "Work order" filter dropdown on prospect conversation threads that never had a work order at all. Prod repro: prospect c18dbad8-74b3-4a3a-926e-73b5b52fd02b, message regardingType='tour' regardingId='b7b0873a-…', no label — and the same component's detail panel (buildMessageDetails) rendered that bare UUID directly in a customer-visible "Related work order" row when no label was present.
  2. src/lib/domain/maintenance/wo-unified-timeline.ts (tiesOf / tiedToThisWo) — the WO detail page's forensic timeline. Not user-visible broken (its exclusion logic is kind-agnostic by luck — ANY tie that isn't this WO's is treated as "tied elsewhere" regardless of what it's tied to), but its inclusion check had the same latent WO-only assumption baked into a differently-shaped local helper, and would misclassify a tour tie as a WO ref if the id ever collided.
  3. src/lib/domain/digest/build-digest.ts (the CLI/agent digest builder) — checked regardingId and attachedToWorkOrderId against the WO's ref set but omitted regardingLabel entirely, a real gap independent of the tour regression: regardingLabel is the field most likely to carry a WO's human displayId, so the digest's handyman-thread filter could miss messages a human would obviously call "about this WO." It masked the gap with a content-substring fallback, which works only when the message text happens to repeat the displayId.

Three drifted, independently-maintained predicates for the same question ("is this message about a work order") is itself the underlying defect — the tour regression is what surfaced it, not the whole of it.

Decision

regardingId/regardingLabel are topic-scoped. Which entity table they point at is a function of regardingType, declared once as a total map:

// src/lib/domain/conversations/topic-labels.ts
export type RegardingEntityKind =
  | 'work_order' | 'prospect' | 'tour' | 'renewal'
  | 'turnover' | 'charge' | 'lease' | 'vendor';

export const REGARDING_ENTITY_KIND: Record<OutboundRegardingType, RegardingEntityKind | null> = {
  maintenance: 'work_order', leasing: 'prospect', tour: 'tour', renewal: 'renewal',
  turnover: 'turnover', billing: 'charge', lease: 'lease', resident_inquiry: null,
  vendor: 'vendor', tenant_confirmation: null, emergency: null,
};

null means the topic has no entity system at all — an id/label stamped under it is meaningless and must never be read as a real tie.

wo-thread-filter.ts#messageWorkOrderRefs is THE canonical WO-tie predicate. Every consumer that needs "is this message about a work order" imports it rather than hand-rolling an id/label check:

type RegardingFields = Pick<Message, 'regardingType' | 'regardingId' | 'regardingLabel' | 'attachedToWorkOrderId'>;
function messageWorkOrderRefs(m: RegardingFields): string[]

The UI never renders a bare regardingId. ConversationThread.tsx's buildMessageDetails used to render regardingLabel ?? regardingId in the "Related entity" row — an internal UUID leaking into a customer-visible panel whenever only the id was set (exactly the tour regression's symptom). The row now renders only when regardingLabel is present; a label-less tie simply omits the row (the "Topic" row alone still shows).

Voice-created WO ties gain a displayId. The voice call-ended route's post-call sweep only returns { workOrderId, category } — no displayId — so the back-stamp it triggers (backstampWorkOrderReportSegment) previously stamped regardingId with no regardingLabel, which (combined with the old id-fallback above) is exactly the failure mode this ADR closes. The route now best-effort resolves the WO's displayId (getWorkOrder, try/catch, never blocks the stamp) before calling back-stamp.

Defensive hardening on an unrelated any-typed write. While auditing every site that assigns Message.regardingId/Conversation.workOrderId, agents/clara/lib/agent/conversation-manager.ts's tool-result handler was found assigning conversation.workOrderId = parsedResult.workOrderId where parsedResult is any (raw JSON.parse of a tool's string result), gated only on .success === true — no proof .workOrderId is actually a string. This is unguarded the same way the sibling extractWoTieFromToolResult (wo-report-backstamp.ts) is guarded, so it now applies the identical typeof x === 'string' && x check before assigning. This is preventive hardening, not a fix for an observed incident — a full production scan found no corrupted rows; the earlier read of a corrupted-looking row was a misread of a legitimate DynamoDB NULL attribute by a display script, not a real bad write.

Consequences

Alternatives considered