We're making the one fixed property on a conversation flexible: none, one or many. This page is the design for that — every case, what the merged code does today, what changes on the conversations page, and the order the work ships in.
Revised 2026-09-24 with Gera's decisions, the independent grade (B; five must-fixes, all folded in) and the 69-item issue sweep · code read at propflowai origin/main 3068aefbd, re-checked by the grader at d28a9331 (no cited line moved) · build green-lit; steps 1–2 in progress · all names, numbers and addresses synthetic
Gera's decisions. The user-facing word is Property. There is no new column: the existing Property column becomes none, one or many property chips — known shown plainly, guessed shown faded, "—" when there are none, never the umbrella placeholder — and staff can add or remove a property with undo. A separate Called column and filter shows which company number or mailbox the person reached, by its friendly name. A number that belongs to exactly one property yields a known property tag on its own.
No new column. The Property column changes what it holds; a Called column and filter is added beside it (filter only, shown when the company has more than one line; the line itself is shown in the conversation's Details — Gera, 2026-10-01). A synthetic company — Northwind Properties, many scattered single-family properties, two published numbers named Leasing line and Maintenance line, one mailbox leasing@ — with two of its properties, 12 Alder Ct (A) and 40 Birch Ln (B).
| Person | Property (today) | Property (after) | Called (new) | Last message |
|---|---|---|---|---|
| Dana R. | 12 Alder Ct | 12 Alder Ct 40 Birch Ln | Leasing line | 2h ago |
| Marco T. | Northwind front door (the umbrella) | — | Leasing line | yesterday |
| Priya S. | 7 Cedar Way | 7 Cedar Way | Maintenance line | 3d ago |
| Lee W. | 40 Birch Ln | 40 Birch Ln | leasing@ · Leasing line | 1w ago |
organizationId === checks anywhere.Every conversation carries three separate facts. Today's code folds all three into one field (propertyId), and that single fold is the root of every case that goes wrong below.
The address of the record. Immutable. Decides the company wall and nothing else. Today the address is the property partition (PROP#<id>); the docs' §3.5 direction is the company (ORG#<id>). That re-key is a separate human decision and this design does not need it first — but the birth-property tag and the metaPK hint become legacy the day it lands.
The number dialled or the mailbox written to — recorded verbatim, a fact, never a guess. Stamped on every inbound message, and kept as a set on the conversation written only with an atomic string-set ADD. For a thread Clara started, the line is the number or mailbox she sent from. Powers the Called column.
source ∈ line · identity · caller-said · inquiry · staff · classifier · backfill (the sync is recorded as the actor that retracts, not as a source); confidence ∈ known · likely · guessed. "Clara" is named honestly: what the person said, as Clara's tool call extracted it, is an LLM reading, not the caller's own words. The voice path already computes this confidence at ring time as home_state and then throws it away.attribute_not_exists(PK) on first write, and never on a row whose status is retracted. A staff "Remove" cannot be silently reversed by the person's next message. (Grade must-fix 1.)likely when it is the person's own message naming exactly one home; guessed when the text is a card, a lead-source email or a transcript; never a tag when several homes are named). (Grade must-fix 2.)turnPropertyId(). The same seam writes the tag, so message label and tag never disagree.Conversation.propertyId; it takes it from the object's own tag. An action that needs exactly one property, on a thread with zero or several tags and no turn property, asks — or parks with one staff notice. It never picks. (Sweep D5, D6.)The first proposal was one more column. Four things break, each on a case Gera named — and the fix is not a column but changing what the existing one holds.
Conversation.propertyId is the partition key of the record (convMetaPK, conversation.ts:100-108). A blank one files the thread under PROP#UNASSIGNED. Because the key is the property, "changing the property" is a partition move, which is why the only writer refuses to re-point a bound thread (conversation.ts:452-470, pinned by bind-home-single-writer.drift.test.ts). A value that cannot be corrected fails the wrong-guess case by construction.
Two properties in one call is two values. No property is zero. A single value holds one, so the second either overwrites the first or is dropped. The merged code already knew this: the by-property index was built as pointer items, one per (thread, property), precisely because "one attribute holds ONE home" (conversation-home-gsi.ts:9-13). The same defect exists one level down in Conversation.unitNumber (sweep D12).
Same shared line, two callers: an unrecognised caller's thread is born under the umbrella placeholder the company line is registered on (config/phone-registry.json, kind property-line; home-state.ts:35-43), while a caller Clara recognises is adopted at ring to a sibling property and born there (personalization/route.ts:871-906). Same number, two partitions, chosen by whether the classifier recognised the voice. The dialled number itself is stored only on the call-scope row, 24-hour TTL, and only for a flipped line (voice-call-scope.ts:36-44, 71, 76-87) — for an unflipped line it is not written down at all.
A property set because the caller dialled that property's own line is a fact. One set because a phone-number lookup matched someone who once toured there is a guess. A single string stores both the same way, so nothing downstream — Clara's tools, the property page, the escalation, an AppFolio write — can tell which one it is acting on.
"Today" means the code on origin/main at 3068aefbd with the company setting conversations.join_across_homes ON (it is off everywhere; zero rows exist — person-first-join-setting.ts:16-24). A separate single-property customer, Cedar Tower, has its own line and a front-door callbox. File:line cites are in the ledger at the end.
| Case | Today — merged code | After — this design | Correcting a wrong guess |
|---|---|---|---|
| 1 · Call to a number that belongs to exactly one property (Cedar Tower's own line) | verified The dialled number resolves to the property; the row is minted at ring under it; one pointer is written on that first save. Property tab: its own partition. Company list: |
Identical outcome as facts: Called = Cedar's line; tag {Cedar, |
Nothing to correct — the line named the property. Staff may add a second tag; a |
| 2 · Call to the company line shared by many properties; property unknown | verified The line is registered on an umbrella placeholder, so the row is born under that pseudo-partition with scope |
Born with Called = Leasing line, no tags; Property column shows "—". Shows on the company list and under "Leasing line" at once. When the caller names B, Clara's tool writes {B, |
Staff: Remove B (kept as |
| 3 · Caller asks in general; no property, ever | verified Company line: parked under the umbrella forever. Unmapped number: post-call the row lands in |
Tagless is a first-class state ("—"), not a sentinel partition. The thread lives at the company, is listed under its line, and Clara answers company-wide questions from the company's own settings. "Unattributable" is only a line that names no company at all. |
Nothing to correct. Staff may Add later ({B, |
| 4 · Caller talks about two properties in one call / one thread | verified Under the setting the thread is one row keyed at birth property A; each message about B carries |
Two tags: {A, per how A was learned} and {B, |
Remove whichever is wrong. A message's own label is history and is not rewritten. |
| 5 · Lookup guesses A, actually B | verified Two guessing paths at ring: an org-wide identity match on a company line adopts the matched sibling property as the call's context ( |
The guess is a faded tag {A, |
Staff: Remove A. Both leave the retracted tag with who/when/why. Undo = re-activate. Clara's next turn cannot re-add a staff-removed tag. |
| 6 · Person known at the company from another property's inquiry now calls | verified Voice mints a new row per call, so "one thread per person" on voice is a person-level view; #11232/#11287 join the caller's history and greeting across properties under the setting, skipping shared devices. On a company line the known caller is adopted to the property their record names — a second guess shape. On SMS/email the join rule keeps the existing thread and labels the message with the turn's property. |
Called = Leasing line; tag {their prior property, |
Same as case 5. The identity guess is the tag most often wrong, so it is the one rendered faded. |
| 7 · Callbox / shared device — one number, many humans | verified No stored marker: |
A line tag is fine here (the callbox rings the property's own line: |
Marking a number as a shared device retracts every identity tag it produced (batch, undoable) and stops future ones. |
| 8 · Email and SMS — where they differ | verified SMS mirrors voice: dialled number → property via the same map; joins across properties under the setting. Guest-card email: the listing's property arrives with the card. Company mailbox: an |
Same three facts, different sources. SMS line = the number texted. Email line = the envelope recipient or mailbox account, never the To header (sweep D7). A guest card yields inquiry tags from the panel list (next section). A company mailbox yields no tag until a property reference is confirmed; a subject/body match is |
Identical Remove/Add. An inquiry tag is retracted by staff, or by the sync when AppFolio moved or removed the inquiry (D11). |
| 9 · A property that belongs to two companies | verified Shape A′: one record owner plus a live co-manager; |
Unchanged on purpose: a tag names a property, never a company. Co-manager visibility is the A′ decision (open question 5). |
n/a. |
| 10 · A thread Clara started (first email from a guest card, outbound renewal call) | verified |
Called = the number or mailbox Clara sent from, recorded on the outbound message and the thread's line set. Tags come from the trigger: an inquiry tag for a guest-card first email, {property, |
Same Remove/Add. |
| 11 · A thread is deleted | verified |
The |
n/a — the audit trail is the point. |
| 12 · A property is offboarded or no longer managed | verified The gate's |
Offboarding a property retracts its tags |
Re-onboarding: staff Undo, or the sync re-adds inquiry tags. |
| 13 · A person is split (one Person turned out to be two) | verified The split/merge tools already carry pointers between threads; identity-sourced tags derived from the pre-split person stay put and nothing retracts them. |
The split retracts every |
Staff Undo per tag if the split was itself wrong. |
| 14 · A thread staff created by hand (admin create, contact import) | verified Born at whatever property the form named; one pointer on first save; no source recorded. |
Tag {that property, |
Same Remove/Add. |
| Surface | Reads | Result when every tag is wrong |
|---|---|---|
| Company conversations list | CLIST#<org> (merged, #11019, untouched — its keys already ignore the property) | Still lists the thread. |
| Called filter ("everyone who reached the Leasing line") | on day one: the company list filtered in memory on the thread's line set, intersected with the viewer's property scope; later the line family in the index — as built (2026-10-01): the filter re-reads the list with ?calledLine=, answered from the LINE# index, and is shown only when the viewer's rows carry more than one line | Still lists the thread — for viewers allowed to open it. |
| Person page | the person's threads (merged; effective-property rule in load-person-conversations.ts) | Still lists the thread. |
| Property column | active tags; "—" for none; umbrella hidden | Shows the wrong chip until removed; the row never disappears from the list. |
| A property's Conversations tab | active tags for that property (index, filtered status=active) ∪ today's partition while the re-key has not run | Shows on the wrong property until removed. |
| Staff opening a thread | the merged gate (conversation-home-access.ts) with a status=active filter, plus the Called-filter admission | Admitted while a tag or line the viewer is scoped to is active; the list and the page always agree. |
The sweep's biggest finding (13 of its 17 misses). An AppFolio guest card holds N inquiries, one per property, each separately assignable with its own status and its own panel id. Our ProspectInquiry is one per card, company-wide, never re-homed (the #9731 rule, writers/guest-card.ts:1119-1175); its propertyId is wherever it was born — often the umbrella for a call-born lead — and its card-level scalars are last-writer-wins across properties. The per-property truth lives only in the panel list, ProspectInquiry.af.inquiries[] (and the mirror's custom_data.inquiries). Since #11227 the sync emits one lead.received per inquiry; since #11433 the drain assigns every ACTIVE inquiry on the card. But the first-email intent is keyed on the single ProspectInquiry id, so a second-property inquiry resolves to the first property's row and is skipped — property B gets no reply (#11524, open). No guest-card path writes a pointer or a message label; the thread is filed under ProspectInquiry.propertyId.
ProspectInquiry.propertyId or its target fields (D1; #11507, #9731). For each AppFolio inquiry on the card: {the Property whose af.propertyId equals the panel row's propertyId, source inquiry, confidence known, refs: [{system: appfolio, kind: inquiry, id: <panel id>}], unitId?} on the person's one thread. Trigger: the per-inquiry lead.received.assign and first-email intent keys carry the same panel inquiry id as the tag's refs; a future per-inquiry note keys the same way. One card with two inquiries = one thread, two tags, two per-inquiry intents. refs is a set, because two inquiries on the same property (two cards, or one card twice) land on one tag row. The id space is the panel id; the Guest Card report's InquiryID is one less (#11246) and any join from a report row must reconcile.inquiry/known; exactly one roster home named in the person's OWN inbound message (an SMS body; an email subject and body they wrote), umbrella excluded, office address ignored → classifier/likely; a home named in text the person did not write (guest-card free text, a listing-site lead email, a call transcript) → classifier/guessed, faded; text naming several homes yields no tag at any confidence. The composer takes facts only from the turn's property, never from a sibling tag.Conversation.propertyId (D4; #11394, #10603, #11507): the original-inquiry row, summary refresh, application send, the listing card. #11394 is the first instance and can ship now.by system:sync, reason recorded, undoable. Whether Active/Inactive flips retract or re-activate the tag is open question 7 (#11170).inquiry/known, by system:backfill); fall back to backfill/guessed from the partition only where the panel was never read — today that is ~96% of existing cards at the scattered-site customer, so sequence after #10259's panel backfill or state the gap.ProspectInquiry rows, as today. Tags are the conversation's view; the funnel's per-inquiry identity is S9's job. The two must name the same panel id.The merged pointer item is the right key shape and the wrong item. Keys today (conversation-home-gsi.ts:15-18, 82-91): base PK=CONVHOME#<convId> SK=PROP#<pid>, index CHOMEPK=CONVPROP#<pid> CHOMESK=<convId>, projection ALL, plus conversationId, propertyId, metaPK, recordedAt. A set of (thread, property) rows is exactly what none/one/many needs. What is missing is everything a tag needs to be trusted, joined to AppFolio, or corrected.
PK CONVHOME#conv_9f3a SK PROP#b-40-birch
CHOMEPK CONVPROP#b-40-birch CHOMESK conv_9f3a ← removed on retraction (sparse GSI)
conversationId conv_9f3a propertyId b-40-birch metaPK PROP#a-12-alder (hint, as today)
source inquiry ∈ line · identity · caller-said · inquiry · staff · classifier · backfill
confidence known ∈ known · likely · guessed
status active ∈ active · retracted · deleted
refs [{system:appfolio, kind:inquiry, id:4471}] ← a SET; panel id space
unitId u-201 (optional; Conversation.unitNumber has the same one-value defect)
at 2026-09-24T18:02:11Z by clara:turn | staff:<personId> | system:sync | system:backfill
via voice · Leasing line ← the line this tag was learned on (fact 2, denormalised)
retractedAt / retractedBy / retractedReason (only on a retracted row; the row is never deleted)
attribute_not_exists(PK). Re-write by an automatic writer: refused when status = retracted, so staff removal stands until staff undo. Retraction removes the two index keys and sets status: the row drops out of the by-property index on its own (a GSI is sparse) and stays in the thread's CONVHOME# partition for audit, undo and the access gate — which then filters status=active (today conversation-home-access.ts:77 reads every row). The line set on the conversation row is written only with an atomic string-set ADD, never by a whole-row put — that is what makes the day-one Called filter possible without an index, and what keeps it clear of the partial-patch race that rules out a list attribute for properties (alternative D).conversation-home-index before it is created in productionCHOMEPK/CHOMESK with projection ALL is right; the new fields are non-key attributes, so the GSI never has to change for them.CHOMEPK=LINE#<e164>, same sort key) or its own index (open question 6). Creating it before the writers land would not be harmful — it would be useless for the motivating case (G1) and born unsourced (G2, curable by the backfill's own stamp). Recommendation: same index, distinct prefix; the reader, the retention and the partition fall-back are identical either way.conversation.ts:1517), merge carry (:2931), merge revert (:3040/:3042), admin label (label-filed-conversation.ts:322/351) — plus the backfill script and the new per-turn and guest-card writers must stamp the fields, or the index lists tags nobody can trust.homesOfConversationMetaRow skips only PROP#UNASSIGNED; the company-line umbrella is a real PROP# row and would otherwise be minted as a guessed tag on every unrecognised-caller thread. Skip isPortfolioProperty rows; stamp the rest source:'backfill', confidence:'guessed'.All rows below sit behind the company setting, which is off at every company. None is a live defect today; each is a reason not to turn the setting on, and G1/G2 are why the index would be useless if created now.
conversation.ts:1512-1518), merge carry (:2931) and revert (:3040/:3042), admin label (label-filed-conversation.ts:322,351). The turn-crossing seam (conversation-manager.ts:5491-5497) stamps the message and writes nothing; no writer exists under agents/ (readers of the access gate do exist under src/app/api/conversations/[id]/). So B's tab (#11214/#11241), B-scoped list visibility (#11286) and B-scoped open (#11296) are inert for the motivating shape. Dock row 5 says the index is "written when a thread touches a home" — the board and the code disagree. step 2 in progressconversation-home-index.ts:82-94); a repeat put overwrites the whole item (:18-22), which is what would let Clara undo a staff removal.voice_call_started activity entry; only VOICECALL#/SCOPE.line, 24-hour TTL, and only for a flipped line (voice-call-scope.ts:36-44, 71, 76-87). SMS carries envelope.toAddress in memory as deliveredToAddress on InboundMessageParams (inbound-router.ts:177) — a ready hook — and stores nothing. step 1 in progressbindHome refuses re-points; the label door is platform-admin only and scoped to the legacy UNASSIGNED cohort. Staff have no Remove/Add.home_state (home-state.ts:195-215) is emitted to the voice platform and persisted nowhere; 'suggested' has no consumer (#9501). It is precisely the tag's confidence.shared-device.ts:1-35). Correct in direction; not correctable by a human.process-inbound-email.ts:1315-1324), and the platform catch-all matches property names across every company (#10483).ProspectInquiry per card, first-email keyed on it, no per-inquiry tag or label — property B never gets a reply (#11524, #11146, #11507, #11394). The whole section above.af.propertyId; never ProspectInquiry.propertyId or its target fields. One tag per inquiry's property.refs (a set of {system, kind, id}, panel id space) and optional unitId on the tag; per-property PMS write intents (assign, first email, future note) key on the same ref.inquiry/known; one home named in the person's own message → classifier/likely; a home named in card, lead-email or transcript text → classifier/guessed; several homes named → no tag; the composer never takes facts from a property that is not the turn's.Conversation.propertyId.Conversation.propertyId.line/known tag only when the number or mailbox belongs to exactly one property. A shared or ambiguous line yields no line tag; the line fact is still recorded.backfill/guessed from the partition only as the fall-back.by system:sync, undoable). Amends "only staff retract". Active/Inactive is open question 7.unitId lives on the tag; Conversation.unitNumber is the same one-value defect as propertyId and goes the same way.| Issue | What went wrong | Now | Where |
|---|---|---|---|
| #11098 (the motivating case) | A second-property inquiry opens a second thread with no memory of the first; 118 of 908 cards at the scattered-site customer carry more than one inquiry. | caught | join rule + case 4 + guest-card tags (D1, S8) |
| Harness case 18 thread-lost-after-mid-conversation-bind | Once bound, the next inbound on the shared line opens a second thread. | caught | "a property is a tag, never the address"; cases 2, 5 |
| Gera: "everyone who called this number" | Nothing stores the number reached. | caught | Called column and filter, step 1 |
| #11524 / #11146 | Second-property inquiry resolves to the first property's row and is skipped; property B never gets a reply. | caught | D2, S9 |
| #11394 / #10603 / #11507 | Original-inquiry join, summary refresh and first-email roster key on the thread's property, not the inquiry's. | caught | D4, S10 (ships now) |
| #11380 / #10483 | Four properties named in card text mint one inquiry and bleed facts; the catch-all matches names across every company. | caught | D3, D9 |
| #9772 / #9491 / #9280 | Writes keyed on the umbrella shell or a sibling's property. | caught | D5 |
| #9857 | A card with no property: no application send, no park, no notice. | caught | D6, S12 |
| #9674 / #9678 / #11170 | A card moved or deleted in AppFolio silently disappears; Inactive→Active is never followed. | caught (Q7 open) | D11, S11 |
| #9501 | home_state='suggested' has no consumer. | caught | G6 → confidence; case 6; step 7 |
| Harness 2, 11, T14 | A shared number silently becomes one property's line by roster order. | caught | D8 |
| Callbox (memory; #11287's skip) | One phone claim is one device; person-derived facts are poisoned. | caught | case 7, step 7 |
| #11286 / #11296 | Property-scoped staff see a joined thread in the list but cannot open it. | kept; refined | gate filters status=active; Called filter scope-intersected |
| b90188076 | "Create the index in prod now." | wait | open question 6 first, then step 6 |
| #10562 | Email bypass counts property-less threads as "under another property". | partly | case 3; the one-line guard is still owed until readers use tags |
| #9343 / #9462 / #10515 | Duplicate Persons from one number or a new phone. | dependency | identity spine; no tag can join two Persons |
Sweep totals over 69 rows: caught 9 · partly 8 · missed 17 (13 of them guest-card rows, now the section above) · already fixed 9 · out of scope 26.
ORG#) now, then tagConversation.scope already declares it, nothing writes it, and populating it is a bulk production re-key. The tag model works on top of today's partition (the birth property becomes one more tag; the metaPK hint stays), so the re-key follows on its own timetable. Both become legacy the day it lands.ADD, never a whole-row put, never a partition key.Settled by Gera, reviewed by Fable 5.1 (verdict: green light with changes). The durable choice was taken each time, even where it costs more effort.
caller-said. Built.ORG#), with no property and zero tags. That move changes where every such thread is stored, so it runs after turn-on as its own program. Before turn-on: the umbrella gets an explicit marker (not the property type), and it is never tagged, by the live writer or the backfill. The umbrella row stays the line's settings holder (Slack channel, desk phone, office hours) until the line moves onto its address record. Who may see an untagged company-level thread is still to be ruled.managed_by) and the rule that a live co-manager governs. The thread's company stamp is the governing company at birth; the other company's visibility is derived from the co-management record at read time, never stored and never an equality check. Open for Gera: may the second company also edit, or only the governing company (Fable recommends governing-only)?calledLines set) and as one LINE#<address> pointer item per thread in the same index, so "everyone who reached this number" is one query. Names come from the phone registry when displayed.Turn-on order: fix the backfill (write today's tag shape; fill provenance onto sourceless pointers) → umbrella marker and never-tag-umbrella → every conversation records where it is stored and its company at birth → create the index in production and backfill → the number-called pointer → test-company run per channel → check staff scope and org stamps on company-line threads → Western Slope → live two-home guest-card check → co-management slices → Inactive badge → the company-filing move.
Everything below is merged to main, reviewed 🟢 at its exact head, and dark behind the company setting conversations.join_across_homes (off at every company) unless noted. Nothing has been switched on for a customer.
| Step | What is now true | PRs |
|---|---|---|
| 1 · Called line | Calls, texts and email record the number or mailbox reached; a Called | #11579, #11594, #11597 · reverted #11903 · filter + Details field: see the PR linked from the row's board entry |
| 2 · Per-turn link | A joined conversation is recorded under every property a message moves to, and never overwrites an existing record. | #11571 |
| 3 · Tags, retraction, staff controls | Every property link says how we know it (source, confidence, active/retracted); a wrong one is retracted, never deleted; staff add, remove and restore on the conversation page and in the list's Property column; staff beat Clara and the sync. | #11578, #11616, #11630, #11639 |
| 4 · Guest cards | One property tag per AppFolio inquiry, carrying its panel inquiry id; a later inquiry adds its property (also before any conversation exists); the second property's inquiry gets its own reply and AppFolio claim; the sync retracts a property whose inquiry moved or was deleted. | #11617, #11627, #11656, #11585, #11626, #11632 |
| 5 · #11394 | A guest card shows the words the person wrote about the property in question. (Live.) | #11574 |
| Actions need one property | Applications, promised follow-ups and work orders never go to a guessed property: the object's own property, else the turn's, else the one confident tag, else they pause with one staff notice. Tours too. Work orders filed without Clara (the intake lane and the after-call sweep) follow the same rule. | #11624, #11633, #11654, #11655 (#11663 fixed a break #11655 caused), #11680, #11683 |
| Provenance | A conversation's property records how it was learned. For texts and calls alike, the number reached counts as known only when exactly one property owns it. A property the person names while Clara saves their details is recorded as “caller said, likely”, never known. | #11653, #11678, #11679, #11681, #11682 |
| 7 · Guesses and callboxes | A ring-time lookup is a faded guess, withdrawn when the person names another property; a callbox is stored as shared and never joins across properties; staff can mark or unmark a callbox (only where they have access; unmark restores exactly). | #11643, #11649, #11659, #11674, #11690 |
| 6 · Live database index | Not done — needs a person's go. Creating conversation-home-index in production and the backfill. | — |
Follow-ups merged: email records how its property was learned (#11682); a repair request is only a duplicate of one at the same resolved property, in the handler and in Clara's own shortcut (#11683); three small review nits (#11690). All code for the design is merged. Flaky renewal eval tracked in #11673. Questions 1, 2 and 4 were built on the proposed answers and can still be overturned. Still for Gera/Fede: questions 3, 5, 6 and 7 above (sent to the decider on 2026-09-24; it could not answer and will be asked again), the company switch (Art. XI line), the live index, and turning it on for a customer.
Each part is a vertical slice, ≤300 lines, off origin/main, dark behind the company setting except where noted. Steps 1 and 2 are already running.
ADD set on the conversation: voice first, then SMS and email (per channel and per tree — the types are mirrored under ADR-0040), then the Called LINE# index via ?calledLine=; the line is shown in the conversation's Details). The only step useful with the setting OFF. Was S4/S5; D7, D8 fold in here.source / confidence / status / refs / unitId / at / by / via on the pointer; the four writer sites and the backfill stamp them; retraction removes the index keys and sets status; readers and the access gate filter status=active; a staff route gated on scope over the property, reusing the existing scope helpers; the Property-column chips. Was S1 + S3, split 3a storage / 3b route + chips if needed.lead.received, a conditional put of the D1 tag with refs and unitId; eval: a synthetic two-inquiry card yields one thread, two active tags, and B's tab lists it; mutation control: a tag sourced from ProspectInquiry.propertyId fails. S9: #11524 as written, intent keys carrying the panel id. S11: sync-driven retraction (moved/vanished; Active/Inactive waits on question 7). S12: action-target guard, application send first, then work orders and tours.load-conversation-detail.ts, mirroring the application-sent join beside it). The first instance of the D4 reader rule; independent of the setting.backfill/guessed; then flip the property tab and the access gate to the index.caller-said tag retracts a prior identity guess if question 2 says so; shared-device marker stored and its identity tags retracted in a batch (question 4). Closes G6, G7.Not in this order, by design: the ORG# re-key (alternative C) and the company-mailbox mint (G8). Both are real, both are separate decisions, and neither blocks the tag model.
origin/main at 3068aefbd| Where | What it shows |
|---|---|
| src/lib/data/dynamo/conversation.ts:100-108 | convMetaPK(propertyId) — the property is the partition key; blank → PROP#UNASSIGNED. |
| src/lib/data/dynamo/conversation.ts:321-323, 334-470 | isUnboundHome counts only blank/UNASSIGNED as unbound (so the umbrella is "bound"); bindHome is the only writer of propertyId after birth, refuses filed rows without the explicit label seam, refuses re-points (already_bound_elsewhere). Pinned by src/__tests__/bind-home-single-writer.drift.test.ts. |
| src/lib/data/dynamo/conversation.ts:1512-1518, 2931, 3040-3042 | Pointer written only when no row stood at the key; merge carry (carryConversationHomes); merge revert records/forgets. Four live writer sites with the label door. |
| src/lib/data/dynamo/conversation-home-gsi.ts:9-18, 82-91, 126-141 | Pointer item shape; why items not an attribute; homesOfConversationMetaRow skips only UNASSIGNED. |
| src/lib/data/dynamo/conversation-home-index.ts:7-24, 82-94 | Lifecycle (save, label, merge, delete); a repeat put overwrites the item; forget is a hard delete; writes are best-effort. |
| src/lib/data/dynamo/label-filed-conversation.ts:1-40, 322, 351 | Platform-admin-only label door for the legacy cohort, with undo. |
| src/lib/domain/conversations/canonical-conversation.ts:60-110, 126-167 | Destination-property hard-scope; joinAcrossHomes lifts it for a person-tier match; join is by personId, else the phone fingerprint. |
| src/lib/domain/conversations/person-first-join-setting.ts:12-34, 41-84 | Org-level setting, zero rows, fail-closed, do-not-write gate. |
| src/lib/domain/conversations/message-home.ts:21-41; agents/clara/lib/agent/conversation-manager.ts:4083, 5488-5497 | Message.homePropertyId stamped only on a crossing turn; arrivedAtPropertyId = leasingContext?.propertyId — the dialled-number map on SMS, the text resolver otherwise; no pointer written there. |
| src/lib/domain/conversations/home-conversations-read.ts:65-103; conversation-home-access.ts:1-40, 69-92 | Property tab = partition ∪ index, fails safe; by-id gate admits a pointed property only with the row's own company stamp and the setting on; reads every pointer (no status filter). |
| src/lib/data/dynamo/conversation-list-gsi.ts:44-53 | CLIST#<org> keys ignore the property; UNASSIGNED cohort unstamped by design. |
| src/app/api/conversations/route.ts:138, 260-272, 643-644 | ?propertyId= filter is c.propertyId === propertyId || homeJoined.has(c.id); no line filter exists. |
| src/app/api/voice/personalization/route.ts:761-806, 866-908, 1932, 1976, 2116-2123 | Head-then-map resolution; homeNamedByLine latch; org-wide identity adoption on an org-scoped line; row minted at ring; activity entry carries no dialled number. |
| src/lib/domain/voice/home-state.ts:35-43, 195-216 | home_state unknown/suggested/known; the company line is registered on an umbrella row with a non-empty property id and scope 'org'. |
| src/lib/data/dynamo/voice-call-scope.ts:36-44, 57-59, 71, 95 | Call-scope row: write-once at ring for a FLIPPED address, 24h TTL, carries line. |
| src/app/api/voice/call-ended/route.ts:540-560, 818-855 | Post-call creation; unmapped → UNASSIGNED "Unknown Caller", counted not refused. |
| agents/clara/lib/messaging/process-envelope.ts:288-322, 369-376; inbound-router.ts:177 | SMS: dialled number → property via the same lookup; personFirstJoin; deliveredToAddress threaded in memory only. |
| agents/clara/lib/messaging/process-pms-email.ts:323-331; agents/clara/lib/email/process-inbound-email.ts:1291-1324 | Guest card carries the listing's property; ORG#-scoped mailbox falls through — "the mint's job". |
| src/lib/domain/messaging/outbound-log.ts:572-680 | ensureConversationForOutbound finds or creates the thread at ProspectInquiry.propertyId. |
| src/lib/domain/pms/writers/guest-card.ts:1119-1175, ~2582-2587; guest-card-outbox-drain.ts:~1570-1584, ~1640-1800; guest-card-mirror.ts:357-365, 946-1000; clara-first-email-intent.ts:23-43; guest-card-event-home-scope.ts:111-190; guest-card-writer.ts:541-551; types.ts:619-680 | One ProspectInquiry per card, never re-homed; panel list on af.inquiries[]; per-inquiry lead.received; assign fans out to every ACTIVE inquiry; first-email intent keyed on the single row; append_note posts to the card. (Sweep lane's read at deabcbfb34 / 538e8677b5.) |
| src/lib/integrations/voice/shared-device.ts:1-36; escalated-call-posture.ts:449-460 | Shared-device heuristic, no stored marker, skips cross-property widening. |
| src/lib/domain/properties/phone-lookup.ts:1-70; config/phone-registry.json; listing-resolution.ts:63 | Registry-driven number → property map; the company line is a property-line on an umbrella row; isPortfolioProperty. |
| docs/planning/portfolio-architecture/how/core-contract-org-anchor-inventory.md:1-45 | A′: record owner + co-manager; getOrgIdForProperty returns the record owner only. |
src/lib/data/dynamo/conversation-address.ts:60-76; src/lib/data/types.ts (Conversation.scope, Message.homePropertyId ~9297-9304) | ORG#/GROUP# scope declared, written by nothing; the message label's docblock names the ADR-0040 mirror. |
Not re-verified: the docs' defect cite messaging.ts:355; whether any staff surface lists the PROP#UNASSIGNED cohort; the exact index write cost; that the 2026-08-13 cross-property audit closed specifically an overwrite-on-second-property leak (the audit exists: tools-leasing.ts:733, 8418). Sources: portfolio-architecture-how (its organization-architecture-how alias is a build redirect) — its "born in the company partition / bindHome single writer / CONVPROP#" scenarios are stamped archived, cited as recorded direction; the person-conversations dock; the independent grade and the 69-item issue sweep (lane files, 2026-09-24).
Design lane pc-home-design for Driver-3, session d2b30ebd. First published 2026-09-24; revised the same day with Gera's decisions. No PRs from this lane, no production writes.