0156 — The Company knowledge base is one store: two slots per topic, one fenced reader, one writer, and money decided at the reader
- Status: Accepted
- Date: 2026-11-05
- Deciders: Gera (2026-11-04 brief and 2026-11-05 decisions A, B and C: the name "Company knowledge base"; customers read and correct through one Settings tab, "Knowledge base · Ask"; "one source is enough", for Clara and for leases). Fede is not looped in on this effort (Gera, 2026-11-05); his 2026-10-07 private-information ruling and his 2026-09-29 fee-source rule are kept and narrowed only where this ADR says.
- Cross-system record: agentflow ADR-0084, One Company knowledge base per company: the only store Clara, Settings and ingest use. That record holds the why, the surfaces and the retirement inventory; this one holds the in-repo mechanics, so a reviewer can tell whether a piece of code violates them.
- Plan: the decided plan, docs.propflowai.co/a/company-brain-plan-2026-11 (§2 money table rows M1 to M12, §3 data model, §8 migration, §9 roadmap). Section numbers below are the plan's.
- Builds on and amends: ADR-0144 §6.4 (amended here for one additive backfill); ADR-0150 (the daily list is a send like any other); ADR-0080 stands (the voice snapshot reads the same reader).
Context
A company's facts live in three stores that disagree: registry rows on the company and building nodes (ATTACH#setting#knowledge.<topic>, knowledge.section.<id>, pets.policy, fees.<slot>), the legacy building record (PROP#<id>/KNOWLEDGE: sections, pricingDetails, leasePolicy, hours), and ingest's sheet (propflow-ingest-sheets, ROW#). Readers pick whichever they were written against. The 2026-11-03 Settings page showed one building blank while Clara read its twelve sections from the old record; one company's application fee reads differently in five places (a listing, the charges ledger, two marketing texts, a staff reply, and AppFolio's unit fields) and Clara picked one. Both counts are the plan's read-only prod reads of 2026-11-04 (plan §0 and §8.1). The lease package carries a stored deposit snapshot nobody re-reads at approval.
Decision
1. Two slots per topic, both registry-keyed setting rows
brain.<topic>is the live answer: one free-form sentence (1 to 2,000 characters) plus an envelope,status: confirmed | learned. Clara reads it.brain_suggested.<topic>holds suggestions and open questions. Clara never reads it. It is its own prefix, notbrain.suggested., because the live read is abegins_withonbrain.and topic ids never contain..- Both are written by
putSettingAttachment(src/lib/data/dynamo/setting-attachment.ts): one live row per node, ended intoHIST#by the existing three-item transact, refused on an occupied slot unlesssupersedesmatches. A machine writer retries once after re-reading; a person's write always wins. - A suggestion never supersedes a live row. A
confirmedrow is never ended by a machine. The rail ends alearnedrow only when itsprovenance.sourcestarts withingest/and that source has been absent two runs with no person confirming since. - The topic registry (
src/lib/domain/brain/topics.ts) declares per topic:scope(building, company, either),inherit(nearest, both, union for life-safety),audience,readerScopeper surface,facets,lifeSafety,compliance,writable,replaces(every legacy read path) andlegacyWriters(every legacy write path). A drift test pins every field on every topic.replacesandlegacyWritersare migration scaffolding: the removal lane (plan part 19) deletes both fields, with the legacy paths they list, so nolegacytoken stays in source after the effort.
2. One reader: readBrain, filter then fold
readBrain({ org, propertyId, reader: { role, tiers?, channel, surface }, mode? }) is the only renter-facing reader of a registry topic's facts. Surfaces: prompt | tool | snapshot | sender | review | pms-write. Modes: default (Clara), page (the Ask tab and Settings pre-fill: unfenced entries with status and origin), audit (the rail and the backfill, which may read HIST#). It is memoised per request; a mid-call tool's p95 adds at most 50 ms.
Order, per tier: filter first (live slot; audience; readerScope[surface] for the role; a fair-housing stamp for person-statement sources; money per section 5), then mask (a building row masks only that building's legacy paths, a company row only the company's), then fold by inherit, then group and render. Filtering before folding is the rule: a building suggested or operational row never hides a company public row.
forClara is a branded superset of PropertyKnowledge: topic-owned fields resolve through readBrain; fields no topic owns are copied from the raw record untouched. Until a topic's backfill, readBrain projects its legacy paths into entries (migration:<path>), so Clara and the page read one shape from the first part on.
3. One writer, and the two-sided writable invariant
writeBrainEntry (src/lib/domain/brain/write-brain.ts) is the only writer of either slot. It refuses: a tier the scope forbids; a topic with writable: false; the group tier; a caller-supplied facet; a learned write to a life-safety or compliance topic on the live slot (its question still lands on the suggested slot, which Clara never reads); an operational audience outside the closed list (section 6); a money confirmed write by anyone mayConfirmMoney rejects (section 5). It runs the private-information gate on every write (section 6). A drift test fails on any other module that writes a brain slot.
A topic is writable: true only when both sides have moved: every code reader of that topic (prompt, tools, snapshot, guards, scripted senders, the lease policy, the lease writers, the pages) reads readBrain, and every entry in its legacyWriters writes through writeBrainEntry or refuses, each proven by a behaviour test.
The storage guard. The five storage doors that can write a brain-owned fact (the savePropertyKnowledge repository dispatch and Clara's mirror, updateKnowledgeSections, the pet-fee apply-to-all writer, and prepareSettingPut) check the touched fields or slot against the registry's replaces. When a field or slot owned by a writable topic changes, inside the existing condition or lock, never a bare read-then-put, they throw BrainOwnedError unless the caller is write-brain.ts, an allowlisted data-layer writer, or reverse-project.ts. One unit test per door. Because a topic flips writable only after every writer it would stop has a home, the guard never throws on a live production path.
4. Brain slots are out of the map-changes view and undo
Every putSettingAttachment appends MAPLOG#<v> and bumps PROFILE.mapVersion (src/lib/data/dynamo/map-log.ts). The knowledge base keeps that as the concurrent-first-write fence, but brain.* and brain_suggested.* slots are excluded from the map-changes view and its undo: a knowledge entry's history is its own HIST# chain, its undo is the Ask tab's Undo chip, and a nightly rail batch must not flood the configuration log. map-writers-logged.drift.test.ts is updated deliberately, in the part that writes the first brain row.
5. Money is decided at the reader, by the plan's table
The money rules are the plan's §2 rows M1 to M12; here they are as code.
acceptRule(src/lib/domain/brain/accept-rule.ts, pure, built before any caller) is the one function the ingest rail, the fee pre-fill crons, property creation and the backfill call. An uncontested find returnslearned; two sources we hold for the same unit or building with different values returnquestion(one per building and topic). A company-wide mode never contests a unit. Staff words and our own read-backs are evidence, never a vote. Learned writes from ingest (rows whose source starts withingest/) are gated per company by thebrain_digestpath's mode (section 8), the one registered switch on daily learning; there is no rollout list in code, no environment flag, and no second setting. The fee crons and property creation are not gated by it: they fill blanks with live data today and keep doing so.readBrain's money surfaces. Uncontested (confirmedorlearned, nothing disagreeing): a bare fact on every surface. Contested (an open question on the row): the better figure with where it was seen, inside the fee line, in renter words ("as posted on our listing", "as posted on our website", "per our records"), for 14 days after the question is delivered; after that a nightly sweep moves the row tobrain_suggestedand the topic reads as not on file (today's silent hand-off). Aconfirmedrow keeps rendering plain while a later machine value disagrees; only the lease holds. Until the money-render part (9b) every row renders bare, so migration changes nothing a renter hears before then.pms-writesurface. Returns a figure only when it is uncontested and, for an AppFolio unit field, fresh (UNIT_DIRECTORY_STALE_MS); otherwise a reason (packet-not-ready-contested,pms-setting-stale) naming both figures. It never falls through to a lower source.depositFor(propertyId, unitId, unitBedrooms, surface)insrc/lib/domain/leasing/lease-terms/is the one deposit function: the unit's AppFolioDefaultDepositwhen set, fresh and uncontested; else thesecurity-depositentry's tiers for the unit's bedrooms; else the listing's headline deposit. Contested at any step: sourced on quote surfaces, held onpms-write. The same order answers the application fee per unit. LEASE ANSWERS,get_pricing_details, the approved-applicant package and the packet all call it, so a lease and a quote never disagree.0.00, null, empty, non-numeric or negative is unset, never "no fee". A sitting tenant's rent-rollDepositis never a price.- The package-review approve route (
src/app/api/new-lease-package-reviews/[id]/decision/route.ts) re-reads every money figure throughreadBrain({ surface: 'pms-write' })at approval, never the storeditem.packagesnapshot. All uncontested: proceed with the re-read figures. A contested figure and an approver who passesmayConfirmMoney: the approve confirms the chosen figure (via: 'review'), clears the question, proceeds. Otherwise the route holds with both figures named. Approving never silently confirms the snapshot's figure. The paperwork rungs (team,prepare,clara) use the same gate:prepareandclarawrite only when every money figure is uncontested, else "not ready" with both figures named. mayConfirmMoney(src/lib/tools/role-matrix.ts, built onuserRoleToPersonRole, imported fromsrc/lib/platform/auth/link-user-to-spine.ts) is the only authority for a moneyconfirmed:pm,org_admin, and the four team labels of Gera's 2026-08-19 ruling (accounting,regional_manager,assistant_property_manager,leasing_assistant); a Propflowplatform_adminonlyonBehalfOfa named customer manager; never a machine bearer. Per-tier tests. It replacesmayConfirmFees(src/app/api/properties/[id]/policies/route.ts) as a money authority, and narrows it on purpose:mayConfirmFeeslets Propflow staff confirm always, while undermayConfirmMoneya Propflow administrator's confirm counts onlyonBehalfOfa named customer manager (otherwise it landslearned). The part that replaces it is that narrowing, not a regression;mayHearMoneystill decides who may hear a figure. Anyone else's statement is one more source throughacceptRule.- Fede's 2026-09-29 fee rule (
src/lib/domain/leasing/fee-source-rule.ts) gains one clause: a fee line that carries where it was posted is quoted with that mark, exactly as written. It otherwise stands, with its drift test.
6. The private-information gate on every write, narrowed to four operational topics
Fede's 2026-10-07 ruling ("knowledge base is public; we should never save private information", src/lib/domain/leasing/knowledge/private-info-gate.ts) runs on every write of either slot, with a mode per writer: authored for a person typing their own words (the Ask tab, Settings, a teach, the CLI); learned for the rail, the crons, the backfill, and text a person merely confirms (a review form, document upload). Machine writes redact (redactPrivateSentences; the entry says a contact detail was left out); person writes are refused with the reason. The gate result gains a reason code (contact | marker | access); an access text (lockbox, service code, contractor instructions, never a renter gate code) routes to the typed maintenance.access_instructions setting, not the knowledge base.
The narrowing, on Gera's decision: "the knowledge base is public" now holds for every topic except a closed list of four operational topics that Clara uses for routing and never says: screening-criteria, income-requirement, on-call-narrative, escalation-narrative. They are accepted only with zero contact data, the gate runs in learned mode on them whoever writes them, and the voice snapshot and every renter-facing render exclude them. A planted test writes an operational entry with a phone number at a fixture building and asserts it reaches neither text nor the snapshot. Legacy staff sections outside the list are not projected.
7. The reader ban
After its part, no module under agents/clara/, src/lib/tools/handlers/, src/app/api/voice/, src/app/api/twilio/, src/app/api/new-lease-package-reviews/, src/lib/integrations/voice/, src/lib/domain/{leasing,move-in,calendar,automations,escalation,maintenance,vendors,documents}/, src/lib/temporal/ or lambda/ imports either raw PropertyKnowledge door. The ban is by directory and by type: a test asserts every banned directory exists, and an exported function in a banned directory that returns the raw PropertyKnowledge type is a type error. The reader inventory is generated from git grep in part 1a and committed as the ban's seed; that generated list, not the plan's appendix, is authoritative.
8. The daily list and daily learning are the brain_digest path: unset is held, a founder turns it on per company
Questions and the daily list to a customer's team go out on one new path, brain_digest (a row in the path catalog, team.brain_digest, whose mode is stored under the registry template gate.path.<path> as gate.path.team_brain_digest, with its "Review & log" column; a TeamNotice kind with its MODULE_FLAGS mapping), through the send boundary and recorded on the conversation page (ADR-0150). Off sends nothing; Preview drafts; Supervised holds on the existing held-message rail (no new registry row); Live posts to the company's team. There is no default-on. A merge never turns it on at a real company (ADR-0144 §6.3, hot rule 8): unset fails closed, so nothing is sent, the 14-day clock does not run, and no ingest/ row is written as learned. A founder (Gera) sets the path's mode per company, as a setting change, not a code change; "Live" is the mode each company is set to at its rollout, so learning daily does not then stall behind a Propflow approval.
The same mode gates learning (section 5), under the mode model: Off or unset: no ingest/ row is written and nothing is sent. Preview: nothing is written or sent; the would-be entries and list are saved as a draft for review. Supervised: ingest/ rows are written (an internal write, like a PMS write under Supervised) and the list is held on the held-message rail. Live: rows are written and the list goes to the company's team. One switch, one path, registered with the gatekeeper's fields (ADR-0144 §3).
9. One observe row in the closed check registry, no hold
scripts/clara-checks/registry.ts is closed ("the count only goes down"). This effort adds no hold gate: the money fence is at the reader. It adds exactly one row, G-BRAIN1, with status observe: every $ figure in a sent reply is matched against the turn's facts, engine lines and tool results (reusing the claim prefilter's English and Spanish patterns), misses are logged and never held, with planted mutated-figure controls in both languages. Adding an observe row does not raise the count of checks that may stop a message, so the registry's rule is honoured. Promoting it to hold is Gera's call on 30 days of data.
10. Amendment to ADR-0144 §6.4: one additive backfill for this store
ADR-0144 §6.4 says "Do not migrate or delete prod data." For the knowledge base, one additive backfill is allowed: it copies legacy rows into brain.* / brain_suggested.* rows (status per the plan's M11: confirmed only where a recorded actor passes mayConfirmMoney, learned through acceptRule otherwise), overwrites and deletes nothing, and runs per company, per step, on Gera's direct approval of that exact step under CLAUDE.md's prod-data rule, with a dry run's counts shown first. Old rows stay in place, unread once the removal lane lands. Everything else in §6 (census first, one source, the vertical split that proves the paths agree, removal in the same lane) applies unchanged; this effort is that sanctioned split.
Entity classification
| Entity | Class | Naming | Spine trace |
|---|---|---|---|
BrainEntry |
canonical | bare name | Spine trace: stored on the ORG# or PROP# node as a registry setting row; confirmed.by names a roster user |
BrainTopic |
canonical (code) | bare name | Spine trace: none; a static registry in src/lib/domain/brain/topics.ts, like the settings key registry |
Consequences
- One store, one reader, one writer. A building whose facts lived only on the old record shows them without a company tier.
- A lease and a quote come from one value. A contested figure holds the lease, so a wrong lone figure costs a quote, never a signed document, wherever we already know something disagrees.
- The accepted risk under "one source is enough": a lone wrong figure no second source reaches is stated as fact and may reach a lease until a manager corrects it. The weekly read-only diff of quoted money against PMS charges and the lease ledger are the net.
- Every lane PR attaches a read-only census of the assembled prompt, tools, snapshot and lease paperwork per building and reader role; the only allowed render change is a contested figure becoming a sourced quote (from 9b).
- Follow-ups are the plan's roadmap, about 41 PRs across propflowai and propflow-ingest; three prod data moves and every per-company rollout are Gera's go.
Alternatives considered
- Quote every single-source figure with its source until a manager taps. Rejected by Gera (decision C): almost every fee at every customer would have become a sourced quote on day one, with no road back but a manager's tap.
- A hold gate on unbacked money in Clara's replies. Rejected: the registry is closed and the fence belongs at the reader;
G-BRAIN1observes instead. - Keep facts in ingest's sheet and sync to propflowai. Rejected: two copies drift. Facts live only in propflowai's table, read on Clara's hot path;
ROW#keeps evidence only. - A customer knowledge-base page with cards. Rejected by Gera (decision B, "too overwhelming"): one chat tab.