Case study
Разбор проекта
Enclave
A private AI knowledge assistant for small businesses — turns scattered PDFs, SOPs, and manuals into searchable answers, entirely on hardware the business already owns.
Локальный ИИ-ассистент для малого бизнеса — превращает разрозненные PDF, регламенты и инструкции в доступные для поиска ответы, целиком на оборудовании, которое у бизнеса уже есть.
Tauri 2RustReactPostgreSQLpgvectorRow-Level SecurityOllama · QwenLoRA
01 · Context
01 · Контекст
Large companies have information overload — and people whose job is managing it
Most of the AI-for-business conversation is aimed at enterprise: Microsoft, Google, Atlassian, Notion, and a hundred well-funded startups building "corporate RAG" for organizations that already have IT departments, procurement processes, and six-month deployment cycles. A small business — a 15-person manufacturing shop, a 20-person law office, a logistics company, an accounting firm, a clinic — has the same underlying problem, in some ways a worse version of it: years of manuals, SOPs, invoices, case files, and emails scattered across folders and inboxes, with no one whose actual job is organizing it. Often the owner is the knowledge base — the person everyone asks — and the business visibly slows down whenever they're unavailable.
That's a sharper problem to design for than "enterprise knowledge management," and a different one: cost sensitivity is real, not a line item; there's no dedicated administrator to run a complex deployment; and sending client files, patient records, or unreleased financials to a third-party cloud AI service is a real objection, not a hypothetical compliance concern. Enclave is built for that business — a private, on-prem knowledge assistant that turns the documents already sitting in its folders into searchable answers, without a cloud dependency and without needing an IT team to run it.
Even a business this size has real internal boundaries worth respecting in software, not just in policy — an accounting firm's client-engagement teams shouldn't casually surface each other's files in a shared search; a law office's active cases carry real confidentiality expectations between the people working them. Enclave treats that boundary as a database-level guarantee rather than an application convention: one deployment, one shared model, and a security boundary that lives where a boundary belongs — not in whichever code path happened to remember to filter correctly that day.
It's fully on-prem by requirement, not preference, for the same reason the market segment demands it: the desktop shell, the database, and the language model inference all run on hardware the business already owns — no document, no query, and no answer ever leaves the network. That constraint rules out the easy version of almost every feature: retrieval, generation, and even per-team model behavior all have to be solved locally, on modest hardware, without a cloud fallback to lean on.
Крупные компании могут позволить себе информационный хаос. Малый бизнес — нет.
Большая часть разговоров об искусственном интеллекте для бизнеса сосредоточена на корпоративных решениях. Microsoft, Google, Atlassian, Notion и сотни хорошо финансируемых стартапов создают корпоративные RAG системы для организаций, у которых уже есть IT отдел, выстроенные процессы закупок и многомесячные циклы внедрения. Однако у малого бизнеса, будь то производство с пятнадцатью сотрудниками, юридическое бюро, логистическая компания, бухгалтерская фирма или частная клиника, существует та же фундаментальная проблема, а зачастую она проявляется ещё острее. За годы работы накапливаются инструкции, регламенты, счета, документы клиентов и переписка, которые оказывааются разбросаны по папкам и почтовым ящикам. При этом в компании обычно нет человека, чья работа заключается в том, чтобы поддерживать всю эту информацию в порядке. Во многих небольших компаниях роль базы знаний фактически выполняет сам владелец. Именно к нему обращаются с вопросами сотрудники, и стоит ему оказаться недоступным, как работа бизнеса начинает заметно замедляться.
Эта задача принципиально отличается от управления знаниями в крупных организациях. Для малого бизнеса стоимость внедрения имеет значение, а не является очередной строкой бюджета. Здесь нет выделенного администратора, способного сопровождать сложную систему, а передача договоров клиентов, медицинских данных или внутренней финансовой отчётности в облачный сервис искусственного интеллекта часто оказывается неприемлемой. Enclave создавался именно для таких компаний. Это локальный ассистент знаний, который превращает уже существующие документы в доступную для поиска базу знаний, не требуя облачной инфраструктуры, постоянного подключения к интернету или собственной IT команды.
Даже в небольших компаниях существуют реальные границы доступа, которые должны обеспечиваться архитектурой системы, а не внутренними регламентами. Сотрудники бухгалтерской фирмы, работающие с разными клиентами, не должны случайно получать доступ к чужим документам. Юристы, ведущие разные дела, ожидают, что материалы останутся доступны только тем, кто непосредственно участвует в работе. Enclave реализует такую изоляцию на уровне базы данных. Все команды используют одно развёртывание и одну языковую модель, однако доступ к данным определяется самой архитектурой системы, а не зависит от того, была ли очередная проверка прав доступа реализована без ошибок.
Полностью локальная архитектура является не особенностью продукта, а его фундаментальным требованием. Десктопное приложение, база данных, векторный индекс и инференс языковой модели работают на оборудовании, которое уже находится в компании. Документы, пользовательские запросы и ответы модели никогда не покидают внутреннюю сеть. Это ограничение определяет каждое архитектурное решение в проекте. Индексация документов, поиск, генерация ответов и разграничение доступа должны эффективно работать без облачных сервисов, на обычном офисном оборудовании и без внешней вычислительной инфраструктуры.
02 · Requirements & constraints
02 · Требования и ограничения
What it must do — and under what conditions
| FR | Ingest a business's existing documents — PDFs, manuals, SOPs, invoices, case files — into a department's knowledge base. Answer questions via retrieval-augmented generation, combining semantic and keyword search over that department's documents only. Chat with a locally-run LLM whose behavior is specialized per department. Support multiple concurrent ingestion jobs without duplicate processing. |
|---|---|
| NFR | Department isolation must hold at the database layer — a bug in application code must be structurally incapable of returning another department's rows, not merely unlikely to. All inference and storage on-prem; no request to any external LLM or embedding API. Ingestion workers must run concurrently without double-claiming the same job. Switching the active department's model behavior must not require reloading the full base model into memory. |
| Constraints | Local Docker Postgres on port 5433, chosen to avoid a collision with another Softwarean project's Postgres instance running on the same development machine. Desktop-shell deployment (Tauri), not a hosted service — the security model has to hold without a network perimeter to lean on. |
Что система обязана делать — и в каких условиях
| FR | Загрузить существующие документы бизнеса — PDF, инструкции, регламенты, счета, дела клиентов — в базу знаний отдела. Отвечать на вопросы через retrieval-augmented generation, комбинируя семантический и ключевой поиск только по документам этого отдела. Общаться с локально запущенной LLM, поведение которой специализировано под отдел. Поддерживать несколько одновременных задач загрузки без дублирующей обработки. |
|---|---|
| NFR | Изоляция отделов должна держаться на уровне базы данных — ошибка в коде приложения должна быть структурно неспособна вернуть строки другого отдела, а не просто «маловероятно» это сделать. Весь инференс и хранение — on-prem, ни одного запроса к внешнему LLM или embedding API. Воркеры загрузки должны работать параллельно без двойного захвата одной задачи. Переключение поведения модели под активный отдел не должно требовать перезагрузки всей базовой модели в память. |
| Ограничения | Локальный Docker Postgres на порту 5433 — выбран, чтобы избежать конфликта с инстансом Postgres другого проекта Softwarean на той же машине разработки. Развёртывание в виде десктоп-оболочки (Tauri), не хостинговый сервис — модель безопасности должна держаться без сетевого периметра, на который можно опереться. |
03 · Architecture
03 · Архитектура
The department boundary is set once, per request, in Rust
A React UI runs inside the Tauri webview; a Rust core handles every Tauri command and is the only thing that ever opens a database connection. Before issuing any query, the Rust core sets the active department as a session variable — the single point where "who is asking" becomes a fact the database itself can enforce. From there, two local services do the work: PostgreSQL with pgvector for hybrid retrieval, and Ollama running a local model with the requesting department's LoRA adapter loaded. Nothing in this diagram reaches outside the machine.
Граница отдела выставляется один раз, на запрос, в Rust
React-UI работает внутри webview Tauri; Rust-ядро обрабатывает каждую Tauri-команду и является единственным, что вообще открывает соединение с базой данных. Перед любым запросом Rust-ядро выставляет активный отдел как переменную сессии — единственную точку, где «кто спрашивает» становится фактом, который сама база способна обеспечить. Дальше работают два локальных сервиса: PostgreSQL с pgvector для гибридного поиска и Ollama с локальной моделью, в которую загружен LoRA-адаптер запрашивающего отдела. Ничто на этой диаграмме не выходит за пределы машины.
04 · Key decisions
04 · Ключевые решения
Five decisions that shaped the system
Digests of the ADRs that mattered most, out of ten written for this project. Each one names what the decision cost — a trade-off you don't acknowledge is a trade-off you didn't see.
Пять решений, определивших систему
Выжимки из наиболее значимых ADR — всего их для проекта написано десять. В каждой названа цена решения — трейд-офф, который не назван, это трейд-офф, который не был замечен.
Row-level security, forced
Row-level security в принудительном режиме
- Options
- Filter by department in every application query · a middleware layer that injects the filter automatically · PostgreSQL row-level security with
FORCE ROW LEVEL SECURITY. - Chosen
- RLS, forced. Every table holding department-scoped data carries a policy keyed on a session variable —
current_setting('app.department_id')— set once by the Rust core per connection. - Why
- Filtering in application code means the boundary is only as strong as the least-careful query anyone ever writes, including future contributors. RLS moves the boundary into the database's own query planner — a missing
WHEREclause simply can't leak data, because the database was never going to hand back those rows regardless of what was asked.FORCEmatters specifically because without it, the table owner role bypasses RLS by default — an easy way to accidentally defeat the whole model during development. - What it cost
- Every query plan now carries the policy check, and debugging "why did this return zero rows" sometimes means checking the session variable before the query logic. RLS bugs are also unusually quiet — a misconfigured policy fails by hiding data, not by erroring.
- Варианты
- Фильтровать по отделу в каждом запросе приложения · слой middleware, автоматически подставляющий фильтр · row-level security PostgreSQL с
FORCE ROW LEVEL SECURITY. - Выбрано
- RLS, принудительно. Каждая таблица с данными, привязанными к отделу, несёт политику, завязанную на переменную сессии —
current_setting('app.department_id'), — выставляемую Rust-ядром один раз на соединение. - Почему
- Фильтрация в коде приложения означает, что граница настолько прочна, насколько прочен самый небрежный когда-либо написанный запрос, включая будущих контрибьюторов. RLS переносит границу в собственный планировщик запросов базы данных — пропущенный
WHEREпросто не может утечь данные, потому что база данных и не собиралась отдавать эти строки, что бы ни спросили.FORCEважен именно потому, что без него роль-владелец таблицы по умолчанию обходит RLS — лёгкий способ случайно свести на нет всю модель во время разработки. - Цена
- Теперь каждый план запроса несёт проверку политики, а отладка «почему вернулось ноль строк» иногда означает проверку переменной сессии раньше логики запроса. Баги RLS необычно тихие — неверно настроенная политика отказывает, скрывая данные, а не выбрасывая ошибку.
Ingestion runs under a separate, privileged role
Загрузка работает под отдельной привилегированной ролью
- Options
- Ingest documents using the same
app_userrole that serves reads · a dedicated ingestion role withBYPASSRLS. - Chosen
- A separate role. The application's read/chat path runs as
app_user, bound by RLS like everything else. The ingestion worker runs as its own role withBYPASSRLS, used only for writing new documents and their embeddings. - Why
- Ingestion legitimately needs to write across department boundaries as part of a single batch job; giving
app_userthat same bypass "just for ingestion" would mean the one role every user-facing query runs as is also the role that can see everything — a single privilege escalation away from defeating the entire isolation model. Splitting the role makes the invariant explicit and checkable: only the ingestion path may ever bypass RLS, and it's a role grant, not a runtime flag someone could flip. - What it cost
- Two roles to provision, grant, and keep straight in every environment — a real chance to misconfigure a fresh environment by granting the wrong one. It also means ingestion code paths need their own audit trail, since RLS itself provides none for that role.
- Варианты
- Загружать документы под той же ролью
app_user, что обслуживает чтение · выделенная роль загрузки сBYPASSRLS. - Выбрано
- Отдельная роль. Путь чтения/чата приложения работает как
app_user, связанный RLS наравне со всем остальным. Воркер загрузки работает под собственной ролью сBYPASSRLS, используемой только для записи новых документов и их эмбеддингов. - Почему
- Загрузке законно нужно писать через границы отделов в рамках одной пакетной задачи; дать
app_userтот же обход «только для загрузки» означало бы, что роль, под которой работает каждый пользовательский запрос, — та же роль, что видит всё, — один шаг эскалации привилегий от слома всей модели изоляции. Разделение роли делает инвариант явным и проверяемым: только путь загрузки может обходить RLS, и это грант роли, а не рантайм-флаг, который кто-то мог бы переключить. - Цена
- Две роли, которые нужно создавать, выдавать и не путать в каждом окружении, — реальный шанс неверно настроить свежее окружение, выдав не ту роль. А ещё это значит, что пути кода загрузки нуждаются в собственном журнале аудита, поскольку сам RLS для этой роли его не даёт.
Hybrid retrieval, fused by rank
Гибридный поиск, слитый по рангу
- Options
- Dense vector search only (pgvector) · lexical search only (Postgres full-text) · both, fused with Reciprocal Rank Fusion.
- Chosen
- Both, run as separate queries, then fused:
score = Σ 1 / (k + rank_i)across the dense-search rank and the lexical-search rank for each chunk,ka small constant. - Why
- Dense embeddings are excellent at conceptual similarity and bad at exact terms — a policy number, an error code, a proper noun. Lexical search is the reverse. Internal knowledge bases are full of exactly the terms embeddings struggle with, so relying on one retrieval mode alone throws away recall the other mode would have caught. RRF combines two ranked lists into one without needing the two scores to be on a comparable scale, which raw score-averaging would require and rarely gets right.
- What it cost
- Two indexes to maintain per chunk (a vector index and a tsvector index), two queries to run and fuse on every search instead of one, and a fusion constant
kthat has no principled default — it's tuned, not derived.
- Варианты
- Только плотный векторный поиск (pgvector) · только лексический поиск (полнотекстовый Postgres) · оба, слитые Reciprocal Rank Fusion.
- Выбрано
- Оба, как отдельные запросы, затем слияние:
score = Σ 1 / (k + rank_i)по рангу плотного и лексического поиска для каждого чанка,k— небольшая константа. - Почему
- Плотные эмбеддинги отлично улавливают концептуальное сходство и плохи на точных терминах — номер политики, код ошибки, имя собственное. Лексический поиск — наоборот. Внутренние базы знаний полны именно тех терминов, с которыми у эмбеддингов проблемы, так что опора на один режим поиска теряет полноту, которую поймал бы другой. RRF объединяет два ранжированных списка в один, не требуя, чтобы два скора были в сравнимой шкале, — а именно этого требует усреднение сырых скоров и редко делает правильно.
- Цена
- Два индекса на чанк для поддержки (векторный и tsvector), два запроса вместо одного на каждый поиск, плюс константа слияния
kбез принципиального значения по умолчанию — она подбирается, а не выводится.
A claimable job queue, not a message broker
Очередь задач с захватом, а не брокер сообщений
- Options
- A dedicated queue system (Redis, RabbitMQ) · a Postgres table polled with
SELECT ... FOR UPDATE SKIP LOCKED. - Chosen
- The Postgres table. Ingestion workers claim the next unprocessed row with
SELECT * FROM ingestion_jobs WHERE status = 'pending' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1. - Why
SKIP LOCKEDmeans multiple workers can poll concurrently and each gets a different row — no worker blocks waiting for a lock another worker is holding, and no two workers ever process the same job. A dedicated broker would solve the same problem but add an entire service to run, monitor, and back up on-prem, for a workload this system's job volume doesn't come close to needing.- What it cost
- Polling instead of push-based delivery — a worker with nothing to do wakes up and checks anyway. At the ingestion volumes this system handles that's irrelevant, but the design wouldn't scale as-is to a workload orders of magnitude larger.
- Варианты
- Выделенная система очередей (Redis, RabbitMQ) · таблица Postgres, опрашиваемая через
SELECT ... FOR UPDATE SKIP LOCKED. - Выбрано
- Таблица Postgres. Воркеры загрузки захватывают следующую необработанную строку:
SELECT * FROM ingestion_jobs WHERE status = 'pending' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1. - Почему
SKIP LOCKEDозначает, что несколько воркеров могут опрашивать параллельно, и каждый получает свою строку — ни один воркер не блокируется в ожидании чужой блокировки, и ни одна задача никогда не обрабатывается дважды. Выделенный брокер решил бы ту же задачу, но добавил бы целый сервис для запуска, мониторинга и бэкапа on-prem — ради объёма задач, до которого этой системе далеко.- Цена
- Опрос вместо push-доставки — воркеру без задач всё равно приходится просыпаться и проверять. При объёмах загрузки этой системы это неважно, но дизайн не масштабируется как есть на нагрузку на порядки больше.
One resident base model, adapters hot-swapped
Одна резидентная базовая модель, адаптеры меняются на лету
- Options
- Load a full fine-tuned model per department · one base model, per-department LoRA adapters loaded on demand.
- Chosen
- One base model kept resident in memory; a lightweight LoRA adapter is loaded or swapped based on which department the active conversation belongs to.
- Why
- Fine-tuned full models are gigabytes each; keeping several resident, or reloading one per department switch, is exactly the kind of cost that's invisible on a cloud GPU fleet and very visible on a single on-prem machine. LoRA adapters are megabytes and swap in milliseconds against an already-loaded base model — department-specific behavior without department-specific memory footprints.
- What it cost
- Every department is bounded by what the shared base model is fundamentally capable of; a LoRA adapter steers behavior, it doesn't grant new capability the base model lacks. That's a real ceiling if departments ever need genuinely divergent model capability, not just tone and domain vocabulary.
- Варианты
- Загружать полную дообученную модель на отдел · одна базовая модель, LoRA-адаптеры под отдел загружаются по требованию.
- Выбрано
- Одна базовая модель, резидентная в памяти; лёгкий LoRA-адаптер загружается или меняется в зависимости от того, какому отделу принадлежит активный диалог.
- Почему
- Полные дообученные модели весят гигабайты каждая; держать несколько резидентными или перезагружать по одной на переключение отдела — ровно та цена, что невидима на облачном парке GPU и очень видима на одной on-prem машине. LoRA-адаптеры весят мегабайты и меняются за миллисекунды поверх уже загруженной базовой модели — специфика отдела без специфического потребления памяти на отдел.
- Цена
- Каждый отдел ограничен тем, на что фундаментально способна общая базовая модель; LoRA-адаптер направляет поведение, а не добавляет новую способность, которой у базовой модели нет. Это реальный потолок, если отделам когда-нибудь понадобятся действительно разные возможности модели, а не только тон и предметная лексика.
05 · Data model
05 · Модель данных
Sixteen entities; the boundary runs through eight of them
The full schema has sixteen entities. The eight below are the ones the department boundary actually touches — everything else (audit logs, adapter version history, job metadata) hangs off these.
Шестнадцать сущностей; граница проходит через восемь
Полная схема — шестнадцать сущностей. Восемь ниже — те, через которые действительно проходит граница отдела; всё остальное (журналы аудита, история версий адаптеров, метаданные задач) висит на них.
department_id the RLS policy filters on; the other tables inherit isolation transitively through their foreign keys.department_id, по которому фильтрует политика RLS; остальные таблицы наследуют изоляцию транзитивно через внешние ключи.The RLS policy pattern is uniform across every scoped table:
Паттерн политики RLS единообразен для каждой ограниченной таблицы:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
CREATE POLICY department_isolation ON documents
USING (department_id = current_setting('app.department_id')::uuid);
-- set once per connection by the Rust core, before any query runs
SET LOCAL app.department_id = '...';
document_chunks doesn't carry its own department_id column — it inherits scope through its foreign key to documents, via a policy that joins rather than duplicating the column. This is deliberate: a duplicated, independently-settable department_id on the child table would create a second place the boundary could quietly drift out of sync with the parent.
document_chunks не несёт собственной колонки department_id — она наследует область через внешний ключ к documents, через политику, которая делает join, а не дублирует колонку. Это намеренно: дублированный, независимо устанавливаемый department_id в дочерней таблице создал бы второе место, где граница могла бы тихо разойтись с родителем.
06 · Hard problems
06 · Трудные места
Migration 005 wasn't safe to re-run
A routine local environment reset — drop the database, replay every migration from scratch — failed partway through migration 005. The migration had been written assuming it would only ever run once, against a database in a known prior state; it created objects without guard clauses, so replaying it against an environment where some of those objects already existed in a slightly different form errored out instead of converging. The fix was to rewrite it idempotently — CREATE TABLE IF NOT EXISTS, conditional policy creation, guards around anything that isn't naturally re-runnable — and the broader lesson generalized to every migration after it: a migration that only works the first time isn't finished, because "reset and replay" is exactly the operation a schema this sensitive needs to survive without drama.
A dormant function was still wired into a live policy
An audit pass over the RLS policies surfaced app_user_in_department() — a helper function still referenced by an active policy, checking membership against a memberships table that nothing in the current schema populates anymore, because membership checks had since moved to department_memberships via is_member_of(). The function wasn't dead code sitting unused; it was dead code a live security policy still depended on, silently evaluating against an empty table. Removing it outright, mid-audit, risked breaking whatever policy still called it without a clear picture of every call site — so the fix was deferred to its own migration, done deliberately rather than as a drive-by cleanup during an unrelated change. The finding itself is the point: RLS policies need the same "who still calls this" audit that application code gets, and they're easy to forget precisely because they don't show up in a normal code-search for callers the way a function invoked from React does.
Миграцию 005 нельзя было безопасно перезапускать
Рутинный сброс локального окружения — снести базу, проиграть все миграции с нуля — упал на середине миграции 005. Миграция была написана в расчёте на то, что выполнится только один раз, против базы в заведомо известном предыдущем состоянии; она создавала объекты без защитных условий, так что повторный прогон против окружения, где часть этих объектов уже существовала в чуть другой форме, падал с ошибкой вместо того, чтобы сойтись. Исправление — переписать её идемпотентно: CREATE TABLE IF NOT EXISTS, условное создание политик, защита вокруг всего, что не перезапускается естественным образом, — а более широкий урок распространился на каждую следующую миграцию: миграция, работающая только с первого раза, не закончена, потому что «сбросить и проиграть заново» — ровно та операция, которую схема такой чувствительности должна пережить без драмы.
Спящая функция всё ещё была подключена к живой политике
Аудит политик RLS вскрыл app_user_in_department() — вспомогательную функцию, на которую всё ещё ссылалась активная политика, проверяющую членство по таблице memberships, которую в текущей схеме больше ничто не заполняет, поскольку проверки членства с тех пор переехали в department_memberships через is_member_of(). Функция была не мёртвым неиспользуемым кодом — она была мёртвым кодом, от которого всё ещё зависела живая политика безопасности, тихо вычисляясь по пустой таблице. Удалить её сразу, посреди аудита, означало рискнуть сломать какую-нибудь политику, всё ещё её вызывающую, без чёткой картины всех мест вызова — поэтому исправление отложено в отдельную миграцию, сделанную осознанно, а не походя во время несвязанного изменения. Сама находка и есть суть: политики RLS нуждаются в том же аудите «кто ещё это вызывает», что и код приложения, и их легко забыть именно потому, что они не всплывают при обычном поиске вызывающих кода, как это происходит с функцией, вызываемой из React.
07 · Verification
07 · Верификация
The isolation claim was tested from inside the database
- The core NFR checked directly: with
app.department_idset to department A, a query againstdocumentsseeded with rows for departments A and B returned only A's rows — verified inpsql, independent of any application code. - The
FORCErequirement checked by removing it in a scratch environment and confirming the table owner role could then see cross-department rows — proving the keyword does load-bearing work rather than assuming it from documentation. - The role split checked by attempting a cross-department read as the ingestion role and confirming
BYPASSRLSbehaves as expected, then confirmingapp_usercannot perform the same read. - The job queue checked by running two ingestion workers concurrently against a shared job table and confirming no job was claimed twice — the actual property
SKIP LOCKEDis there to guarantee.
Заявление об изоляции проверено изнутри базы данных
- Основной NFR проверен напрямую: с
app.department_id, выставленным на отдел A, запрос кdocumentsс заранее заполненными строками отделов A и B вернул только строки A — проверено вpsql, независимо от кода приложения. - Требование
FORCEпроверено удалением его в тестовом окружении и подтверждением, что роль-владелец таблицы после этого видит межотдельные строки, — доказательство того, что ключевое слово несёт реальную нагрузку, а не предположение по документации. - Разделение ролей проверено попыткой межотдельного чтения от роли загрузки с подтверждением ожидаемого поведения
BYPASSRLS, затем подтверждением, чтоapp_userто же чтение выполнить не может. - Очередь задач проверена запуском двух воркеров загрузки параллельно против общей таблицы задач с подтверждением, что ни одна задача не была захвачена дважды, — то самое свойство, которое призван гарантировать
SKIP LOCKED.
08 · Retrospective
08 · Ретроспектива
If I rebuilt it
- An automated test suite that runs the RLS verification checks on every migration, not just once by hand after writing the policy. The migration 005 incident and the dormant-function finding are both the kind of regression a CI-run isolation test would have caught immediately instead of during a manual audit.
- A policy audit as a recurring practice, not a one-time pass — the dormant
app_user_in_department()function was found because someone went looking, not because anything would have surfaced it otherwise. That shouldn't depend on remembering to look. - All ten ADRs published alongside the code from the start, not written up separately after the fact — for a project whose main claim is a security boundary, the reasoning behind each policy is as much the deliverable as the policy itself.
- One-command-at-a-time execution was the right call for schema work and I'd keep it, but I'd extend the same discipline to the retrieval-and-fusion code, which moved faster and consequently got less scrutiny per change.
- The framing itself started too generic — "corporate RAG" is a crowded category with well-funded incumbents, and it undersells what's actually distinctive here. Repositioning around small businesses (5–100 employees, no dedicated IT or knowledge-management staff, real cost sensitivity, real reasons not to trust a cloud vendor with client files) turned an abstract requirement — "many departments" — into a concrete one: confidentiality between a handful of client-facing teams at a firm that can't afford to get it wrong or hire someone to manage it. The technical decisions didn't change; the reason they matter got sharper.
Если бы строил заново
- Автоматический набор тестов, прогоняющий проверки RLS при каждой миграции, а не один раз вручную после написания политики. И инцидент с миграцией 005, и находка со спящей функцией — оба ровно того типа регрессии, которую CI-тест изоляции поймал бы немедленно, а не во время ручного аудита.
- Аудит политик как регулярная практика, а не разовый проход — спящая функция
app_user_in_department()нашлась потому, что кто-то пошёл искать, а не потому, что что-то всплыло бы само. Это не должно зависеть от того, вспомнят ли посмотреть. - Все десять ADR публиковались бы вместе с кодом с самого начала, а не оформлялись отдельно постфактум — для проекта, чья главная претензия — граница безопасности, рассуждение за каждой политикой такой же результат работы, как и сама политика.
- Выполнение по одной команде за раз было правильным решением для работы со схемой, и я бы его сохранил, но распространил бы ту же дисциплину на код поиска и слияния — он двигался быстрее и получил меньше проверки на изменение.
- Само позиционирование поначалу было слишком общим — «корпоративный RAG» это переполненная категория с хорошо профинансированными игроками, и она преуменьшает то, что здесь реально отличает продукт. Перепозиционирование вокруг малого бизнеса (5–100 сотрудников, без выделенного IT или управления знаниями, реальная чувствительность к стоимости, реальные причины не доверять облачному вендору файлы клиентов) превратило абстрактное требование — «много отделов» — в конкретное: конфиденциальность между несколькими командами, работающими с клиентами, в фирме, которая не может позволить себе ошибиться в этом или нанять того, кто будет этим управлять. Технические решения не изменились; причина, по которой они важны, стала острее.