Flexible Conversation Property

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.

In plain English

  1. Today every conversation is filed under exactly one property, and that one property is also where the record is stored. So it can never be blank, never be two, and never be corrected. We're making it flexible: a conversation can be about no property, one, or several, and each one is a tag you can see, trust or remove.
  2. Each property tag says who put it there and how sure we are — the number they called, an AppFolio inquiry, a lookup, Clara's reading of what the person said, or a staff member. Known tags show plainly; guessed ones show faded. Removing a tag never deletes anything and never moves the conversation, and it can be undone.
  3. A "Called" column filter shows which of the company's numbers or mailboxes the person reached, by its friendly name. That works even when every property tag is wrong, which is the thing Gera asked for. Today the number called is not written down anywhere that lasts. amended 2026-10-01 Gera, 2026-10-01 (block b90781494): filter only, shown when the company has more than one line, plus a Called field in the conversation's Details — no column; the column shipped in #11579/#11594/#11597 was reverted by #11903.
  4. Guest cards were the big miss. One AppFolio card can hold several inquiries, one per property. Each inquiry becomes its own known tag carrying the AppFolio inquiry id, and every per-inquiry action (assign, first email, a future note) uses that same id — so the second property's inquiry finally gets its reply.
  5. Staff win. If a staff member removes a property, Clara's next message cannot quietly put it back. Only a staff undo can.
  6. The database index that lists conversations by property is not created yet. It waits on one schema question (whether the Called line lives in the same index); Gera has green-lit the writers that make it useful.

What changes on the conversations page

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

PersonProperty (today)Property (after)Called (new)Last message
Dana R.12 Alder Ct12 Alder Ct 40 Birch LnLeasing line2h ago
Marco T.Northwind front door (the umbrella)—Leasing lineyesterday
Priya S.7 Cedar Way7 Cedar WayMaintenance line3d ago
Lee W.40 Birch Ln40 Birch Lnleasing@ · Leasing line1w ago

The answer, in one picture

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.

1 · Where it lives

The company

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.

2 · What was called

The line

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.

Leasing lineMaintenance lineleasing@

3 · Which properties

None, one or many

A set of tags. Each has a source, a confidence, a status and, for an AppFolio inquiry, the inquiry id. Adding one never moves anything; removing one never deletes anything.

12 Alder Ct · line · known40 Birch Ln · inquiry #4471 · known7 Cedar Way · lookup · guessed9 Elm St · text · guessed

The rules the design is tested against

  1. Tags never hide. A conversation shows on the company list and under the line it arrived on whatever its tags say. A property's Conversations tab is a view over the tags — it can be wrong, and being wrong there costs nothing elsewhere.
  2. Every tag says who and how sure. 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.
  3. Staff-retracted beats Clara-added, until staff undo. Every automatic writer is a conditional put — 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.)
  4. Provenance comes from the resolver, never hard-coded. The per-turn writer records whatever named the property this turn: the dialled-number map (a line fact, known), an AppFolio inquiry (known), an identity lookup (guessed — a ring-time suggestion is never more than a guess), Clara's extraction (likely), a text match (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.)
  5. Correction is retract + add, with undo. No partition move, no delete. The retracted tag stays on the record so the audit and the undo both work. This is a click for staff once built; the build behind it (a scope-gated route, undo, precedence) is not trivial.
  6. A line tag is known only when the line maps to exactly one property. A shared or ambiguous number yields no line tag at all — the line fact is still recorded. (Sweep D8.)
  7. The company wall does not move. Tags widen which property pages a thread appears on, inside one company. Text-derived tags are drawn only from the roster of the company that owns the line; a line that names no company yields no tag. (Sweep D9.)
  8. Clara's turn follows the message's property, not the thread's. Already merged as turnPropertyId(). The same seam writes the tag, so message label and tag never disagree.
  9. Writes need a property too. No PMS write, inquiry, tour or card takes its property from the umbrella or from 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.)

Where "just add a column" fails

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.

It is already the column — and it is the address.

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.

One value is not "none, one or many".

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

It cannot answer "who called this number".

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.

It carries no confidence.

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.

Every case, not the happy path

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

CaseToday — merged codeAfter — this designCorrecting 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: CLIST#org. Correct and unchanged by the setting.

Identical outcome as facts: Called = Cedar's line; tag {Cedar, line, known}. Single-property customers are the control: behaviour-identical (the stored line field is new for everyone, so not byte-identical).

Nothing to correct — the line named the property. Staff may add a second tag; a line/known tag is removed only by staff.

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 'org'. When the caller names 40 Birch Ln, the tour and inquiry are created on B, but bindHome refuses to move the conversation (umbrella ≠ unbound). B's tab never shows it; no B pointer is written. gap The umbrella partition is, accidentally, the closest thing to "everyone who called this number" — and it holds only the callers Clara did not recognise (case 6).

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, caller-said, likely}; the conversation does not move; B's tab shows it via the tag. A call that names nothing stays tagless and is still findable by line and by person.

Staff: Remove B (kept as retracted) or Add A ({A, staff, known}). Undo re-activates. Nothing moves.

3 · Caller asks in general; no property, ever

verified Company line: parked under the umbrella forever. Unmapped number: post-call the row lands in PROP#UNASSIGNED as "Unknown Caller"; the CLIST index skips that cohort by design; no pointer. not verified whether any staff list surfaces the UNASSIGNED cohort.

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, staff, known}); adding a tag inside your own company grants nothing new.

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 Message.homePropertyId=B; Clara's reads, tools, work orders and SMS lanes follow the turn's property (#11294/#11317/#11318/#11320). gap No B pointer is ever written, so B's tab and B-scoped staff access — built on the pointers — come up empty for the very case they exist for (G1).

Two tags: {A, per how A was learned} and {B, caller-said, likely} — written by the per-turn seam with the resolver's provenance, put-if-absent. Both property pages show the thread; each message keeps its own label.

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 (home_state:'suggested') and the row is born there; the leasing-context backfill binds an unbound thread to whatever the text resolver named. Once bound, bindHome refuses every later candidate. No unbind, no staff correction surface; the only door is platform-admin-only and for the legacy UNASSIGNED cohort. A wrong guess is permanent.

The guess is a faded tag {A, identity, guessed}. When the person says Birch, {B, caller-said, likely} is added. Whether B's arrival auto-retracts A is open question 2; either way the thread was never trapped — it was on the company list and under its line throughout.

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, identity, guessed} — suggested, never asserted; Clara confirms ("Is this about Alder Court again?"). A yes upgrades it to caller-said/likely with the confirmation recorded; a no retracts it. Two phone numbers for one person join by personId once both claims resolve; until then the phone tier treats them as two people — an identity dependency, not a tag one.

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: looksLikeSharedDevice infers it per call (≥2 distinct names, >8 calls in 14 days, or a number the company itself publishes); a "shared" verdict only switches the cross-property widening off.

A line tag is fine here (the callbox rings the property's own line: line/known). A shared device's Person never yields an identity tag and never joins across properties. Store the marker on the phone claim, staff-settable, reason recorded.

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 ORG#-scoped mailbox falls through to the old matcher; binding it to a property is "the mint's job" and not built. The platform catch-all matches property names against every company's roster (#10483).

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 guessed, drawn only from the line's own company roster (D9).

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; getOrgIdForProperty returns the record owner only. The setting is read for the line's company; the by-id gate admits a pointed property only when the thread's own company stamp names that property's record-owner company. A co-manager's staff would be refused even with a tag. A consider site, not a defect.

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 ensureConversationForOutbound finds or creates the thread at ProspectInquiry.propertyId; outbound legs carry the person we rang as both caller and called number, so nothing names a line; outbound first-email messages carry no property label.

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, identity, likely} for a renewal call to a resident. The stored line is also the natural "reply from the number they were contacted on" source.

Same Remove/Add.

11 · A thread is deleted

verified deleteConversation → forgetAllConversationHomes: a hard delete of every pointer.

The CONVHOME# partition stays as a tombstone: index keys removed so no property lists it, rows kept with status: deleted so who tagged and who removed remains answerable. Hard delete only when the thread itself is purged for a data-erasure request.

n/a — the audit trail is the point.

12 · A property is offboarded or no longer managed

verified The gate's organizationOf(property) returns null and it skips safely — but the tag stays active, the property page is gone, and nothing retracts.

Offboarding a property retracts its tags by system:offboard (reason recorded, undoable) so the chip disappears from the column; the line facts and the company-list row are untouched.

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 identity-sourced tag on the affected threads by system:person-split; inquiry tags follow whichever person the AppFolio card lands on; line and staff tags are facts and stay.

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, staff, known, by the creating staff member}. Source staff already exists in the enum; the admin-create writer is one of the writer sites step 3 touches.

Same Remove/Add.

Which page shows what, after

SurfaceReadsResult when every tag is wrong
Company conversations listCLIST#<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 lineStill lists the thread — for viewers allowed to open it.
Person pagethe person's threads (merged; effective-property rule in load-person-conversations.ts)Still lists the thread.
Property columnactive tags; "—" for none; umbrella hiddenShows the wrong chip until removed; the row never disappears from the list.
A property's Conversations tabactive tags for that property (index, filtered status=active) ∪ today's partition while the re-key has not runShows on the wrong property until removed.
Staff opening a threadthe merged gate (conversation-home-access.ts) with a status=active filter, plus the Called-filter admissionAdmitted while a tag or line the viewer is scoped to is active; the list and the page always agree.

Guest cards: one inquiry, one tag, one id

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.

  1. The inquiry tag comes from the panel row, never from 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.
  2. Tag and AppFolio write are one join (D2; #11524, #11146, #11402, #9150). The 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.
  3. Confidence ladder for cards and text (D3; #11380; Fable 5.1, 2026-09-24): a panel inquiry → 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.
  4. Readers key on the inquiry's own property, never on 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.
  5. The sync may retract (D11; #9674, #9678). An inquiry tag whose AppFolio inquiry moved to another property or vanished is retracted by system:sync, reason recorded, undoable. Whether Active/Inactive flips retract or re-activate the tag is open question 7 (#11170).
  6. Action target on a multi-inquiry card (b90204814): act on the inquiry behind the turn's property; a system action with no turn property (assign) acts on every ACTIVE inquiry — exactly what #11433 does; never act on a card-level scalar. The block's "which inquiry" question answers itself under this rule; re-read card 2782 after #11433's deploy before ruling.
  7. Backfill (D10; #11245, #10259): for guest-card threads derive tags from the mirror's panel rows where present (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.
  8. Where the prospects funnel reads from: the 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 tag row, and the verdict on the parked index

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)
Writes are conditional. First write: 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).

Verdict on conversation-home-index before it is created in production

  1. Keys and projection: keep. CHOMEPK/CHOMESK with projection ALL is right; the new fields are non-key attributes, so the GSI never has to change for them.
  2. What actually blocks creation is one schema question: whether the Called line is a second partition family in the same index (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.
  3. Writers first, for value not safety. Four live writer sites — save (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.
  4. The backfill skips the umbrella. 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'.
  5. Retention: tags and line rows live as long as the conversation. The 24-hour call-scope row is not the record of the line.

What the merged code lacks

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.

Design additions from the issue sweep

Issues this design now catches

IssueWhat went wrongNowWhere
#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.caughtjoin rule + case 4 + guest-card tags (D1, S8)
Harness case 18 thread-lost-after-mid-conversation-bindOnce 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.caughtCalled column and filter, step 1
#11524 / #11146Second-property inquiry resolves to the first property's row and is skipped; property B never gets a reply.caughtD2, S9
#11394 / #10603 / #11507Original-inquiry join, summary refresh and first-email roster key on the thread's property, not the inquiry's.caughtD4, S10 (ships now)
#11380 / #10483Four properties named in card text mint one inquiry and bleed facts; the catch-all matches names across every company.caughtD3, D9
#9772 / #9491 / #9280Writes keyed on the umbrella shell or a sibling's property.caughtD5
#9857A card with no property: no application send, no park, no notice.caughtD6, S12
#9674 / #9678 / #11170A card moved or deleted in AppFolio silently disappears; Inactive→Active is never followed.caught (Q7 open)D11, S11
#9501home_state='suggested' has no consumer.caughtG6 → confidence; case 6; step 7
Harness 2, 11, T14A shared number silently becomes one property's line by roster order.caughtD8
Callbox (memory; #11287's skip)One phone claim is one device; person-derived facts are poisoned.caughtcase 7, step 7
#11286 / #11296Property-scoped staff see a joined thread in the list but cannot open it.kept; refinedgate filters status=active; Called filter scope-intersected
b90188076"Create the index in prod now."waitopen question 6 first, then step 6
#10562Email bypass counts property-less threads as "under another property".partlycase 3; the one-line guard is still owed until readers use tags
#9343 / #9462 / #10515Duplicate Persons from one number or a new phone.dependencyidentity 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.

Alternatives considered

A · One more column
Fails on correction, on many, on the line, and on confidence. It is also already there. Gera's ruling: no new column; the existing one becomes chips.
B · One conversation per (person, property) — today's behaviour with the setting off
Rejected by the owner's rule ("everything should flow to the human") and by the observed harm: a second-property inquiry opens a new chat that forgets the first.
C · Re-key every conversation to the company partition (ORG#) now, then tag
The right end state per the docs' §3.5; Conversation.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.
D · A list attribute of properties on the conversation row
Cannot be indexed by property in DynamoDB, races with every partial-patch writer of the row, has no per-entry audit. Pointer items stay. The line set on the row is the deliberate exception: append-only, atomic ADD, never a whole-row put, never a partition key.
E · Let the classifier move the thread and trust it
The re-point guard exists because a weak identity match once moved a live conversation to another property. Guesses are faded tags, never moves.

Decisions (2026-09-24)

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.

  1. Who may add or remove a property tag? Any staff member whose scope includes the property, inside their own company. Built.
  2. Does the person naming a property retract a lookup guess? Yes, recorded as caller-said. Built.
  3. The company line's umbrella placeholder. End state: company-line threads are filed at the company (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.
  4. Callbox marker: stored on the phone claim, staff-settable. Built.
  5. Co-managed property: both companies' staff see threads tagged with it. The co-manager is primary, using the existing co-management record (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)?
  6. The number called: stored as the real number (E.164, or the lowercase mailbox), never a short code. It lives on the conversation row (the 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.
  7. AppFolio Active/Inactive: the tag stays; the inquiry's status is stored on the tag and rendered greyed with an Inactive badge, filterable. Only a moved or deleted inquiry retracts (D11), because a retract-then-restore would exempt the tag from later sync retraction.

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.

Built so far (2026-09-24)

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.

StepWhat is now truePRs
1 · Called lineCalls, texts and email record the number or mailbox reached; a Called column and filter on the conversations page, limited to conversations the viewer may open, shown only when the viewer's conversations carry more than one line, plus a Called field in the conversation's Details. (Recording is live; not behind the setting.) The column and filter from #11579/#11594/#11597 were reverted by #11903 (Fede: "I never asked for that"); the filter and the Details field came back on Gera's decision of 2026-10-01 (block b90781494), filter only.#11579, #11594, #11597 · reverted #11903 · filter + Details field: see the PR linked from the row's board entry
2 · Per-turn linkA joined conversation is recorded under every property a message moves to, and never overwrites an existing record.#11571
3 · Tags, retraction, staff controlsEvery 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 cardsOne 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 · #11394A guest card shows the words the person wrote about the property in question. (Live.)#11574
Actions need one propertyApplications, 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
ProvenanceA 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 callboxesA 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 indexNot 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.

Build order — green-lit

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.

  1. Called line — in progress. Persist the line on every inbound (and outbound) message and as an atomic-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 column and a scope-intersected filter over the company list (as amended 2026-10-01: filter only, shown when the company has more than one line, answered from the 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.
  2. Per-turn pointer, put-if-absent — in progress. The turn-crossing seam records a tag the first time a thread crosses to a property, with the resolver's provenance, conditional on the row not existing. Closes G1. Was S2; grade must-fixes 1 and 2.
  3. Tag fields, retraction, staff add/remove/undo. 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.
  4. Guest-card tags per inquiry (S8), then S9, S11, S12. S8: on each per-inquiry 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.
  5. #11394 fix (S10) — can ship now. The original-inquiry join keys on the inquiry's own property (~30 lines in load-conversation-detail.ts, mirroring the application-sent join beside it). The first instance of the D4 reader rule; independent of the setting.
  6. Create the index in production (human go), then backfill. Through the decider with table, index name, projection, write cost and rollback, after question 6 is answered; the backfill skips umbrella rows, uses panel rows for guest-card threads (D10), stamps the rest backfill/guessed; then flip the property tab and the access gate to the index.
  7. Guess handling and the callbox marker. Identity tags render faded; a confirmed 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.

Second opinion — Astra, and the independent grade

Claims about code — verified on origin/main at 3068aefbd

WhereWhat it shows
src/lib/data/dynamo/conversation.ts:100-108convMetaPK(propertyId) — the property is the partition key; blank → PROP#UNASSIGNED.
src/lib/data/dynamo/conversation.ts:321-323, 334-470isUnboundHome 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-3042Pointer 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-141Pointer item shape; why items not an attribute; homesOfConversationMetaRow skips only UNASSIGNED.
src/lib/data/dynamo/conversation-home-index.ts:7-24, 82-94Lifecycle (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, 351Platform-admin-only label door for the legacy cohort, with undo.
src/lib/domain/conversations/canonical-conversation.ts:60-110, 126-167Destination-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-84Org-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-5497Message.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-92Property 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-53CLIST#<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-2123Head-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-216home_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, 95Call-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-855Post-call creation; unmapped → UNASSIGNED "Unknown Caller", counted not refused.
agents/clara/lib/messaging/process-envelope.ts:288-322, 369-376; inbound-router.ts:177SMS: 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-1324Guest card carries the listing's property; ORG#-scoped mailbox falls through — "the mint's job".
src/lib/domain/messaging/outbound-log.ts:572-680ensureConversationForOutbound 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-680One 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-460Shared-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:63Registry-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-45A′: 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.

PropFlow Docs