0083 — regarding ties are topic-scoped, not always work-order refs
- Status: Accepted; amended by ADR-0095 (2026-07-15) — the pointer gains an explicit
Message.regardingKind, so consumers check the kind directly instead of inferring it from the topic;REGARDING_ENTITY_KINDbecomes the write-time default and is demoted to migration-only once the backfill converges. - Date: 2026-07-09
- Deciders: Fede
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:
src/lib/domain/conversations/wo-thread-filter.ts(messagePertainsToWorkOrder,listWorkOrderRefs) — consumed byConversationThread.tsx's standalone conversation page. A tour-tied message madelistWorkOrderRefsreturn 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: prospectc18dbad8-74b3-4a3a-926e-73b5b52fd02b, messageregardingType='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.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.src/lib/domain/digest/build-digest.ts(the CLI/agent digest builder) — checkedregardingIdandattachedToWorkOrderIdagainst the WO's ref set but omittedregardingLabelentirely, a real gap independent of the tour regression:regardingLabelis the field most likely to carry a WO's humandisplayId, 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[]
regardingId/regardingLabelcount only whenREGARDING_ENTITY_KIND[m.regardingType] === 'work_order', orm.regardingTypeisundefined. Theundefinedcarve-out is a legacy rule, not a loophole: every writer that stamps an id/label also stamps a type, except history that predates ADR-0055 entirely — and the taxonomy's only entity-stamping predecessor was work-order-only. An untyped row carrying an id/label can therefore only be a legacy WO tie, and treating it as WO-kind keeps that old history working instead of silently orphaning it.attachedToWorkOrderIdcounts regardless of topic — it is WO-by-definition (its only writer isbindPhotoToWorkOrder, a photo-binding field that never meant anything else).wo-unified-timeline.tsandbuild-digest.tsboth now call this function instead of their own copies.wo-unified-timeline.tsadditionally needs a kind-agnostic "is this message tied to some entity" check for its exclusion/window-boundary logic (a tour-tied message must still be excluded from a WO's story timeline — just recognized as "tied elsewhere," not matched as a WO ref) — that stays a separate local helper (entityRefsOf, renamed from the oldtiesOfto make the split explicit), deliberately NOT routed throughmessageWorkOrderRefs.build-digest.ts's handyman-message filter now matches onregardingLabeltoo (closing the gap named above) via the shared predicate, on top of the unchanged content-substring fallback.
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
- Positive: one predicate, one entity-kind map, no more drift between the
three consumers. A future topic that grows its own entity tie (the way
tourjust did) is a one-line addition toREGARDING_ENTITY_KIND— tsc's total-Recordcheck forces the decision, and every consumer picks it up automatically instead of needing a separate audit. The customer-facing thread panel can no longer leak an internal id. - Cost:
wo-unified-timeline.tsnow carries two conceptually distinct helpers (entityRefsOfkind-agnostic,tiedToThisWokind-gated) instead of one — a reader has to understand the inclusion/exclusion asymmetry the file header now documents explicitly. - Follow-up (explicitly out of scope here):
- Converging the three parallel "what kind of thing is this" enums —
OutboundRegardingType(this ADR),EntityActivityEvent.entityType, andEvidenceEntityType— into one vocabulary. They currently overlap but are not identical, and reconciling them is a bigger migration than this fix. - The two entity-deep-link resolvers,
conversation-entity-links.tsandresolveInsightDeepLink, still each maintain their own per-entity routing logic; unifying them againstREGARDING_ENTITY_KINDis future work. - Per-topic entity deep links for
tourandrenewalrows in the conversation thread's "Related entity" row — today onlymaintenanceresolves anhref(workOrderHref); a tour/renewal row shows its label as plain text with no link.
- Converging the three parallel "what kind of thing is this" enums —
Alternatives considered
- Keep the three predicates separate, patch each one's WO-only assumption
individually. Rejected — this is exactly how the drift happened the first
time (
build-digest.ts's missingregardingLabelmatch sat unnoticed for an unrelated reason); three copies of the same semantic rule will drift again the next time a topic gains an entity tie. - Give every topic's entity a uniform
regardingRef: { kind, id, label }shape instead of scoping the existing flat fields. More correct long-term, but a breaking change to theMessageschema and every writer (dozens, perCONVERSATION_WRITERS) — far larger than this fix's blast radius. Left for a future ADR if the entity-kind map itself starts feeling cramped.