0156 — The Company knowledge base is one store: two slots per topic, one fenced reader, one writer, and money decided at the 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

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.

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

Alternatives considered