Architecture & decisions · 8 August 2026 · Proposed — pending review

The property knowledgebase

The property knowledgebase is the one record that holds everything Clara knows about a property — deposits, fees, specials, utilities, office hours, pet rules, policy prose. Until this week the same facts lived in two rows that could disagree; the change now in review puts them in one row behind one door. This brief shows that change in three diagrams, records what was decided on 8 August, and proposes the shape the whole thing should settle into — every move deleting or collapsing rather than adding.

Before — two homes, three planes

The same property facts lived in two database rows and different parts of Clara read different ones, with nothing reconciling them.

Before: property facts split across two database rows feeding three separate groups of readers WRITERS TWO HOMES READERS Admin UI edit typed fee fields Document upload free text only Website scraper specials, hours Engineer script the only lease writer PROP#id / KNOWLEDGE home one pricingDetails 21 typed fee fields — deposit, app fee, pet, parking… sections[] free text, whatever a document said concessions[] current specials officeHours plus amenities, utilities, neighborhood both rows quote deposits, fees and specials nothing says which one wins PROP#id / LEASING_SETTINGS home two applicationLeasePolicy typed: deposit tiers, app fee, terms, utilities Voice: pricing_fees typed fees only — never the free text Voice: property_policies free text only — never the typed fees Voice: property_concessions specials list, blank when empty Tools: get_pricing_details / get_property_details hand back the typed fees verbatim Email & SMS property-knowledge block typed fees + free text, side by side Lease-answer grounding block reads the OTHER home only WHAT THIS COST US — ALL THREE REPRODUCED IN REPLAY A deposit of $1,200 stated to a prospect that no row anywhere contained. One fee answered two different ways — $50 on one channel, $45 on another. A live special denied, because an empty specials field read as “there are none”.
Writers on the left, the two rows in the middle, the readers on the right. No reader sees both homes; nothing reconciles them.

After — one knowledgebase, one door

The typed lease policy moves into the knowledge row and every surface reads it through one resolver, so two answers to the same fee question can no longer exist.

After: writers pass a validation gate into a single knowledge row, which every reader reaches through one resolver Admin UI edit fees and policy Document upload free text Scraper & specials watcher specials, hours Migration script dry run unless told otherwise Schema check on every write — a malformed policy never lands PROP#id / KNOWLEDGE — the one home What was already here pricingDetails — 21 typed fee fields sections[] — free text from documents concessions[] — current specials officeHours, amenities, utilities unchanged, still the fallback leasePolicy — new, typed deposit tiers · application fee · allowed terms specials with their real mechanics utilities, including who sets each one up + 9 optional move-in facts signing deadline · pre-move-in requirements · fee list insurance · payment · utility deadline · holding deposit One read boundary — the typed policy replaces a legacy fee only where it actually carries that fact 6 fee keys can be superseded · parking stays legacy when the policy leaves it out · nothing else changes Voice variables fees, policies, specials Leasing tools no raw fee object goes out Email & SMS block same resolved facts Lease-answer grounding same home as everyone else Every surface reads the same resolved view, so a contradiction has nowhere to live. Retired: LEASING_SETTINGS.applicationLeasePolicy no readers left — a standing test fails the build if one comes back
The gate at the top and the boundary in the middle are the whole idea: one way in, one way out.

Prove it, then ship it

The Willows is the test grounds. The new knowledgebase runs there first, under heavy testing — replaying real production questions and trying hard to make it give a wrong answer. When the results show it answers better than today, it ships. Until a property is switched on, nothing changes for it, and Camellia cannot be switched on by accident.

Enrollment states: not enrolled behaves identically to today, enrolled answers lease questions from the typed policy Not enrolled Legacy fee fields only Byte-identical to today's answers Lease questions are not answered — they route to a human this is the safe default migration script dry run by default · must name the table to write refuses Camellia outright Enrolled Typed policy answers the money questions Facts it doesn't carry are punted honestly, not guessed presence of the policy IS the enrollment The Willows goes first, as the test property. Camellia stays untouched until go-live — migration, then Fede's own self-test.
Nothing changes for a property until someone deliberately fills in its data; Camellia is deny-listed so it cannot be switched on by accident.

What to simplify next

Measured on the real repo and the real production table on 8 August 2026. Production has exactly four property knowledge records (Camellia House, Yale 25 Station, the test property, and one stub), so “how many properties actually use this?” is a question with a real answer, not a guess.

1. Kill the third home for renewal facts Decided — building

Renewal terms are stored in three places at once. Camellia's month-to-month premium is 100 on the knowledge row and 100 on the leasing-settings row; its term options are [6, 12] in both. A third, separately-versioned RENEWAL_POLICY# row holds the approval thresholds. The lease-money dual-home was just fixed — this is the same bug, one table over.

Evidence: prod rows PROP#1773625953462 / KNOWLEDGE.renewalPolicy, / LEASING_SETTINGS.renewalTermOptions,renewalMtmPremium, / RENEWAL_POLICY#000002.

A — Recommended: collapse into the knowledge row Delete the duplicated renewal* fields from leasing settings, fold the thresholds into renewalPolicy, keep one versioned history.
B — Collapse the other way Move renewalPolicy out of knowledge and make RENEWAL_POLICY# authoritative. More correct on paper, but touches every renewal reader.
C — Leave it, add a consistency test Cheapest today; keeps three homes and the drift risk forever.

Net complexity: −2 storage homes, −4 duplicated fields, −1 write path. Adds nothing.

Decided 8 Aug: option A. In build now, and it gets the same treatment the lease change got — proven at The Willows under heavy replay testing before any live property moves.

2. Make the scraper wipe-proof Decided — keep it

The scraper is the only writer that can quietly destroy facts: it saves the whole knowledge record at once from a local file, so any field that file doesn't know about is gone. It also skips the blessed save path, which is what refreshes the voice agent's cached answers — so a scrape can leave the phone quoting last month's office hours. That is the bug, not the scraping.

Evidence: src/lib/platform/scrapers/registry.ts (2 property configs), scrapers/shared.ts:660 (whole-record save, bypasses the blessed writer), prod scan of all 4 KNOWLEDGE rows.

Decided 8 Aug: keep the scraper — fresh specials and hours off a property website are worth having, and they only get more valuable at hundreds of properties. Fix it instead, on four rules: never overwrite a filled-in field with an empty scrape result; never touch a field it didn't produce; always refresh the voice cache after writing; and for anything that changes an existing value, propose the change and have a human confirm it. Tracked as Trello emhIG7NC.

Net complexity: −1 bypass, −1 whole-record overwrite. Adds one confirmation step on changed values.

3. Facts live in fields, never in paragraphs

Today a property fact can be written down two different ways: as a typed field (a labelled slot like securityDeposit: 500, which Clara's tools and every prompt read) or as a paragraph of free text pasted in from a document (which only some prompts see). Five of the 14 paragraphs in production simply restate a fact that already has a field — and when the two disagree, whichever the prompt happens to list last wins.

Example: Camellia has a typed security deposit and a “Security Deposit” paragraph saying something different. That is exactly where the wrong deposit answer came from. The proposal: paragraphs may still describe things a field can't hold — what the neighbourhood feels like, how to find the office — but every fact (any number, policy or rule) lives only in a field.

Evidence: prod sections[] titles on all 4 KNOWLEDGE rows; typed leaves in PropertyKnowledge.pricingDetails.

A — Recommended: move the 5 fact-bearing paragraphs into fields and delete them One place per fact; paragraphs keep only what no field can hold.
B — Make paragraphs the authority, delete the overlapping fields Also one place per fact, but throws away the structure the voice agent and renewal automation need.
C — Keep both and declare a winner in the prompt Deletes nothing; the contradiction stays and we just hope the ordering holds.

Net complexity: −5 duplicate paragraphs, −1 authority conflict. Adds one line of policy, no new code.

How the entities should be organized (proposed)

The two consolidations above each fix one duplicate. Neither answers the bigger question: at hundreds of properties, what shape should property facts have at all? Today a single property's facts are spread over three kinds of row — a knowledge record, a leasing-settings record, and a stack of versioned renewal-policy records — and only the renewal stack has history, review and rollback. Four candidate shapes:

A — One knowledge record per property, typed sections per domain Where we already land after the two consolidations: lease, renewal, turnover and pricing each get their own typed block inside one record. Simplest to read, but the record is still edited in place, so there is no history and no undo.
B — A separate versioned record per fact domain LEASE_POLICY#, RENEWAL_POLICY#, TURNOVER#… each append-only like the renewal stack is today. Best audit trail, but every reader now has to know which record holds which fact — the exact ambiguity we just spent two PRs deleting — and the voice path would go from one lookup to five.
C — One flat catalogue of individual facts, each tagged with its domain Maximum flexibility for future ingestion sources. But it throws away the typed shape, and the typed shape is load-bearing: Clara decides whether she is allowed to answer by checking whether a specific field is filled in. A bag of tagged facts cannot answer “is this property enrolled for lease questions?”
D — Recommended: one knowledge record per property, versioned as a whole Option A's single record, plus the renewal stack's discipline applied to the whole document instead of one domain: every save writes a new numbered version with who/when/why, the newest version is what everything reads, and rollback is re-saving an old one. One lookup, one home per fact, full history — and it removes the separate renewal-policy stack rather than adding a sixth kind of row.

Why D. It is the only option that keeps all three things we actually rely on at once: one place to look for a fact (so no reader ever has to pick), a typed shape (so “this field is empty” can keep meaning “don't answer, hand to a human”), and a real audit trail (so a decision made in June can be replayed against the facts as they stood in June). B buys the audit trail by re-fragmenting the read path; C buys flexibility by giving up the fail-closed behaviour that keeps Clara from inventing deposits. D is also the cheapest from where we stand after the two in-flight changes: the readers do not move at all, only the save path does.

Row inventory per property: five kinds of row today, two after the proposal TODAY — PER PROPERTY KNOWLEDGE LEASING_SETTINGS RENEWAL_POLICY# ×N TURNOVER_SETTINGS VOICE_INJECTION_CACHE PROPOSED — PER PROPERTY KNOWLEDGE#version — every fact, with history VOICE_INJECTION_CACHE rebuildable cache — not a home for facts
Settings rows keep only true settings (tour windows, on/off switches); every property fact collapses into one versioned record.

Staging — each stage ships and is proven at The Willows before the next.

Net at the end: 5 kinds of row → 2, and one save path instead of fourteen.

Also measured, worth knowing

Waiting on you

Later, only if

A verification stamp (who confirmed this fact, when) and an extract-then-confirm ingestion flow are not proposed here. A verification stamp is largely subsumed by versioning the record (§D gives you who changed what, when and why for free), and extract-then-confirm only earns its place as the same confirm step the wipe-proof scraper needs — one review surface, not two. Revisit after stage 3, not before.

PropFlow Docs