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>/conversation → 200,
/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.
agents/clara/lib/email/ndr-bounce.ts — the last EMAIL
emitter. Delivered mail cannot be recalled, so deleting the route strands
these permanently; Gera has accepted that trade.calendar/EventRow.tsx and
domain/calendar/building-calendar.ts — the vendor-scheduled
rows, whose sourceRef is a conversation but whose stored row
carries NO person. A writer change plus a backfill, not a link change.maintenance/conversations/[id]/page.tsx — the redirect
stub. Emitting the old shape is its whole job.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.
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 shape | What 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>/conversation | Still resolves after she converts, and still calls a resident a prospect. Reads her history person-wide. |
/tenants/<occupancyId>/conversation | Same human, same thread — but scoped to one property, so it shows fewer messages than the row above. |
/people/<personId>/conversation | The 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 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 claims | What the thread is |
|---|---|
| One work order, CAM-2592 | 49 messages across 30 distinct work orders, June 13 → today |
| A record's conversation | A person's three-month dispatch history, spanning ~15 units |
| CAM-2592 is the subject | CAM-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)."
"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:
| Word | What it means | Where it shows up |
|---|---|---|
| Message | One 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 |
| Conversation | Everything 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 |
| Exchange | The 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 |
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.
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.
| Who | Has a person id? | Where their name points today | Verdict |
|---|---|---|---|
| Tenant — Carly Sprague | yes | /tenants/tenant_0ca9174c… | works |
| Prospect | yes | /leasing/prospects/<inquiryId> | works |
| Handyman (in-house) — Brian Wieser | yes | /vendors/vendor_appfolio_697 | page exists |
| Vendor contact (external) | yes, via VendorMembership | the company's page, not theirs | coarse |
| Property manager / staff | yes | nothing — resolveStaffDestinationId returns null unconditionally | no page |
| Owner | no role exists in the data model | nothing. Property.ownerId is a login, not a person | not modelled |
| Unknown caller | yes (minted on first contact) | nothing, correctly — there is no page to point at yet | by design |
| Work order | n/a — not a person | its own record page; its messages live in the people's conversations | by design |
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.
| Counterparty | Person ID | Detail page | Reachable today |
|---|---|---|---|
| Prospect | yes | yes | /leasing/prospects/… |
| Tenant | yes | yes | /tenants/… |
| Former tenant | yes | yes | /tenants/… |
| Co-tenant | yes | none | standalone only |
| Vendor contact | often the wrong human | company page only | standalone only |
| Handyman | same as vendor | company page only | via work order |
| Shared company line | a phone skeleton | none | standalone only |
| PM / staff | yes | none | standalone only |
| Unknown caller | a sentinel | none | standalone only |
| Owner | does not exist | none | — |
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.
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.
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.
| Guard | Question it answers | State |
|---|---|---|
| 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 |
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.
| # | Step | What makes it non-trivial | State |
|---|---|---|---|
| 1 | Build /people/<personId>/conversation | The 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 |
| 2 | Move the internal producers | Ten 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 |
| 3 | Redirect, then backfill Slack | The 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 |
| 4 | Delete the route | No redirect left behind, no shim. Checks before pulling: ratchet green with no allowlist rows, traffic counter quiet, Slack backfilled. | blocked by 3 |
Both came out of the same reported bug — a Slack link that landed on a page with no way back.
<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.?person=1 emitter — the Slack ops card — and replaced the hand-written guard list that let it drift with a derived sweep.vendorPersonId would land dispatch threads on the roster human — but for a shared company line it binds a company number to one person, which brushes the no-inferred-merges ruling. A product decision, not a bug fix.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.