ADR-0024 — WorkOrder.displayId is the canonical user-facing label

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

  1. Type. WorkOrder.displayId: string in src/lib/data/types.ts. The compiler refuses any creation path that doesn't stamp it.

  2. 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.

  3. Stamping. stampWorkOrderDisplayId in src/lib/domain/maintenance/stamp-display-id.ts is the only entry point that produces a displayId. It throws DisplayIdStampError with one of three causes (no_property, no_ticker, format_failed) when its inputs can't produce a valid label.

  4. Server-side propertyId derivation. Every WO creation path reads propertyId from 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.

  5. Tool result includes displayId. create_work_order returns {displayId, ...} so downstream surfaces (Pipeline Lab UI, agent activity log, Telegram dispatch) render the same canonical label the tenant sees.

URL contract

Drift guards (src/__tests__/wo-displayid-drift-guards.test.ts)

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.