Why the Settings knowledge page looked blank in the 11-03 demo, what shipped today, and the design decision Gera needs to make.
2026-11-04 · Settings, Clara knowledge, ingest · design pass on Fable 5 (read-only) · part 1 live in prod
Clara already knows the building's rules. The Settings page only shows part of what she reads, so it looks empty. The fix is to make Settings a window onto the same store Clara reads, and keep ingest as a proposals layer a person confirms.
Building topic cards now show the answer Clara reads from the building's record. Example from a TEST company:
On Camellia this fills 4 cards (parking, utilities, laundry, amenities). Lease lengths and deposit tiers still look empty: showing them changes which facts Clara reads first, so it is a separate, careful PR. The application fee lives on the Fees & deposits card, not the topic cards.
| Piece | Role |
|---|---|
| Saved knowledge on each company and building | The source of truth. Clara reads it on every message. A Settings Save writes it instantly, so nothing is delayed. |
| Settings page | The window onto it. It must show everything Clara reads, not a subset. |
| Ingest | Dump, then propose. Reads every channel (AppFolio, listings, staff replies, messages, meeting notes, calls). Never writes the truth itself; a person's Save confirms a proposal and pins it. |
Ranking fix: ingest currently shows the newest read on required rows, so a dated staff answer ("application is free", Situs 11-03) loses to that morning's listing read. Rank by source authority instead, surface real disagreements to a person, and treat a Save in Settings as final.
# Where a company's knowledge lives: one source of truth, Settings as its window, ingest as proposals
Design investigation for Gera, 2026-11-04. Read-only: every prod read below is a keyed GetItem/Query on
`propflow-prod` or `propflow-ingest-sheets` (us-east-1); nothing was written. File:line cites are against
`propflowai` main `197c3315888` and `propflow-ingest` `origin/main ab4031a` unless stated.
## 0. The answer in one paragraph
The source of truth already exists and is the right one: the **registry-keyed setting rows on the company and
building nodes** (`ORG#<org>` / `PROP#<id>` + `ATTACH#setting#<slot>#<startedAt>`: `knowledge.<topic>`,
`knowledge.section.<id>`, `pets.policy`, the fee slots), resolved nearest-wins by `foldEffective`, plus the
building's legacy `PROP#<id>/KNOWLEDGE` row that the new rows are retiring field by field. Settings already
writes into that store synchronously and refreshes the voice cache in the same call, and Clara reads the live
rows on every turn — so **Settings is not "delayed" today and must stay that way**: a Save is the fact the
moment it lands. What is delayed, and should be, is **ingest**: it is a proposals layer that publishes
`PREFILL#<org>` once a day and never writes the canonical store; a person's Save (or a confirmed pick) is what
turns a proposal into a fact. The three views are out of sync for two reasons that are both fixable without a
new store: (1) the **window does not read everything Clara reads** — the new topic cards only know the new
rows, so a company whose facts live in the legacy row (Camellia) shows blank while Clara answers fine; and
(2) ingest's proposals reach customers only on the setup-intake pages and staff-only hints, with a winner rule
that lets today's listing read outrank a dated staff statement. The design below keeps ONE store, makes
Settings a complete window onto it (legacy facts included), makes ingest a first-class "proposed" state on
every card with authority-then-recency ranking and human confirmation that pins, and feeds ingest from every
channel. Clara reads canonical only; a proposal reaches her only after confirmation.
Founders' statements this honours: Gera 10-05 ("knowledge base and settings both touch the same thing, so if
you duplicate that interface, it's confusing because you don't know where to look"; "I hate how open-ended a
lot of our knowledge base is"), 10-07 ("it's not like the settings was actually hooked up to our code"), 10-16
("how far does the settings page go then? Because we do want some settings"), 11-03 ("settings knowledge base
isnt the backend, its the window to it… the ui isnt hooked up to what clara sees"); Fede 10-15 ("Dump it in one
place. Step one. Then step two is, from that dump, what actually useful information I can put in the knowledge
base clean"); the 10-20 standup (meeting notes → private internal brain → ingest fills empty fields).
---
## A. Every store a company/building fact lives in today
| # | Store (prod key) | Shape | Who writes | Who reads | When |
|---|---|---|---|---|---|
| 1 | `PROP#<id>` / `KNOWLEDGE` (entityType `PropertyKnowledge`) | `sections[]` (free text, `source`: lease / manual / PM / `staff_reply`), `pricingDetails.*` (applicationFee, adminFee, securityDeposit, pet*, parking, lateFee…), `pricingVerified.<fee>` stamp, `pricingSources.<fee>` provenance, `leasePolicy` (typed: deposit tiers, applicationFeePerApplicant, concessions, utilities, applicationProcess), `concessions`, `amenities`, `neighborhood`, `officeHours`, `utilities`, `holidayPolicy` | Building editor `PATCH /api/properties/[id]/knowledge` (`src/app/api/properties/[id]/knowledge/route.ts`, whole-schedule rebuild); Fees page `PUT /api/properties/[id]/policies` (`…/policies/route.ts:1-60`, one field, optional `confirmed` → `pricingVerified`); staff-reply teaching (`src/lib/domain/escalation/teaching-capture.ts:1-40`, `writeRememberedPolicy`, property tier); customer review submit (`src/lib/domain/leasing/knowledge/apply-knowledge-review-writer.ts:1-20`); rent-roll charge aggregator (`src/lib/domain/policies/charge-fee-prefill.ts:219-236`, writes `pricingSources.source: appfolio-charges`, skips stamped fees `fee-prefill-fresh.ts:20`); public-listings fee pre-fill (`listing-fee-prefill.ts:171-187`, `appfolio-listings`); website concession watcher (`concessionSource: website`, `lastScrapedAt`); gold-set loader (`scripts/knowledge/load-knowledge-gold-set.ts`) | Clara every turn: `agents/clara/lib/agent/conversation-manager.ts:7417` (`getPropertyKnowledge`), `:7509` (`buildLeasingKnowledgeBlock({orgSections, propertySections})`), `:7514` (`pricingDetails`); `clara-unified.ts:1824` (`renderPropertyKnowledgeBlock`); voice via `leasing-context-injection.ts` (`formatPricingFees` walks `PRICING_FIELD_DESCRIPTORS`, `property-knowledge-block.ts:119-232`); core-topic "older record" projection (`core-topic-sections.ts` `OLDER_BUILDING_RECORDS`); Settings building column (`settings/property/knowledge/client-page.tsx:41` via `/api/properties/<id>`); Fees page GET (`policies/route.ts:139-170`) | On demand (saves), every 15 min (listings/rent-roll crons), hourly website scrape; read on every Clara turn |
| 2 | `ORG#<org>` / `ATTACH#setting#knowledge.section.<id>#<t>` (one-element array `[{title, body, …}]`) | Company free-form entries | `PUT/POST/DELETE /api/settings/knowledge` (`src/app/api/settings/knowledge/route.ts:30-42`, `putSettingAttachment`, `setBy.source: settings-api`); staff-reply teaching at company tier (`team-answer-knowledge.ts:18-28`); seed/gold-set scripts (`setBy.source: script`); core-topic PUT absorbs them (`/api/settings/core-topics/[topic]` "FOLDED ENTRIES") | Clara: `loadOrgKnowledgeSectionsSafe` (`conversation-manager.ts:7453`, `:10111`) → `getEffectiveKnowledgeSectionsReport` → `buildLeasingKnowledgeBlock` (`leasing-knowledge-block.ts:84-88`: "rows exist in production since 2026-09-15"); Settings company column (`settings/knowledge/client-page.tsx:72,259`) | On save; read every turn |
| 3 | `ORG#<org>` or `PROP#<id>` / `ATTACH#setting#knowledge.<topic>#<t>` (the ~50 core topics, registry `knowledge.<fact>`, `keys.ts:2068-2075`: nearest wins, replace) and `pets.policy` | Typed answer per topic (`TopicSpec` fields, `topic-spec.ts:1-20`) | `PUT /api/settings/core-topics/<id>` (company) and `PUT /api/properties/<id>/core-topics/<id>` (building) via `writeTopic` (`pets-topic-store.ts`), `refreshCompanyVoiceCaches` in the same request; Knowledge intake pages write the SAME slots (`knowledge-intake.ts:9-14` "ONE HOME PER FACT … Nothing is stored twice"), `setBy.source: settings-knowledge-intake` | Clara: `loadCoreTopics` (`core-topic-sections.ts:319-330`: org rows + building rows, masked by the building's OLDER record fields `olderRecordFieldsFor(k)`) → appended to company sections; Settings topic cards (`CoreTopicCard.tsx:10-12, 332, 498`); ingest reads them back (`connectors/propflowai/knowledge-intake.ts`, #324–#327) | On save; read every turn |
| 4 | `ORG#<org>` / `ATTACH#setting#setup_intake.collected.<slot>`, `setup_intake.handoff.<field>`, `setup_intake.answer_changed.<field>` (`keys.ts:391, 889, 918`) | Free-text collected answers; hand-off and change stamps | Setup intake pages (`collected-answers-store.ts`); `stampAnswerChange` on fee saves (`policies/route.ts` import) | Only "How Clara talks" slots reach Clara (`company-voice.ts`); Settings shows them (`SettingsCollectedAnswers`, `knowledge-policies-page.tsx:54`); onboarding pending read (`onboarding-answers-read.ts`) | On save |
| 5 | `ORG#<org>` / `ATTACH#setting#knowledge_intake.prefill-seen-*` and `knowledge_intake.utilities-by-building` | Which ingest pre-fills a customer has seen/kept | Knowledge intake | Knowledge intake; ingest (`#324`: "a kept pre-fill is not customer-typed") | On view/save |
| 6 | `PROP#<id>` / `VOICE_INJECTION_CACHE` (`src/lib/data/dynamo/settings.ts:311-328`, LWW, 26h staleness `leasing-context-injection.ts:116`) | Rendered voice read-model | Write-through on every knowledge save (`save-knowledge-and-refresh-voice-cache.ts`, `refreshCompanyVoiceCaches`); nightly refresh | ElevenLabs personalization webhook per call | Per save / nightly / per call |
| 7 | `propflow-ingest-sheets`: `SHEET#<org>` / `ROW#<policy_id>` (+ `META`), `RUN#<org>`, `DECISION#<org>`, `RECIPE#<org>`, `PREFILL#<org>` / `v1` | Per policy: value, path, source, seen_at, conflicts[], attempts[], confidence (`sheet/README.md`, `sheet/authority.ts`); PREFILL = the one contract propflowai reads (`src/lib/data/dynamo/ingest-prefill.ts:5-25`) | Daily launchd `co.propflow.ingest-daily` 05:30 local for the ids in `~/.propflow-ingest/companies` (today: Situs, Western Slope — **not** `org_jpco`); staff Teach-it / Pick-the-winner in ingest.propflowai.co | propflowai setup-intake suggestions (`ingest-suggestions.ts`), Settings staff-only hints (`ingest-topic-hints.tsx:16-21`), `IngestHistoryCard`, Knowledge intake building evidence; `live-oncall.ts` (emergency chain) | Daily; read on page load |
| 8 | Company brain `ORG#<org>` internal notes (`src/lib/domain/company-brain/internal-notes.ts:1-9`, "Store only — nothing reads it yet") | Private notes (manual / email_forward / meeting) | Nothing customer-visible | Nothing yet | — |
| 9 | Local seed `data/*.json` | dev only | — | — | never prod |
Two facts the table makes plain: **Settings and Clara already share stores 1–3** (no second backend), and the
only store that is "delayed" by design is 7 (ingest), which nothing in Clara reads directly.
---
## B. Why Camellia's Knowledge & policies page was blank on 11-03 (and still is)
Camellia Apartments = `PROP#1773625953462`, `organizationId: org_jpco` (prod META row). Yale 25 Station =
`PROP#1773625952029`, same org. Fede, DM 11-03 16:13 CST: "we tried to demo the camellia knowledbase and it was
all blank"; the demo was likely Sean driving `propflowai.co/settings/policies` (Gera's DM links that page; the recap says Sean drove) (transcript: "I think you guys just kind
of changed this UI a little bit").
**Layer 1 — the page lands on the Company column, and `org_jpco` has no company-tier knowledge at all.**
- `/settings/policies` is a `'both'` page (`settings-page-sides.ts:47`), so `useOpenOnABuilding` is inactive
(`SettingsTopicPage.tsx:209-221`); "Nothing picked focuses Company: no column-less view"
(`use-topic-scope.ts:11`); one column is drawn at a time (`SettingsTopicPage.tsx:223-230`, Gera 2026-10-03).
- Prod read today (`Query PK=ORG#org_jpco, SK begins_with ATTACH#setting#`): **7 rows, zero knowledge rows** —
modules enabled, call-list recipients, office hours, a call-routing line, a rollout marker, a company-policy
pin. No `knowledge.section.*`, no `knowledge.<topic>`, no `pets.policy`, no `setup_intake.collected.*`.
- So every card in `CompanyBody` (`knowledge-policies-page.tsx:44-66`) renders its empty state: collected
answers (none), Pets grid ("Empty"), ~50 core topic cards ("No answer saved yet, for the company or this
building." `CoreTopicCard.tsx:494`), custom policies (none), company knowledge list
(`EMPTY_COMPANY_KNOWLEDGE`, `company-knowledge.ts:40`).
- Contrast: Western Slope `ORG#org_c42d9150…` holds **32** `knowledge*` rows (20 `setBy.source: script` on
2026-09-15 = `scripts/apply-western-slope-knowledge-sections.ts`, then knowledge-intake and settings saves);
Situs `ORG#org_a3376147…` holds 6 (plus one `knowledge_intake.prefill-seen-*` marker). Camellia was live before the company tier existed
(2026-09-15, `leasing-knowledge-block.ts:84`) and never got an equivalent seed. That is Gera's "it never got
the backfill".
**Layer 2 — even on the Camellia building chip, the new cards do not show what Clara reads.**
- Camellia's facts are all on the legacy row `PROP#1773625953462/KNOWLEDGE`: **12 live sections** (Pet Policy,
Smoking Policy, Laundry, Lease Terms, Finding the Leasing Office, Visiting a Resident, Security Deposit,
Co-signers & Guarantors, Resident Buzz-In Code, Move-In Special on the Lease Document, Utility Bills,
Parking Availability & Overflow; sources lease/manual/PM), `pricingDetails` (applicationFee 38, adminFee 0,
securityDeposit 300, petFee 250, petDeposit 100, petRent 35, parking 80–95), `leasePolicy` (deposit tiers
300/400, applicationFeePerApplicant 38, 6/12-month terms, utilities via a third-party biller, guarantor
process), concessions (1 month free, website-scraped `lastScrapedAt 2026-11-04T13:01Z`), office hours.
`pricingVerified` and `pricingSources` are null (never confirmed, never machine-sourced). Yale's row: 5 lease
sections and a full fee schedule.
- Clara reads all of it: `conversation-manager.ts:7417/7509/7514`, `loadCoreTopics` masks each topic's fields
with `OLDER_BUILDING_RECORDS` (`core-topic-sections.ts:52-60`) and `leasePolicy` substitutions
(`property-knowledge-block.ts:351-375`).
- The building topic cards do not: `GET /api/properties/[id]/core-topics/[topic]` exposes `olderRecord` for
**`petPolicy` only** (`route.ts:65-70, 91`). Every other topic card on Camellia's chip says "No answer saved
yet" while Clara answers from the record. The bottom-of-page `BuildingKnowledge` editor
(`settings/property/knowledge/client-page.tsx:41`, `PropertyKnowledgeEditor.tsx:361-376`) is the only
place the 12 sections and fees show.
**Is it still blank now?** Yes. The `ORG#org_jpco` read above was taken 2026-11-04; nothing has written a
knowledge row there. `ingest` has never run for `org_jpco` (`~/.propflow-ingest/companies` lists Situs and
Western Slope only; `GetItem PREFILL#org_jpco/v1` and `SHEET#org_jpco/META` → no item), so the staff-only
found-value hints are empty too — but that is a separate, smaller gap than the one Fede saw.
Not ruled out from the stores alone: a platform-staff viewer with zero or several companies picked gets the
"pick one company" notice rather than blank cards (`client-page.tsx:49-53`); the DM wording ("all blank")
matches empty data, which is what the rows show.
---
## C. The read paths, drawn
```
WRITERS CANONICAL STORE READERS
┌───────────────────────────────┐
Settings › Knowledge & policies ── PUT core-topics ───▶│ ORG#/PROP# ATTACH#setting# │──▶ Clara every turn
(company + building cards) ── PUT/POST knowledge ─▶│ knowledge.<topic> (replace, │ loadCoreTopics + loadOrgKnowledgeSectionsSafe
Knowledge intake (same slots) ────────────────────────▶│ nearest wins) │ → buildLeasingKnowledgeBlock → prompt
Staff reply to Clara (teaching) ──────────────────────▶│ knowledge.section.<id> │──▶ voice cache (write-through) → ElevenLabs
Scripts (seed / gold set) ────────────────────────────▶│ pets.policy, fee slots │──▶ Settings cards (read the SAME rows)
└───────────────────────────────┘──▶ ingest (reads back as saved-answer 0.95)
Building editor / Fees page / review submit ──────────▶┌───────────────────────────────┐
Rent-roll + listings crons (unstamped only) ──────────▶│ PROP#<id>/KNOWLEDGE (legacy) │──▶ Clara (pricingDetails, sections, leasePolicy)
Website concession scrape ────────────────────────────▶│ sections[], pricingDetails, │──▶ building topic cards: pets ONLY (gap)
│ pricingVerified, leasePolicy │──▶ BuildingKnowledge editor (bottom of page)
└───────────────────────────────┘
▲ never
│
AppFolio (synced copy, charges, listings, marketing text), website, ┌───────────────────────────────┐
S3 history, transcripts, staff replies, knowledge-intake rows ─────▶│ propflow-ingest-sheets │──▶ setup-intake suggestions
daily 05:30, per company id in ~/.propflow-ingest/companies │ SHEET#/ROW#, DECISION#, │──▶ Settings staff-only hints + history
│ PREFILL#<org>/v1 (contract) │──▶ Knowledge intake building evidence
└───────────────────────────────┘ (a person's Save = the fact)
```
The "backfill" question, answered: the KB redo (core topics as registry rows, 2026-10) did not move data to a new
shape that Settings cannot read; it added a new shape that Settings reads **exclusively**, while Clara reads the
new shape **and** projects the old one. Nothing migrated Camellia's legacy facts into the new rows, and the
cards do not project them. The fix is projection in the window (part 1), not a data move.
---
## D. Recommendation
### D1. One canonical store, named
**Canonical = the registry-keyed setting rows (store 3 + 2), nearest-wins, with the legacy `KNOWLEDGE` row (store
1) as a retiring rung that every reader projects through one function.** No new table, no second schema. This
is already the direction of every 2026-10 PR (Pets 1b/1c, core topics P0a, knowledge-intake "ONE HOME PER
FACT"); the decision is to say it out loud and finish the projection on the read side.
Why not the ingest sheet as the backend (Gera's "it would make sense if it's on the knowledge base"): the sheet
is a **proposal** by construction — `README.md` "Nothing writes to a customer's settings without a person's
click", `CLAUDE.md` "Ingest never writes to customer settings" — and it rebuilds daily from sources that can
disagree. Making it canonical would make Clara's answers change overnight on a listing edit, which is the
"delayed and wrong" failure Gera is worried about, inverted. Keep it as the proposals layer and give it a
first-class seat on the cards instead.
### D2. Settings = the window, with no delay
- **Reads**: every card reads the resolved answer Clara reads, through ONE resolver: `loadCoreTopics` /
`resolvePricingFactView` already compute it; the routes must return that view (plus where it came from:
company row, building row, legacy record, lease policy) instead of only the new row. Today the window reads
a subset — that is the whole Camellia bug.
- **Writes**: unchanged mechanics — `writeTopic` + `refreshCompanyVoiceCaches` in the same request; Clara's next
turn lists live rows (`listSettingAttachments`), the voice cache is refreshed write-through. **There is no
delay between a Settings Save and Clara**; the ADR should pin that as a property ("a Save is live on the next
turn, text and voice") with the existing drift tests as the fence.
- **A Save pins** (see D4): a person's Save stamps the row (`setBy.source: settings-*`, and `pricingVerified`
for fees), and ingest reads stamped values back as `propflowai/saved-answer` (rank 0.95) so a later listing
read cannot overrule a person.
### D3. Ingest = proposals, with provenance, confidence and conflicts, surfaced where the fact lives
- Ingest keeps its own table and publishes `PREFILL#<org>/v1`. propflowai reads only the contract (already so,
`ingest-prefill.ts:9-13`).
- **Every card shows its proposal, to customers too**, not only to staff on setup-intake: "Found: $45 — AppFolio
› Listings, seen Nov 4 · also seen $40 (charges, Jul 27), $0 (staff reply, Nov 3)" with one action, Use it,
which is the card's ordinary Save (no confirm button; Gera's onboarding rule). Phase 6.8 made the hints
staff-only ("settings as little as possible") — this reverses it only for **required** rows with a proposal and
no saved value, and for conflicts, which are exactly the rows a person must settle. **Product call for Gera**:
it changes what a customer sees on Settings.
- **Clara never reads a proposal.** `loadCoreTopics` reads setting rows only; the contract read stays on the
settings/intake routes. Pinned by a planted test: a `PREFILL#` value with no saved row never appears in a
rendered knowledge block.
- **Trigger, not only daily**: a Settings Save or a staff reply enqueues `ingest run --company <org>` for that
company (one Temporal signal, or for now the existing launchd runner with a per-company touch file), so a
proposal reflects a correction within the hour, not the next morning. Daily stays as the floor.
The touch-file queue is the interim (ingest's "no Temporal until a source needs a schedule" rule); Temporal when
a second trigger needs it.
- **Companies list**: every live company runs (today only two of three live companies do); the id list moves
from a dotfile to the org row (`Property.isTest=false` companies), so a new customer is picked up on go-live.
### D4. Authority vs recency, and when a human must look (the coordinator's addition)
Current rule, cited: `ingest/runner.ts:131-144` — a required/blocker row with disagreeing numbers "show[s] the
newest, list[s] the rest" by `seen_at`; `autoPick` (authority first, then newest, `:104-110`) runs **only for
`nice`** rows. `sheet/authority.ts:9-26` ranks `propflowai/staff-reply` 0.9 (added by #329), `appfolio/charges`
0.8, `appfolio/listings` 0.65, `appfolio/marketing-text` 0.6 (#330). So on the Situs application-fee row the
listing read of Nov 4 ($45) wins every day over a staffer's written "application is free" of Nov 3, purely
because it is re-seen daily. (Prod `SHEET#org_a3376147…/ROW#pricingDetails.applicationFee` as of run 45,
11:30Z 11-04, built before #329/#330 merged at 18:26Z: value $45 listings, conflicts [$40 charges 07-27]; the
marketing $48 and the $0 staff reply land on tomorrow's 05:30 run.)
Recommended rule, one function for every tier:
1. **Authority first, recency second, on every tier**: winner = highest `authorityOf(source)`; ties → newest
`seen_at`. A date is a tiebreak between equals, never a promotion across classes. A listing is what the
public page says; a staff statement is what the company says; the company outranks its own marketing copy.
2. **Ranking (most to least trusted)**: a person's confirmation in Settings / Knowledge intake / Teach-it
(1.0–0.95) › a dated staff statement to Clara, named (0.9) › the PMS setting itself (0.9) › what the PMS
bills (charges 0.8) › leases/tenants/reports (0.8) › the customer's own transcripts and emails (0.7) › public
listings (0.65) › website (0.6) › marketing free text (0.6) › staff notes in history (0.4). This is the
existing table; the change is that it decides the **shown value**, not only the confidence score.
3. **Surface to a human (Questions list + the card's conflict line) when**: two sources at ≥0.8 disagree; or
the winner is a staff statement that contradicts a system of record (a $0 staff reply vs $45 listing — a
person must say which is stale, and the listing may need fixing in AppFolio); or the winner is older than
180 days and a fresher lower-ranked source disagrees. Otherwise the row auto-settles and records why
(`DECISION#`, `by: auto`, as 6.2 does for nice-to-have).
4. **A person's confirmation pins**: Save in Settings = a `knowledge.<id>` row with `setBy.source: settings-*`
(ingest already reads it as `propflowai/saved-answer`, 0.95) and, for the five lockable fees,
`pricingVerified.<fee>` (`policies/route.ts:38-49`); the crons already skip stamped fees
(`fee-prefill-fresh.ts:20`). The pin is **shown**: "Confirmed by <person>, Nov 5" on the card, and ingest's row
says "customer-saved; differs from listing ($45)" instead of raising it again. A pin is undone only by a
person (edit), never by a newer read; a newer read that disagrees with a pin shows as a quiet "listing now
says $45" note, not a conflict.
5. **Staff statements are cited by name, building and message** on the row and the card ("<staff name>, Nov 3, reply
on the <building> ask"), because that is what lets the next person decide in one glance.
### D5. Every channel feeds ingest
Already read today (`connectors/`): AppFolio synced copy, charges, leases, tenants, listings, marketing text
(#330), on-call, work orders; website; S3 history (guest-card notes, applications); transcripts (statements
only, fenced); staff replies to Clara's asks (#329); knowledge-intake answers; documents index. Missing, in the
order they pay off:
1. **Clara conversations** (our `CONV#` threads): a staff message in any thread that states a policy
("application is free", "we take co-signers") — keyed reads per thread from the conversation index, fenced
to policy statements by the existing `fence.ts`, cited by name/date/thread. The staff-replies connector is
the template (`connectors/propflowai/staff-replies.ts:1-9`).
2. **PMS messenger**: the AppFolio messages sync (the mirror rows) — same extractor, source `pms/messenger`,
rank with staff statements (0.9) when the author is staff.
3. **Emails to the team** (forwarded policy mail, the company brain `email_forward` notes) — rank 0.7
(transcript class) unless from staff, then 0.9.
4. **Calls** (ElevenLabs transcripts + the call-summary rows): statements by a staff caller only, 0.7; never a
renter's claim.
5. **Meeting transcripts** (Clara as note-taker → internal notes, the 10-20 decision): source
`transcript/meeting`, 0.7, and the only writer into the private brain; ingest reads the brain's policy
statements the way it reads transcripts today.
Each connector: keyed reads only, the conversation fence, a dated citation, and the authority rank above. None
writes propflowai.
### D6. Clara reads canonical + confirmed proposals
Unchanged reader (`loadCoreTopics`, `loadOrgKnowledgeSectionsSafe`, `pricingDetails`/`leasePolicy` through
`resolvePricingFactView`), plus one addition: the "Not on file" line names a fact that has an **unconfirmed
proposal** differently ("application fee: not confirmed yet — ask the team"), so Clara never quotes a proposal
and the team sees the ask on the Questions list. A fact with no row and no proposal stays "Not on file".
### D7. How far Settings goes (Gera 10-16)
Settings holds **facts the company has decided** (typed topics, fee slots, free-form entries), each in one home
with its provenance. It does not hold the dump, the sheet, the conflicts' full history or the paths — those
stay in ingest.propflowai.co for staff. A customer sees on each card: the saved value, who/when, and (when there
is one) the proposal with its source. Nothing else.
---
## E. Build plan (vertical slices, one concern each, ≤~300 lines, each off `origin/main`)
**Part 1 — the window reads what Clara reads (building).** `GET /api/properties/[id]/core-topics/[topic]`
returns `resolved: { fields, from: 'building' | 'company' | 'record' | 'lease-policy' }` computed by the SAME
`olderRecordFieldsFor` / `leasePolicy` substitution `loadCoreTopics` uses (`core-topic-sections.ts`), for every
spec topic (today `olderRecord` is pets-only, `route.ts:65-70`). `CoreTopicCard` building scope renders one line:
"On file for this building (from its record): $38 per applicant · Edit to confirm". Test: a Camellia-shaped
fixture (synthetic ids) with `leasePolicy.applicationFeePerApplicant` and no rows shows the line; a planted
defect (route returns null) fails it. Verify read-only against `PROP#1773625953462` with the route's loader.
Files: `src/app/api/properties/[id]/core-topics/[topic]/route.ts`, `CoreTopicCard.tsx`, one test.
**Part 2 — the Company column says where the facts are.** When a company holds zero `knowledge*` rows and has
buildings, the company column's cards still render but the section header carries one sentence with a link:
"No company-wide answers yet. Camellia Apartments: 12 entries and a fee schedule on file → open building"
(counts from `/api/properties/<id>` already loaded for the chips). No auto-switch (Gera 2026-10-03 rule stands).
Files: `knowledge-policies-page.tsx`, one small read helper, test.
**Part 3 — proposals on the card, for customers (Gera's product call; reverses Phase 6.8 for required rows).** The core-topic and fee cards read the company's `PREFILL#`
contract (route already exists: `/api/settings/setup-intake/suggestions`) and show the proposal line +
conflicts for required rows with no saved value, "Use it" = the card's Save. Drops the `viewerIsStaff` gate in
`knowledge-policies-page.tsx:48-50` for those rows only. Test: a contract with a conflict renders both values
and the source labels; a saved row hides the proposal.
**Part 4 — authority-first winner on every tier (ingest).** `runner.ts:131-144`: replace the newest-wins branch
with `autoPick` for every tier + the "needs a human" predicate (D4.3) recorded on the row (`needs_human: {why}`)
and carried in `PREFILL` rows (`sheet/prefill.ts`). Tests: the Situs shape ($45 listing 11-04, $40 charges, $48
marketing, $0 staff reply 11-03) shows $0 (staff, 0.9) with `needs_human: staff statement contradicts
listing`. Re-run `ingest run --company <situs> --dry-run` and paste the before/after for the fee rows.
**Part 5 — a Save pins, visibly.** (a) propflowai: every Settings/Knowledge-intake Save of a lockable fee passes
`confirmed: true` (today only the intake form does); the card shows "Confirmed by …". (b) ingest: a pinned row
(saved-answer or `pricingVerified`) is never re-raised; a disagreeing newer read becomes a `note`, not a
conflict. Two PRs, one per repo.
**Part 6 — ingest runs for every live company, and on demand.** Replace `~/.propflow-ingest/companies` with
the live-company list read from `propflow-prod` org rows (`isTest=false`, gone-live), and add
`ingest run --company <org>` on a Settings Save / staff reply (touch-file queue the launchd job drains hourly;
Temporal later). Adds `org_jpco`. SYSTEMS.md `signals` updated in the same PR.
**Part 7 — the Clara conversations connector** (D5.1), then PMS messenger (D5.2), one connector per PR, each
with the fence and a citation.
**Part 8 — "not confirmed yet" in the Not-on-file line** (D6): `renderPropertyKnowledgeBlock` takes
`proposedLabels[]`; the leasing eval gains one case (fails on old code: Clara quotes nothing and says "ask the
team" instead of "not on file").
**Part 9 (optional, Gera's call, prod write) — legacy projection backfill.** `scripts/knowledge/project-legacy-
knowledge.ts --org org_jpco --dry-run`: writes building-tier `knowledge.<topic>` rows from `leasePolicy` /
`pricingDetails` and `knowledge.section.<id>` rows from `sections[]` **at the building tier** (never the
company: Camellia's door code and third-party biller are not Yale's), `setBy.source: script:project-legacy`. Not
needed once part 1 lands; it only makes the rows editable as typed topics. Needs Gera's per-step OK per the
prod-data rule.
**Part 10 — ADR + SYSTEMS.md** (section F), written with part 1, not after.
### The smallest safe thing for Thursday 11-05
The Thursday call is the Chicago prospect's vendor (transcript thread `C0BE1NFTA0K/p1793743895274839`); which
company Sean will open is unknown, so the fix must not be Camellia-specific.
- **Zero code, today**: demo the **building chip** (Camellia or Yale), not the Company column, and scroll to
"What Clara knows about Camellia Apartments" — the 12 entries, fees, specials and hours are all there now.
Gera's own 11-03 line applies: review the page before the demo.
- **One PR (part 1), ≤300 lines, no prod write**: building topic cards show the record-derived answer, so the
cards are not blank on Camellia's chip. Can land by Thursday if driven today; proof is a read-only route run
against Camellia's row plus a screenshot on the Live Preview tier.
- **Part 2** is the second cheapest and removes the "all blank" first impression on the Company column.
- **Not recommended for Thursday**: a prod backfill into `ORG#org_jpco` (part 9). It is a prod data write
(Gera's OK), it would push Camellia-only facts to Yale if done at the company tier, and until the cards absorb
the old sections it produces a second statement per fact — the "two copies, which does Clara read?" problem
the onboarding-settings plan was written to end.
---
## F. ADR draft (agentflow `docs/adr/`, per ADR-0004)
Number: the next free number after `0077-*.md` is **0078** (confirm against `NUMBER-REGISTER.md`'s high-water
rule; note that `SYSTEMS.md:6716` cites "ADR-0059" for propflow-ingest, but `0059-*.md` is the Smith
runtime-clone decision and no ADR in the ledger mentions propflow-ingest — this ADR also closes that gap by
recording the ingest↔propflowai relation it depends on).
```
# 0078 — A company's knowledge has one canonical store; Settings is its window and ingest proposes into it
- Status: Proposed
- Date: 2026-11-04
- Deciders: Gera (11-03: "settings knowledge base isnt the backend, its the window to it… the ui isnt hooked up
to what clara sees"); Fede (10-15: "Dump it in one place… then… what actually useful information I can put in
the knowledge base clean")
- Relates to: ADR-0004 (why this is written), propflowai knowledge-grounding-architecture (2026-08-03),
onboarding-settings-plan-2026-10, propflow-ingest README (rules that never bend)
## Decision
1. The canonical store of a company's and a building's facts is the registry-keyed setting rows on the company
and building nodes (`knowledge.<topic>`, `knowledge.section.<id>`, `pets.policy`, the fee slots), resolved
nearest-wins by the portfolio resolver, with the building's legacy `KNOWLEDGE` row read as a retiring rung
through one projection that every reader shares. No second store, no second schema, no cache that is read
instead of it.
2. Settings (and Knowledge intake, which writes the same slots) is the window onto that store. A Save lands
synchronously, refreshes the voice read-model in the same request, and is live for Clara on her next turn
on every channel. The window shows the resolved answer Clara reads, including facts that still live on the
legacy rung, and names where each came from. Nothing a person can see in Settings differs from what Clara
reads for that company and building.
3. propflow-ingest is the proposals layer. It reads every channel (PMS copy, charges, listings, marketing text,
website, history, transcripts, staff replies, our conversations, PMS messenger, team email, calls, meeting
notes), ranks by source authority first and recency second on every tier, records conflicts and the reason
for every pick, publishes one contract per company, and never writes the canonical store. A proposal
becomes a fact only by a person's Save or a recorded pick; a person's confirmation pins the value against
later reads.
4. Clara reads the canonical store only. An unconfirmed proposal is never quoted; it may only change what
Clara says about not knowing ("not confirmed yet, ask the team").
## What this replaces / inventory of the old surface
- The Company-column-only read of the new topic rows (`CoreTopicCard` building scope reading `own`/`company`
only; `olderRecord` pets-only) — replaced by the shared resolved read (part 1).
- Newest-seen-at wins on required rows (`propflow-ingest/ingest/runner.ts:142-143`) — replaced by
authority-first on every tier (part 4).
- Staff-only found-value hints on Settings (Phase 6.8) — replaced by customer-visible proposals on required
rows with no saved value (part 3).
- The dotfile company list for the daily run (`~/.propflow-ingest/companies`) — replaced by the live-company
read (part 6).
- Any script that seeds company-wide rows from one building's record (the 2026-09-15 pattern) — fenced: a
projection writes at the building tier unless the entry says company-wide.
## Fencing
- A test in propflowai plants a `PREFILL#` value with no saved row and asserts it never reaches a rendered
knowledge block (text and voice).
- A drift test asserts every spec topic's building GET returns the same resolved fields `loadCoreTopics`
would hand Clara for a fixture building.
- SYSTEMS.md: propflow-ingest gains the signals "every live company ran today" and "a Save-triggered run
landed"; propflowai's knowledge surface gains "resolved-view parity test green on main".
## Consequences
- Camellia-class companies (facts on the legacy rung only) show their facts in Settings without a data move;
a backfill becomes optional and building-scoped.
- A staff statement to Clara outranks a public listing on the proposals layer and is surfaced with a name and
date; a person settles it once and the pin holds.
- Ingest's delay is explicit and acceptable: it only ever delays a proposal, never a fact.
```
---
## Appendix — prod reads taken (all read-only, 2026-11-04)
- `propflow-prod` GetItem `PROP#1773625953462/META` → `organizationId: org_jpco`; `PROP#1773625952029/META` →
`org_jpco`.
- `propflow-prod` Query `PROP#1773625953462` SK=`KNOWLEDGE` → 12 live sections, `pricingDetails` (7 keys),
`leasePolicy` present, `pricingVerified: null`, `pricingSources: null`.
- `propflow-prod` Query `PROP#1773625953462` begins_with `ATTACH#setting#` → 2 rows (ring lists), no knowledge.
- `propflow-prod` Query `ORG#org_jpco` begins_with `ATTACH#setting#` → 7 rows, no `knowledge*`.
- `propflow-prod` Query `ORG#org_c42d9150-…` begins_with `ATTACH#setting#knowledge` → 32 rows (slots listed in
this session: 18 `knowledge.section.*`, 5 `knowledge.<topic>`, 9 `knowledge_intake.*`).
- `propflow-prod` Query `ORG#org_a3376147-…` begins_with `ATTACH#setting#knowledge` → 7 rows.
- `propflow-prod` BatchGet `KNOWLEDGE` for 5 Situs buildings + `situs-front-door` → only `…property-13` has a
row (petDeposit, securityDeposit from `appfolio-charges`; no applicationFee); the others have **no**
`KNOWLEDGE` row — the "per-building field was empty at the apartments" finding.
- `propflow-ingest-sheets` GetItem `PREFILL#org_jpco/v1`, `SHEET#org_jpco/META` → no item.
- `propflow-ingest-sheets` GetItem `PREFILL#org_a3376147-…/v1` → run 45, built 2026-11-04T11:30Z, 30 rows,
19 gaps, 3 to_confirm, 32 building-evidence entries; `pricingDetails.applicationFee` = `$45` conflict
(listings 11-04) vs `$40` (charges 07-27).
- `propflow-ingest-sheets` Query `SHEET#org_a3376147-…` SK=`ROW#pricingDetails.applicationFee` → attempts:
saved-answer empty, charges $40, listings $45 (winner), rental_applications empty.
- `~/.propflow-ingest/last-run.json` → 2026-11-04 launchd run, head 27ede63, Situs 77% BELOW_BAR, Western
Slope 86% MET; `companies` lists Situs and Western Slope only.