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

AMENDED 2026-10-28: the label is unchanged, but the work-order page now NAMES it (Gera, on a work order whose bare label read like an AppFolio number). The detail page's line under the title reads PropFlow ref <displayId>, followed by the PMS's own number (AppFolio WO #<service request>) or Not in AppFolio yet with the reason behind an (i). displayId is still the one label PropFlow shows and the only key in the path. The new text says which number is ours and which is the PMS's, and adds no second label of ours. It is built in one place, workOrderLabelLine (src/lib/domain/pms/work-order-label-line.ts). The deleted workOrderReference helper that the drift guard bans was a different, label-replacing renderer, and the ban stays.

AMENDED 2026-10-30: no person is shown the ticker-prefixed displayId (Fede 2026-10-28: "use only and exactly the number appfolio already provides"; Gera 2026-10-30: "why still show ticker?"). This supersedes the "only string PropFlow shows a human" sentence above and the "Every operator surface … renders the same string the tenant sees" consequence below, for TEXT a person reads: residents, vendors, handymen and staff UI. Shown text uses shownWorkOrderNumber (src/lib/domain/maintenance/shown-work-order-number.ts, AppFolio's own number); while there is none, a surface names the job by its issue (workOrderPersonRef, work-order-person-ref.ts) or a plain label ("Work order", "View work order"), never the ticker. displayId itself is unchanged and keeps every other role this ADR gives it: the URL segment and href, the lookup key, log lines, audit rows and tool-call arguments. The remaining render sites are tracked by src/__tests__/wo-number-bypass-ratchet.drift.test.ts and its baseline, which only shrinks.

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

⚠️ AMENDED 2026-10-23 — the three bullets below are a DATED SNAPSHOT (2026-05), and three of their clauses were already false before this amendment was written. Ruling f949e86ff (a shared service-request number resolves to a chooser) and receipt f0f60b4f4 (which of several rows the URL may name). What is corrected, and what is NOT:

1. The row id is NOT private to the data layer, and was not when this was written. GET /api/work-orders/[displayId] has always returned the whole WorkOrderDetail to the browser — return NextResponse.json(detail) (route.ts:154), and WorkOrderDetail extends WorkOrder, which carries id. The detail page's own client reads it (rowRef.current = data.id). And ?propertyId=${p.id} — also a storage row id — rides user-visible links on three live leasing pages. The clause described an aspiration for the PATH and was read for years as a product-wide invariant.

2. The row id MAY ride ONE query parameter, and only one. displayId is <ticker>-<service-request #>, and one AppFolio service request can carry a work order per trade — measured on the anonymized mirror: 1,012 collision groups, the largest 8 rows. So the label cannot name a row, and a disambiguator is unavoidable. It is ?wo=<row id> (WORK_ORDER_ROW_PARAM), and the PATH remains displayId-only: /maintenance/<row-id> still 404s.

What makes the carrier safe is a REFUSAL IN THE LOADER, not the chooser — and this sentence was wrong in the first draft of this amendment, so it is spelled out exactly. Two mechanisms, and only the first is a security property:

  • readPinnedRowBehindGate (resolve-readable-work-order.ts) is the one that matters, and it is UNCONDITIONAL. After the ticker's claim names the building and the access gate admits it, the pinned row is read BY KEY inside that one building and returned only if it carries the very label in the path. Anything else — a row in another building, a row with another label, an id that names nothing — answers null, which is the door's own 404. This holds on every door and does not depend on the chooser existing. This is the clause that makes the exposure grant no capability. Do not weaken it.
  • chooseWorkOrderSurface decides only WHICH of several legitimate rows a reader sees, and it does NOT "fall back to the chooser" in every case. With more than one live row and no ?wo= naming a member of the group, it renders the chooser. With exactly one live row, or none, there is nothing to choose between: it ignores the parameter entirely and renders the row the label names. That is still safe — a foreign ?wo= is discarded rather than honoured — but it is a weaker and different statement than "always refuses back to the chooser", and an amendment that claimed the stronger one would have had the next reader relying on a guarantee the code does not make.

THE ACCEPTED PRICE, recorded here because an amendment that notes only the permission is how the next reader gets this wrong:

  • A work-order row id is shaped wo_appfolio_<afId>_<timestamp>, so a staff-visible URL now discloses the PMS type, the AppFolio work-order id and the mint time.
  • Amending this clause normalises internal ids in URLs product-wide; that generalisation is the real cost, not this one page.
  • A future re-key of WorkOrder.id would degrade saved pinned links back to the chooser. Fail-safe, but an unattributed UX regression — attribute it to this line.

Alternatives priced and rejected (receipt f0f60b4f4): an ordinal is fail-unsafe, because siblings sort by createdAt and rows are archived, so a stale ordinal silently acts on a different row through seven write doors — the original defect returning; an opaque token mints a second key per row, which this ADR's own one-key rule forbids; and every PMS id fails, since pmsId IS the shared service-request number (types.ts:7002), af.workOrderId is optional with enrichment red since 2026-10-16 (7,497 rows unreached, block b92575454), and service-request/work-order-number conflation already caused four wrong-ticket incidents (types.ts:7007-15).

3. "All 7 handlers" is stale, and "resolves strictly via getWorkOrderByDisplayId" is not what the code does. There are 11 route files / 13 exported handlers, and resolveWorkOrderRef accepts either an internal id or a displayId by design — the root PATCH calls it (route.ts:184), because Clara's tools and the PMS callback each pass whichever id they hold. The enforced invariant is the PATH, which is also all the drift guard ever checked.

The guard was widened in the same PR, because this prose was stricter than anything enforcing it: the path rules are blind to everything after the ?, so a row id in a query param passed green. wo-displayid-drift-guards.test.ts now also refuses any work-order URL whose query carries a disambiguator other than the one sanctioned above, with a mutation control under scripts/mutation-controls/.

Not amended: the decision itself. displayId remains the canonical label and the only thing in the path.

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.