Where Conversations Live

What a conversation URL should be keyed on, what the words mean, and the order we get there — the decision record for retiring /conversations/<id>.

2026-09-03 · decided: vocabulary + destination · MERGED — all 10 PRs on main · owner: Gera

Status, 2026-09-03. The person route is built. This page's earlier stamp said "not built"; that is what changed.

Landed and live in prod: the auth bounce now carries the query string, so a deep link clicked while logged out keeps its arguments (#6834, verified on prod). The same change closed an open redirect on /login: /\evil.com passed the old guard and browsers resolve it to an external host. Redirect-hit counters are merged (#6837).

Merged (2026-09-03). Nine PRs became one: each landed on its parent as its review cleared, which is the stack working rather than a mistake. On main: the retirement detector (#6844), personId required on the wire (#6851), and the /people/<personId>/conversation route (#6864, carrying the href seam #6872). Collapsed into the last PR, merged 22:43 UTC as f5f2d6ec (#6879): the work-order loader's person projections (#6875), every in-app link moved, the nine old builders deleted (#6891), ops-feed cards (#6907), the email emitters (#6917, #6925), and the last hand-spellings including the Copy-link button (#6927). agent-smith moves in lockstep (agent-smith#369).

THE DEPLOY GATE IS CLOSED — this paragraph said the opposite for most of the day and is corrected rather than deleted, because the risk it named was real and the way it resolved is worth recording. /people/<personId>/conversation 404'd on prod until #6864 deployed at 20:31 UTC. Nothing that MINTS the new shape could reach production first: merged is not deployed, and merged in the wrong order every ops card, email and copied link is dead on arrival rather than failing loudly — and delivered email cannot be recalled.

It resolved by collapsing rather than by sequencing. #6872 had auto-merged into #6864, so the route and its first minter (the dock's Copy link) shipped in ONE push — the route existed the moment anything addressed it. Verified on prod by CLI, and the verification is worth stating because the first attempt proved nothing: unauthenticated, every path 307s to /login, so the route answering looked identical to a route that does not exist. Authenticated, with two controls: /people/<id>/conversation200, /people/<id>/not-a-route → 404, /totally-bogus-path → 404. The old /conversations/<id> still answers 200, which is the accept-old-forever half doing its job.

What the review round actually found — worth recording, because none of it was the migration being hard. Every blocker was a test still pinning the shape its own PR had just deleted, and two of them were GREEN while doing it: a vi.mock factory exporting a pre-rename key (untyped, so the typechecker saw nothing), and the single test guarding the link agent-smith parses out of Slack, whose mock took a personId, discarded it, and returned the retired shape — so it would have passed either way. The lesson is narrow and repeatable: a mock that RESTATES a value instead of DERIVING it from the real builder is a test that goes green after the thing it guards has already broken.

What the last four rounds found, which was different. Rounds 1–6 were stale assertions. Round 8 looked like a seventh, and was not: the test's vi.mock factory was CLOSED and omitted getConversation, so the call threw into the subject's own catch, personId came back undefined, and the email shipped the conversations LIST under a test named "deep-links the conversation thread". Patching the literal would have made that permanent.

Then the full sharded suite — which had never actually run on the PR — found a real defect. ops-event.ts types a card's conversation as { id: string; personId: string }, but Conversation.personId is TYPED string while documented absent on pre-spine rows. A dozen postOpsEvent call sites build that object off a stored row, so the card could post /people/undefined/conversation into Slack — a link that looks live and resolves to nothing. Guarded at the SEAM (formatOpsEventCard now gates the link on personId) rather than at twelve callers.

The decision that changed under review. The anchor was going to be ?highlight= in the path. It is ?thread=<conversationId> in the query instead: that param already exists and is already parsed, and the reason for putting it in the path — queries being destroyed by the auth bounce — was the bug #6834 fixed.

What the work actually turned out to be. Not a rename. Every old builder takes a ROLE or RECORD id; the new one takes a personId, and at five call sites the obvious id is a valid id for the WRONG HUMAN — a turnover's person is the outgoing tenant while its walk thread is the PM's; a work order's person is the tenant while its handyman thread is not. That failure renders as a working link into someone else's entire merged history, so it is invisible to tests, types and review. Two loaders now project the thread's own person, and a drift test bans the convenient id.

What was blocking everything, and no longer is: the person route 404'd on prod until #6864 deployed, so nothing that MINTS the new shape — ops cards, emails, agent-smith — could merge ahead of it without every link being dead on arrival. Resolved as described above, and the whole migration is now on main.

Still on the retired shape — four files, and each for a stated reason. Read off the ratchet's allowlist (conversation-link-ratchet.drift.test.ts) rather than restated here, because a hand-maintained list in prose is how this paragraph was wrong before: it claimed four email emitters and three hand-spelled hrefs, and the emitters had already moved.

Key conversations on the person, not the role. A prospect who becomes a tenant keeps the same personId forever; the prospectId in today's URL describes who they used to be. The old route gets a temporary redirect and is then deleted — not supported indefinitely.

The problem, in one example

Someone inquires, tours, applies, signs. Every message they sent is one continuous exchange — but a link pasted in Slack three months ago still says /leasing/prospects/…. It does not 404. It renders. It just describes a resident as a prospect.

URL shapeWhat it actually addresses
/conversations/<conversationId>A chunk, not a person. Voice mints one row per call, so a three-call resident has three of these. No parent page — which is why its back button was dead.
/leasing/prospects/<inquiryId>/conversationStill resolves after she converts, and still calls a resident a prospect. Reads her history person-wide.
/tenants/<occupancyId>/conversationSame human, same thread — but scoped to one property, so it shows fewer messages than the row above.
/people/<personId>/conversationThe key that cannot rot, and the only one that forwards when two records turn out to be one human.

Rows two and three are the ones worth staring at. Both are "correct" today and they disagree about which messages exist. That is not a bug anyone introduced — it is what happens when the address is a role.

The same problem, measured — a handyman's line

The prospect case is about an address that rots. The dispatch case is about one that was never true for a moment. Gera, 2026-09-02, holding /maintenance/work-orders/CAM-2592/conversation?with=vendor: "it is confusing that the URL says 2592, but also in the conversation we see 2479."

It is worse than two. That thread is Brian Wieser's SMS line — one phone, one person, checked against prod on 2026-09-02:

What the URL claimsWhat the thread is
One work order, CAM-259249 messages across 30 distinct work orders, June 13 → today
A record's conversationA person's three-month dispatch history, spanning ~15 units
CAM-2592 is the subjectCAM-2592 is one message, the last one. CAM-2479 sits directly above it.

This is the same ruling already made for the resident side on 2026-08-30 — ?with=tenant was retired that day and now redirects to the tenant's own conversation page with ?regarding=<displayId>. ?with=vendor is the last survivor of a pattern already abandoned, and the route file says so in its own header: it stays "until the vendor/handyman person page exists (PR 2)."

First, the words — three, and "thread" is not one of them

"One conversation contains many conversations" is true, which is the problem: two different objects share the name. An earlier draft of this page fixed that by calling the small one a thread. Gera killed it, 2026-09-02: "I don't want to use the word thread… most people don't really know what a thread is."

He is right, and the reason is worth keeping. The small unit — one stored row, one channel anchored at one property — is a storage fact. A PM has no decision that depends on knowing it. A word for it is a word a reader has to learn before they can read the screen, and every such word is a tax the product charges for its own filing system. So the reader-facing vocabulary is three words:

WordWhat it meansWhere it shows up
MessageOne thing one person said — a text, an email, one turn of a phone call. The atom. Nothing smaller has a name.Every bubble in the timeline
ConversationEverything one person has said, across every channel and every property, in one timeline. The product word — and what the URL should address.The page. /people/<personId>/conversation
ExchangeThe slice of a conversation about one record — the messages concerning one work order. Not a separate page: a highlighted band inside the conversation.?regarding=CAM-2592

Highlighting is the whole trick

Once the address is a person, "which work order?" cannot be answered by the path — and it should not be. It is answered by tinting the messages that pertain to that record and leaving everything else legible around them. You keep the person's real history on screen and you point at the part you came for.

That is already built and already ruled on. Gera, 2026-08-30, deciding what the band covers:

"Contiguous span: from the first WO-pertaining message to the last, plus everything inside the same conversation segment — one solid band."

His reason is the part that matters: the band is a lens, not a filter. A message the classifier missed but that plainly sits inside the exchange must still be shown as part of it, never silently excluded. So membership is positional — first tagged message to last, then extended outward to the natural break on both ends. A filter would hide the thing you needed; a band cannot.

Every kind of person, checked against production

The design is only as good as its worst counterparty. Each row below was resolved through resolvePersonCandidates — the same function the conversation page runs — against propflow-prod on 2026-09-02.

WhoHas a person id?Where their name points todayVerdict
Tenant — Carly Spragueyes/tenants/tenant_0ca9174c…works
Prospectyes/leasing/prospects/<inquiryId>works
Handyman (in-house) — Brian Wieseryes/vendors/vendor_appfolio_697page exists
Vendor contact (external)yes, via VendorMembershipthe company's page, not theirscoarse
Property manager / staffyesnothingresolveStaffDestinationId returns null unconditionallyno page
Ownerno role exists in the data modelnothing. Property.ownerId is a login, not a personnot modelled
Unknown calleryes (minted on first contact)nothing, correctly — there is no page to point at yetby design
Work ordern/a — not a personits own record page; its messages live in the people's conversationsby design

The battle test — does a vendor have a person ID?

This was the case most likely to break person-keying. The answer is yes, but not the one you would guess.

A vendor company genuinely has no person id and never will — ADR-0033 strips human identity off it deliberately. But you never text a company. You text a human at one, and that human is a VendorMembership, which carries a required personId. So the URL works.

CounterpartyPerson IDDetail pageReachable today
Prospectyesyes/leasing/prospects/…
Tenantyesyes/tenants/…
Former tenantyesyes/tenants/…
Co-tenantyesnonestandalone only
Vendor contactoften the wrong humancompany page onlystandalone only
Handymansame as vendorcompany page onlyvia work order
Shared company linea phone skeletonnonestandalone only
PM / staffyesnonestandalone only
Unknown callera sentinelnonestandalone only
Ownerdoes not existnone

Six of ten have no entity page. That is why the standalone route still exists, why person-keying is not a tidy-up — it gives a home to a class of humans that currently has none — and why the route cannot be deleted before the person route is built.

One honest correction to the framing we started from: "owners have conversations" is not true yet. There is no owner role in the data model at all — Property.ownerId is a login, not a landlord.

The decision

Person.id is the only key in the system that never changes for the lifetime of a human, and the only one with a forwarding mechanism (mergedIntoPersonId) when two records turn out to be one person. A role id has neither.

The smart router idea — inspect the roles, forward to whichever page ranks highest — was tested and fails, for a reason worth keeping: a link pasted in Slack would land somewhere different after the person converts. That is the opposite of what a stable URL is for.

The path says whose. The trail says what they are now. The query says which slice?thread= for one anchor, ?regarding= for one work order.

The end state — delete, don't support forever

Gera, 2026-09-02: "We could route them in for a bit. But we should never create them anymore. My concern is if we route them in and we're creating them, it's gonna be a hidden issue — because it's working."

That concern is the design constraint, not a worry to reassure away: a redirect that silently works is indistinguishable from a migration that never finished. So the redirect is scaffolding with an expiry, and two guards make it visible rather than silent.

GuardQuestion it answersState
CI ratchet"Are we still creating new ones?" A 19th producer fails the build; a stale allowlist row fails too, so fixing a file must delete its row.in review
Traffic counter"Have the old ones gone quiet?" The real evidence a deadline cannot give. Already exists — the route is in the traffic manifest, so /admin/dev/traffic counts it.live
Bake timer"Has it been long enough to look?" A reminder, explicitly not proof.2026-10-02

The order

Each step is blocked by the one above it. Step 4 in particular cannot happen before step 1, or six kinds of counterparty lose their only reachable page.

#StepWhat makes it non-trivialState
1Build /people/<personId>/conversationThe authorization seam. Today's routes scope by "the id came from a scope-checked entity"; a person route must derive that from the person's roles on properties the viewer can reach. New code on a security boundary, and it wants an ADR.next
2Move the internal producersTen of the eighteen are internal UI with no external artifact to chase — list rows, the dock, search hits, the renewals board. Cheapest group.blocked by 1
3Redirect, then backfill SlackThe old route becomes a redirect onto the person form; Slack history gets rewritten. This is where the email/SMS count gets confirmed against the accepted trade.blocked by 2
4Delete the routeNo redirect left behind, no shim. Checks before pulling: ratchet green with no allowlist rows, traffic counter quiet, Slack backfilled.blocked by 3

Shipped on the way here

Both came out of the same reported bug — a Slack link that landed on a page with no way back.

Was

The back chevron was a <button> calling history.back(). Opened cold from Slack there is no history, so it did nothing; opened behind Slack in the same tab it left the app entirely.

Now (#6787)

An href to the person's own entity page, falling back to history-back only where no page could be resolved. The same PR retired the repo's last ?person=1 emitter — the Slack ops card — and replaced the hand-written guard list that let it drift with a derived sweep.

Open questions

Sources: ADR-0018, ADR-0020, ADR-0032, ADR-0033, ADR-0035; spine-stamp-pattern.md; person-lookup.md. Every claim was traced to code — anything that could not be established from the repository is listed above as open rather than answered.

PropFlow Docs