ADR-0024 — WorkOrder.displayId is the canonical user-facing label
- Status: Active
- Date: 2026-05-10
Decision
WorkOrder.displayId is the only string PropFlow shows a human for a work order. Tenant SMS, vendor Telegram, operator dashboard, log lines, Pipeline Lab output, URLs — every surface reads wo.displayId directly. It's typed string (required, never optional).
Concrete shape
Type.
WorkOrder.displayId: stringinsrc/lib/data/types.ts. The compiler refuses any creation path that doesn't stamp it.Format.
<Property.ticker>-<af.workOrderId>(e.g.,CAM-604). Property ticker per ADR-0022; the numeric portion is the PMS's identifier per ADR-0030.Stamping.
stampWorkOrderDisplayIdinsrc/lib/domain/maintenance/stamp-display-id.tsis the only entry point that produces adisplayId. It throwsDisplayIdStampErrorwith one of three causes (no_property,no_ticker,format_failed) when its inputs can't produce a valid label.Server-side
propertyIdderivation. Every WO creation path readspropertyIdfrom verified server context (the resolved conversation, the resolved tenant), never from LLM-supplied input. The LLM never supplies internal IDs; identity flows from verified conversation/session context.Tool result includes
displayId.create_work_orderreturns{displayId, ...}so downstream surfaces (Pipeline Lab UI, agent activity log, Telegram dispatch) render the same canonical label the tenant sees.
URL contract
- Public route:
/maintenance/[displayId]. Visiting/maintenance/<row-id>returns 404. - API:
/api/work-orders/[displayId]/...resolves strictly viagetWorkOrderByDisplayId. All 7 handlers (GET, PATCH, DELETE on the root + 6 action sub-routes) share the same contract. Internal writes operate onworkOrder.id(the row id stays private to the data layer). - Every link generator that has a
WorkOrderin scope emitswo.displayId. The 3 callsites that only carry a foreign key (ConversationThread, the email-ingestion admin page, the turnover detail page) readworkOrderDisplayIdjoined at API read time — view-only, never persisted.
Drift guards (src/__tests__/wo-displayid-drift-guards.test.ts)
WorkOrder.displayIdis declared with no?insrc/lib/data/types.ts.- No source file uses zero-padded format
padStart(6, '0')for a WO label. - No
/maintenance/${X.id}or${X.workOrderId}template literals — must bedisplayId-suffixed identifiers. create-work-order.tsdoes not readinput.property_idat runtime (comment-stripping grep so the docstring describing the contract is allowed).
Consequences
One source of truth for the WO label. Every operator surface (SMS, email subject, Telegram, dashboard, Pipeline Lab) renders the same string the tenant sees.
Server-side identity resolution generalizes. The same principle applies to tenant_id, property_id, unit_id, etc.: the LLM never supplies internal IDs; identity flows from verified conversation/session context.
propertyTicker is required at the sync-writer entry point. A property without a ticker fails loud (DisplayIdStampError no_ticker) and points at scripts/backfill-property-tickers.ts.
Alternatives considered
Optional displayId type with runtime defaults. Rejected — optionality at the type level signals null is a valid runtime state, which leaks into renderer code that handles null gracefully, which produces tenant-visible fallbacks.
Auto-generate the ticker at WO creation if the property doesn't have one. Rejected as an implicit fallback. Property tickers are required by ADR-0022's backfill; a property without one is a data invariant violation. The fail-loud error is the correct signal.
Keep an alternate WO label for internal logging (CloudWatch / structured logs). Rejected — dual surfaces invite copy-paste reintroduction of legacy formats. One label, everywhere.