# Модель угроз ## Периметр **Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей `trusted-header-login`, называет она его **заголовком, который ставит обратный прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login` 2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит **каждый** запрос, и отзыв доступа действует со следующего. Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` — **само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси, и человек, которого прокси не назвал, до приложения дошёл бы только мимо него — а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных открытость не касается — всякий адрес под корнем приложения узнанного по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра. **Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**, а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное браузеру сервису нечем. Целевой периметр добавляет к нему отдельный вход для программ по личным токенам и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14** задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня доступа — страницы расхода для владельца сервиса. Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения не принимает, и держит это схема хранилища. Прежде такие записи заводил вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран вместе с этим исключением. **Целевой периметр шире сегодняшнего не только входом.** Содержимое записи начинает уходить на три новые стороны — языковой модели, в канал уведомлений и на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11 сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда уходит содержимое записи» и «Что вне модели». **Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22, задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком: панели не существует, второго периметра на порту сервиса не осталось, и правилу прокси нечего закрывать. **Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и `/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи `spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего: оба попадают под общее правило неизвестного пути и отдают разметку приложения. Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса. **Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача `oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его секрет в настройки коллекции пользователей, и чтение файла базы становилось равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним. **Вместо него — новый и главный: барьер держится на том, что прокси ставит заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять** заголовок вместо замены, оставит рядом со своим значением присланное анонимом — и аноним войдёт под любым именем. Половину этой беды сервис закрывает сам: запрос с двумя значениями `Remote-User` не узнаёт никого. Вторую половину проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template` репозитория `pet-project-server`, и **требование к нему такое — заголовки `Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает человек. **`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.** С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка: иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и бюджет становится общим на весь сервис. Цепочка читается **справа налево**, доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым значением распоряжается сам спрашивающий, а правое приписал ближайший к нам прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого соединения. **Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.** Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой контейнер на хосте», включая чужие. Образец конфига называет узкий пример именно поэтому. **Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи — `Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново после ухода прежнего владельца, и тогда новый человек при первом же обращении попадает в **существующую** запись и получает весь её архив — самое чувствительное, что у сервиса есть. Сервис этого не различает и различить не может: неизменяемого признака заголовок не приносит. Не допускать переиспользования — работа провайдера, и это принятая цена, записанная в [access](../openspec/specs/access/spec.md). Обратная сторона той же цены: переименование заводит **новую** запись, а прежняя остаётся с архивом, который нечем ни слить, ни убрать. Отсюда главное следствие, из которого читается всё остальное: **`POST /app/audiorecords` требует входа, а размер файла ограничен потолком записи, число же запросов ограничено только частотой**. Вошедший тратит наши деньги на распознавание столько, сколько захочет: ограничитель частоты под корнем приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму по-прежнему нет — её заводит `per-user-size-quota`. ## Недоверенный вход Что приходит извне и каким каналом. | Вход | Канал | Кто может слать | | --- | --- | --- | | **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения | | Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков | | Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи | | Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу | | Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` | | Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель | | Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи | Что добавится вместе с целевым периметром — каждый вход появляется своей задачей, и до неё его нет: | Вход | Канал | Кто может слать | Чья задача | | --- | --- | --- | --- | | Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` | | Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` | | Вычитанный текст | Ответ той же модели | То же | `literary-text-level` | | Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` | | Хеш-сумма файла | Поле запроса приёма | Отправитель — и она же решает, отдать ли прежнюю запись | `dedup-by-content-hash` | Ответ языковой модели опаснее прочего в этом списке: он приходит текстом, идёт в заголовок записи и оттуда на экран — то есть внешний сервис пишет то, что увидит человек. ## Куда уходит содержимое записи Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез 2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом приложения. Целевой периметр добавляет три пути, каждый — своей задачей: | Куда | Что уходит | Чья задача | | --- | --- | --- | | Языковая модель за шлюзом bifrost | Текст расшифровки целиком | `llm-insights-adapter`, затем `literary-text-level` | | Канал уведомлений (ntfy через apprise) | Готовый текст либо причина отказа | `ntfy-delivery` | | Почтовый сервер | Готовый текст либо причина отказа, на адрес из учётной записи | `email-notification` | Каждая из названных задач обязана оставить строку в этом разделе — там это записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст остаётся в приложении. ## Из чего строятся пути и ключи Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога. Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда путь не предполагался. - **Путь на диске** выбирает сервис: `data/records//<имя>`. Обе части задаёт он сам — подкаталог назван идентификатором записи, имя файла это `<расширение>`, — и имя, данное отправителем, не попадает ни в одну из них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки списком; `filepath.Ext` режет по последней точке и не пропускает разделитель каталогов, но это единственное, что стоит между входом и именем файла. Длина расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками роняет заведение временного файла. - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с расширением. Бакет один на все записи, префикса по пользователю нет. С 2026-08-14 копия там файлом записи не считается: она существует лишь потому, что провайдер читает аудио по адресу, и её ключ живёт в строке попытки распознавания. - **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге записи, под именем, которое задаёт сервис. Содержимое там — **полный текст речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса, которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в журнал, ни в метку метрики, ни в ответ. - **Адрес файла** — `GET /app/audiorecords/{id}/file?copy=original|normalized`. Право пройти по нему даёт **узнавание пришедшего и владение записью**, и судится оно там же, где отдаётся файл. Значений на предъявителя сервис не выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал идёт расширение своим полем. - **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же единственное, что защищает карточку записи, её текст и её файл сверх владения. - **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит [webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md). Целевой периметр добавляет сюда три вещи, и все три — от новых задач: - **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска прежней записи. Ищется она **в пределах одного пользователя**: глобальный поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и заодно сообщал бы, что запись у кого-то уже есть. - **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же плоский каталог — раскладка каталога данных меняется, и это необратимо. - **Имя отправляемого документа** (`long-text-delivery`) собирается из идентификатора задачи: имя, данное пользователем, в него не попадает. ## Что разграничивает доступ - **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения, переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому отзыв доступа у Authelia действует со следующего обращения. Собственных токенов сервис не принимает вовсе: значения, предъявленного запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое значение било заголовок — им работал владелец панели; панели нет, и правило приоритета осталось бы правилом без предмета. **Область узнавания — корень приложения**, и выводится она из объявленного адресного пространства сервиса: слои одеты на корень целиком, вторым списком адресов область не описывается. Проба здоровья, метрики и ресурсы приложения под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё. - **Учётная запись** — заводится первым обращением с новым логином и находится по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе: своих экранов профиля он не заводит, а поверхности хранилища, правившей запись библиотечным правилом, не осталось. - **Файл записи** — узнавание пришедшего и владение записью, судимые в самом обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места, где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос, сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла. - **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы приложение не делает: кого пускать, определяет правило провайдера на этого клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду его не проверить. Клиент, настроенный слишком широко, открывает сервис всякому, у кого есть учётная запись в общей Authelia. Решение владельца от 2026-08-12. - **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать больше нечего, и адресов этих не существует. - **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному: учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и учётной записи на них не заводит. Наружу их закрывает правило обратного прокси — работа выкладки, и сервис на неё не полагается: содержимого записей эти адреса не несут. - **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя частоты на них нет: правило заведено под корень приложения, а раздача стоит вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли кто-то, — узнанному и неузнанному отдаётся одно и то же. Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла есть владелец. Знание идентификатора задачи правом её читать больше не является — читает её тот, кто её принёс. Целевой периметр заводит четыре механизма вместо одного белого списка; первый из них уже стоит: | Механизм | Что даёт | Чья задача | | --- | --- | --- | | Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` | | Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` | | Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней модели нет вовсе: до него всё разграничение сводилось к «свой или чужой». Откуда он берётся — из группы OIDC или из конфигурации — не решено (`admin-stats-screen`). **Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До 2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и всех пользователей мимо любого из механизмов разграничения, а пускал его свой пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у сервиса осталось одно — владение записью. Владелец сервиса взамен получил одно действие и один инструмент: подкоманда `cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот же каталог данных**, то есть требует доступа к файлам сервера, а не к сети: поверхности, открытой в интернет, у неё нет вовсе. ## Что чувствительнее чего 1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка; это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной колонкой, а шестью таблицами: сама запись (заголовок и краткое описание), `texts` (расшифровка и вычитанный текст), `structures` (реплики со временем), `recognitions` (попытка распознавания; **сохранённый ответ провайдера — полный текст речи — лежит файлом в подкаталоге записи**), `record_events` (журнал событий, содержимого не несёт) и `topics` (словарь тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается наравне с записью — норму держит спека `storage`. 2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage. Утечка оплачивается деньгами и доступом к бакету. Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и из базы вместе с собственным входом. Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита. Целевой периметр добавляет к списку пять записей, и первая из них — новый вид секрета, которого сегодня в проекте нет вовсе: 1. **Токены пользователей** (`api-tokens`). Токен даёт права своего владельца целиком. Срока жизни у него нет. В базе лежит только отпечаток, полное значение показывается один раз при выпуске. Это первый секрет, который хранится **в базе**, а не в конфигурации. 2. **Ключ языковой модели** и адрес шлюза bifrost (`llm-insights-adapter`). Утечка оплачивается деньгами. 3. **Пароль почтового сервера** (`email-notification`). 4. **Адрес почты пользователя** — приходит от Authelia и хранится у нас (`oidc-login`, `email-notification`). 5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит, но говорит, кто и когда пользовался сервисом и сколько; страница расхода открыта только владельцу. Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище, пропал, и ключа под него в конфигурации не заводится по той простой причине, что заводить нечего. Тексты расшифровок в логи не пишутся — логируется длина текста и идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано 2026-08-11 задачей `no-user-filename-in-log`; запрет проверяют тесты приёма по HTTP на успешном пути и на пути отказа — они ищут значение, а не имя поля. **Хвост после последней точки остаётся в журнале, и это объявленное изъятие** инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток. Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08` — `08`. В журнал оно идёт собственным полем, а не в составе имени файла: по нему прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени файла объявлен необратимым и меняется решением человека. **Наружу хвост не выходит.** Метки метрик (`file_extension` у `transcriber_input_file_size_bytes`, `source_format` у `transcriber_conversion_duration_seconds`) несут расширение, только приведённое к закрытому перечню известных форматов; всё прочее заменяется значением `other`. Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем. Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало `mp3`) — форма выровнялась с меткой конвертации, которая точку не носила никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный по старому значению, покажет пустоту, и это не поломка. Требование важно тем, что `GET /metrics` открыт вместе с остальным: без приведения хвост читал бы кто угодно из интернета, а множеством значений метки распоряжался бы анонимный отправитель. Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки клиента — закрыты задачами `no-user-filename-in-log` и `local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14 вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор случая остался в [review.md](review.md) — он про класс, а не про Telegram. Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла настроек пересказывался как есть, а библиотека разбора собирает текст отказа из разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка секретного ключа с оборванной кавычкой — типовая поломка криво собранного шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ пересобирается своими словами: путь, строка, столбец и последний ключ, без текста библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»; оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек. Остаточный риск назван там же: разрез опирается на то, какое семейство отказов несёт значения **в нынешней версии** библиотеки. ## Что вне модели Перечислить явно. - **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и Ansible — не наша граница. - **Машина разработчика и то, что он на ней поднимает.** В репозитории лежит `cmd/devtools` — оснастка разработчика; её подкоманда `proxy` встаёт на место контура: ставит заголовок `Remote-User` и переправляет запрос сервису, не проверяя ничего. С 2026-08-15 по 2026-08-22 ту же роль играл `cmd/oidcstub`, подставной провайдер OIDC. Двух вещей это не отменяет, и обе проверяемы: в образ оснастка не едет (ступень собирает `./cmd/transcriber` поимённо), а слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял её у себя в чужой сети, отвечает за это сам. - **Злоупотребление со стороны пользователя из белого списка.** Приглашённому доверяем полностью. - **Достоверность расшифровки.** Подмена или искажение текста на стороне SpeechKit не рассматривается. - **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся. - **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно (паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а не замер: распределения длин у сервиса нет, а самая длинная проверенная запись — 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)). Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот нет — это граница домена, [passport.md](passport.md), «Учёт денег»; расход считают `usage-accounting` и `admin-stats-screen`. Для модели угроз отсюда следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом ничем не наблюдается — открытый вопрос `architecture.md`. - **Перерасход денег на внешних сервисах.** Распознавание и языковая модель оплачиваются по факту; потолка на пользователя нет по тому же решению. - **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан внешней программе, своей песочницы вокруг неё нет. - **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить бессрочно, и тем же днём заведена задача `delete-record`: своя запись убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат. **Руками запись сегодня убирается только запросом к базе, и порядок в нём несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее чего»), связи приложений с записью обязательны и каскада не имеют, поэтому удаление самой строки отвергается базой, пока живы приложения. Порядок такой: сперва строки приложений — журнал событий, попытка распознавания, структура, тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом убираются **одним движением**: подкаталог записи под её идентификатором. Тот, кто убрал только файлы, стирает аудио и **оставляет полный текст речи** — расшифровку, разбивку по репликам и сохранённый ответ провайдера. До `delete-record` это единственный способ, и он ручной целиком.