letopis 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENT-CHEATSHEET.en.md +117 -10
  2. package/AGENT-CHEATSHEET.md +110 -8
  3. package/CHANGELOG.md +739 -0
  4. package/README.en.md +873 -68
  5. package/README.md +830 -66
  6. package/dist/admin.d.ts +24 -4
  7. package/dist/admin.js +39 -9
  8. package/dist/cache.d.ts +33 -9
  9. package/dist/cache.js +81 -32
  10. package/dist/chain.d.ts +29 -7
  11. package/dist/chain.js +61 -12
  12. package/dist/cli.js +59 -7
  13. package/dist/errors.js +4 -2
  14. package/dist/import.d.ts +6 -0
  15. package/dist/import.js +196 -175
  16. package/dist/index.d.ts +27 -5
  17. package/dist/index.js +191 -29
  18. package/dist/internal.d.ts +16 -0
  19. package/dist/internal.js +14 -0
  20. package/dist/load.d.ts +63 -0
  21. package/dist/load.js +331 -44
  22. package/dist/mcp.d.ts +28 -0
  23. package/dist/mcp.js +810 -0
  24. package/dist/model.d.ts +34 -0
  25. package/dist/model.js +17 -0
  26. package/dist/project.d.ts +309 -0
  27. package/dist/project.js +1097 -0
  28. package/dist/registry.d.ts +17 -0
  29. package/dist/registry.js +13 -0
  30. package/dist/signals.d.ts +73 -0
  31. package/dist/signals.js +169 -0
  32. package/dist/sql.d.ts +1 -1
  33. package/dist/sql.js +26 -7
  34. package/dist/tx.d.ts +9 -7
  35. package/dist/tx.js +15 -5
  36. package/dist/typed.d.ts +21 -6
  37. package/dist/types.d.ts +77 -4
  38. package/dist/types.js +8 -3
  39. package/dist/up.d.ts +16 -2
  40. package/dist/up.js +34 -4
  41. package/dist/uuid.js +19 -8
  42. package/dist/watch.d.ts +23 -2
  43. package/dist/watch.js +44 -7
  44. package/dist/write.d.ts +11 -2
  45. package/dist/write.js +57 -10
  46. package/llms.txt +9 -2
  47. package/package.json +13 -2
  48. package/sql/10-core.sql +13 -2
  49. package/sql/15-errors.sql +4 -1
  50. package/sql/40-class.sql +57 -2
  51. package/sql/60-write.sql +611 -82
  52. package/sql/70-read.sql +33 -8
  53. package/sql/75-history.sql +358 -0
  54. package/sql/80-auth.sql +7 -7
  55. package/sql/88-derived.sql +1154 -0
  56. package/sql/90-time.sql +134 -21
  57. package/sql/95-seed.system.sql +24 -6
  58. package/sql/99-revision.sql +1 -1
  59. package/sql/README.md +5 -1
  60. package/sql/migrate/0006-changed-paths.sql +3 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,745 @@
2
2
 
3
3
  Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
4
4
 
5
+ ## [1.2.0] — 2026-10-28
6
+
7
+ letopis 1.2 по плану `reports/PLAN-LETOPIS-1.1.md` (редакция 5, этапы 4–6 и контрольная точка Б) и плану доработок
8
+ после точки Б: проекторы из журнала, агрегаты в базе, инструменты MCP; ревью безопасности №2 и №3. Версия `1.2.0` (во
9
+ время работы — `1.2.0-alpha.0`, не публиковалась). Схема 1.1.0 обновляется повторным `up()`: ревизия движка прежняя
10
+ (6), функции накатываются заново. Первый `up()` строит индекс журнала `log_class_tenant_at (class, tenant, at)` вместо
11
+ `log_class_at`, и пока индекс строится, запись в журнал ждёт: на большой установке постройте его заранее
12
+ (`create index concurrently`, README, «Обновление пакета и схемы»). Обновление схемы 1.1.0 с данными проверено разовым
13
+ прогоном (установка пакетом 1.1.0, `up()` 1.2, вход, чтение, запись, ужесточение, `purge` удалённого в 1.1,
14
+ `maintain()`, `verify({ data: true })`); в набор тестов оно не входит.
15
+
16
+ ### Added — этап 4а (проекторы)
17
+ - `db.projector(имя, { from, into, version, handle, withBefore, onExpired, batch, pollMs })` — проектор из журнала
18
+ (`lib/src/project.ts`): строит строки производных классов из изменений классов-источников. `run()` догоняет
19
+ журнал и следует за ним до `stop()`, `run({ once: true })` только догоняет; `rebuild()` и `rebuild({ chunked: true })`
20
+ перестраивают проекцию с текущего состояния; `status()` отдаёт состояние, версию, курсор и отставание; `close()`
21
+ останавливает проектор и отписывает его от сигналов. Каждая пачка — одна транзакция: блокировка проектора,
22
+ чекпоинт, изменения журнала, `handle(changes, out)`, запись результатов и сдвиг курсора. Каждое изменение
23
+ применяется ровно один раз, писатель у проектора один. Только на администраторском подключении (иначе
24
+ `admin_required`).
25
+ - Опция класса `derivedBy: 'имя-проектора'`: строки класса пишет только этот проектор. Опция наследуется, потомок её
26
+ не меняет; концы производного класса — только `onDelete: 'cascade'` или `'unset'`; системный класс производным не
27
+ бывает; концы других классов на производный класс не ведут. Опцию показывают реестр, `db.describe()` и
28
+ `describeText()`.
29
+ - Функции базы `lib/sql/88-derived.sql` (только для роли владельца): `projector_state` (чекпоинт — строка `secret`
30
+ вида `PROJECTOR`), `projector_advance` (сдвиг чекпоинта, только если курсор не изменился), `derived_split`,
31
+ `derived_id`, `derived_apply` (запись результатов пачки: `upsert`, `inc`, `delete`), `derived_clear` (удаление
32
+ строк проектора перед перестройкой).
33
+ - Код ошибки базы `LT021` `derived_class`: строку производного класса пишет, правит или удаляет не его проектор,
34
+ либо конец ведёт на такую строку.
35
+ - Коды библиотеки `rebuild_required` (проекция построена другой версией проектора) и `projector_busy` (перестройку
36
+ не начать: проектор сейчас работает в другом процессе).
37
+ - Зарезервированное имя корня `projector`; типы `Projector`, `ProjectorOptions`, `ProjectorChange`, `ProjectorOut`,
38
+ `ProjectorRow`, `ProjectorReport`, `ProjectorStatus`.
39
+
40
+ ### Changed — этап 4а
41
+ - Функции кредов (`auth_credentials`, `auth_revoke_credential`, `auth_set_flags`) не видят вид `PROJECTOR`, а
42
+ `auth_kind_ok` не принимает его как вид кредов.
43
+ - `targets_bad` отличает производную цель: возвращает `!роль`, и триггеры записи, `check_links` и проверка базы в
44
+ загрузчике отвечают `derived_class`, а не `target_not_found`.
45
+ - Загрузчик (`db.load()` и первая заливка) отклоняет строки производных классов и ссылки на их строки —
46
+ `derived_class` в отчёте.
47
+ - `restore`, `reclass`, `rekey` и `rekeyClass` строк производного класса — `derived_class`.
48
+ - Ревью коммита: проектор работает с арендаторами, у которых каждый класс `into` помечен его `derivedBy`, и
49
+ пропускает изменения остальных — одноимённый класс без пометки у другого арендатора больше не останавливает его для
50
+ всех; класс System, который становится производным, сверяется со ссылками всех арендаторов; описание со ссылкой и
51
+ пометка `derivedBy` идут под блокировками семейства и не проходят параллельно мимо друг друга; источники и
52
+ результаты проектора — свои у каждого арендатора: имя класса одного арендатора не меняет набор источников другого.
53
+ - Правка `derivedBy` в описании класса — не безопасное изменение (`class_change_safe`): её проверяют как ужесточение.
54
+
55
+ ### Tests — этап 4а
56
+ - `projector` (план 1.1, §7.5 №1–11 и гонка №19 §7.8): догонка 10 000 изменений, ровно один раз при обрыве
57
+ посреди пачки, один писатель, долгая пишущая транзакция, транзакция на границе снимка перестройки, запрет записи
58
+ в производные классы и ссылок на них, границы `out`, перестройка атомарная и пачками во время записи источника,
59
+ `purge` источника служебным изменением, отставание старше яруса `all` (`cursor_expired`, `onExpired: 'rebuild'`,
60
+ молчащий проектор не истекает), функции кредов; версия, имя, администраторское подключение, повторный `up()`;
61
+ два процесса одного проектора при параллельной записи источника.
62
+ - `engine-attacks` №33–34 (план 1.1, §7.7): запись в производный класс и ссылка на него — `derived_class`, описание
63
+ класса со ссылкой на производный отклонено; чекпоинт проектора через функции кредов — строк нет в ответах,
64
+ изменить его нельзя.
65
+ - `scenario-salon`: матрица покрытия API исключает `projector` — его проверяет набор `projector`.
66
+ - `initial-load` 7.4 №5: загрузка стартует после вставки транзакции `early`, как `holdRow` в `write-races`. В CI
67
+ загрузка иногда брала блокировку первой и проходила по пустой таблице без `not_empty`. `lt()` сообщает
68
+ «ожидалась ошибка X, а операция прошла», если операция не бросила, вместо `TypeError` на `null`.
69
+
70
+ ### Added — этап 4б (агрегаты в базе)
71
+ - Опция класса `aggregate: { поле: { sum: 'поле' | count: true, of: 'Класс', via: 'роль', where?: JSON Schema } }`:
72
+ поле держит сама база — сумма поля или число живых строк класса-источника (с потомками) того же арендатора, чей
73
+ одиночный конец `via` ведёт на строку и чьи данные проходят `where`. Строка System в агрегат не входит. Опция
74
+ наследуется, потомок заменяет её целиком; её показывают реестр (`ClassInfo.aggregate`), `db.describe()` и
75
+ `describeText()`; в `letopis/model` — `ClassDef.aggregate` и тип `AggregateDef`, экспорт типа `AggregateInfo`.
76
+ - Триггеры `entity_total_ai`, `entity_total_au`, `entity_total_ad` (`lib/sql/88-derived.sql`): после оператора, с
77
+ переходными таблицами, без условия `WHEN` — срабатывают и для владельца (каскад удаления, `restore`, `reclass`,
78
+ `rekey`, удаление аккаунта, загрузчик), `enable always`. Вклады строк оператора складываются по цели, цели
79
+ блокируются в порядке id, итог проверяет описание класса цели (`minimum: 0` → `invalid_data`), версия цели —
80
+ `sys_update` с автором и причиной операции. Новая цель оператора (вставка, `restore`, смена класса) считается
81
+ целиком из живых источников в ту же версию. Перенос строки (`move_row`) копит вклады до своего конца и применяет их
82
+ одним итогом — промежуточная сумма не выходит за правило класса.
83
+ - Проверки описания агрегата (`invalid_class`): поле есть в схеме, числовое и не в ключе; ровно одно из `sum` и
84
+ `count`; конец `via` источника одиночный и принимает класс цели; поле `sum` — число; нет цикла агрегатов; цель и
85
+ источник не системные и не производные; агрегат арендатора не считает классы System. Источник, описанный после
86
+ цели, сверяется при появлении; его класс получает метку `resolved.aggregatedBy`.
87
+ - `db.rebuildAggregates(класс)` — ремонт: поле класса и его потомков во всех арендаторах заново из живых источников
88
+ под исключительными блокировками, версия только у исправленных; возвращает их число. Только администраторское
89
+ подключение (иначе `admin_required`); функция владельца `rebuild_aggregates`. Зарезервированное имя корня
90
+ `rebuildAggregates`.
91
+ - `verify({ data: true })` сообщает расхождения агрегатов: `{ id, class, field, stored, expected, problem }`.
92
+ - Код ошибки базы `LT022` `aggregate_field`: приложение передало или изменило агрегатное поле.
93
+
94
+ ### Changed — этап 4б
95
+ - Агрегатное поле пишет только база: `trg_entity_bi` отклоняет переданное значение и ставит 0 до умолчаний и
96
+ проверки, `trg_entity_bu` отклоняет изменённое, а при замене данных целиком без поля возвращает прежнее значение;
97
+ `reclass()` и `rekey()` отклоняют его в данных. Библиотека отвечает `aggregate_field` до отправки (`create`,
98
+ `upsert`, `update`, `reclass`, `rekey`), а `upsert` заменяет данные без агрегатных полей
99
+ (`excluded.data - поля`).
100
+ - Добавить или изменить агрегат — ужесточение (`class_change_safe`): исключительные блокировки семейства цели и
101
+ семейств источников, пересчёт поля у всех целей (`class_evolve`), метки источников заново (`agg_mark`).
102
+ - Загрузчик ставит агрегатные поля в 0 (поле посчитает триггер), при `existing: 'update'` сохраняет прежние значения
103
+ (`agg_keep`) и пишет журнал пачки раньше строк: новая цель получает итог в ту же версию. Первая заливка считает
104
+ поле функцией `agg_fill` — одним оператором на агрегат до перестройки индексов, в ту же версию в `entity` и `log`.
105
+ - `restore()` и `sys_update` со сменой класса пишут в журнал строку таблицы, а не `returning`: агрегатные поля
106
+ досчитаны триггером; `sys_create` пишет журнал раньше строки. `reset({ level: 'tenant' })` агрегаты не
107
+ пересчитывает: строки арендатора уходят вместе с целями.
108
+ - Ревью коммита этапа 4б: агрегат считают функции владельца по всем строкам источника, без прав чтения, и описание с
109
+ `where` выдавало суммы и счётчики по строкам, которые автору не видны. Теперь строки попадают в агрегат только с
110
+ ведома того, кто читает их все. Описать или изменить агрегат может только тот, у кого право READ без условий на все
111
+ строки каждого класса-источника (класс `of` и его потомки) — и свой агрегат, и унаследованный (новый потомок цели,
112
+ смена `parent`); неизменённый агрегат при правке описания не проверяется. Сделать класс источником (новый класс, смена `parent`, источник после цели) можно только с таким правом
113
+ на этот класс и его потомков — до правки, по прежним предкам, и после. Иначе `acl_denied` (`class_admit`, а для
114
+ нового класса и новых предков — `agg_source_admit` в триггерах после оператора и в `class_evolve`). Агрегат класса
115
+ арендатора считает только классы своего арендатора — везде одинаково (`agg_prepare`, `agg_classes`, `agg_compute`,
116
+ `agg_apply`, `agg_fill`, `agg_cycle`): класс System с именем `of` его источником не бывает. Поэтому такой агрегат
117
+ больше не останавливает установку и правку классов System (`agg_source` его пропускает, а не отвечает
118
+ `invalid_class`), не расходится с `verify` и `rebuildAggregates` и не мешает арендатору править цель.
119
+
120
+ ### Tests — этап 4б
121
+ - `aggregate` (план 1.1, §7.5 №12–16, №18–20 и гонка №18 §7.8): описание агрегата в реестре и его проверки;
122
+ 20 соединений пишут операции одного кошелька; `insert`, `update` и `delete` сырым SQL; каскад, `reclass` (с
123
+ переносом id и без), `restore` операции, загрузчик, удаление аккаунта; `restore` кошелька после удаления части
124
+ операций, `rekey` и `reclass` кошелька — одна версия без поправок; агрегат добавлен под параллельной записью;
125
+ смена описания и параллельные операции; `aggregate_field`; порча владельцем, `verify` и `rebuildAggregates`;
126
+ строка System.
127
+ - `initial-load` 7.4 №10: первая заливка в схему с агрегатом — итог равен сумме загруженных операций, одна версия,
128
+ журнал совпадает со строкой, `verify` чист.
129
+ - `roles` №4 и №7: триггерные функции агрегатов приложению не выданы; триггеры `entity_total_*` — `enable always`
130
+ без условия `WHEN`. `engine-attacks` №19 включает их обратно вместе с остальными. `scenario-salon`: матрица
131
+ покрытия исключает `rebuildAggregates` — его проверяет набор `aggregate`.
132
+
133
+ ### Added — этап 5 (MCP)
134
+ - Подпуть `letopis/mcp`: `createMcpServer(db, { write, maxRows, maxBytes })` (`lib/src/mcp.ts`) — сервер MCP для
135
+ одного пользователя letopis. Инструменты: `letopis_model` (модель: классы, поля, `anyOf`/`oneOf`, концы, ключи,
136
+ алиасы, метаданные; с `class` — один класс), `letopis_query` (чтение JSON-цепочкой: старт `class` и `filter`, `id`
137
+ или `ids`, `steps` по ролям и классам, `asOf`, `withDeleted`, `sort`, `limit`, `cursor`; терминалы `rows`, `first`,
138
+ `ids`, `count`, `sum`, `avg`, `min`, `max`, `countBy`), `letopis_history` (`versions`, `diff`, `blame`) и при
139
+ `write: true` — `letopis_write` (`create`, `update`, `upsert`, `delete`). Цепочка собирается вызовами API, без
140
+ `eval` и без SQL; операторы фильтра JSON (`$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$like`, `$ilike`,
141
+ `$starts`, `$ends`, `$has`, `$hasAny`, `$hasAll`, `$exists`, `$isNull`, `$not`, `$or`) — фабрики `ops.ts`.
142
+ - Ответ — `structuredContent` и его текстовый дубль: не больше `maxRows` строк (100) и `maxBytes` байт (50 000),
143
+ дальше — свой курсор `cursor` (сортировка и значение последней строки; без `sort` — по `id`). Всё, что пришло из
144
+ базы, в тексте — внутри `<untrusted-data-…>` со случайным суффиксом и пояснением; описания инструментов и
145
+ `instructions` — постоянный текст пакета, `meta` классов — только в результатах. Неизвестный класс, роль, параметр,
146
+ оператор — `isError` с подсказкой похожих имён (расстояние Левенштейна по классам, алиасам и ролям реестра).
147
+ - Запись через MCP: `update` — только с `rev`; `delete` без `confirm` — превью и код операции, с `confirm` —
148
+ подтверждение человека через elicitation (ревизия 2025 — запрос клиенту, 2026-07-28 — `input_required` с
149
+ подписанным `requestState`), привязанное к хэшу операции; превью и удаление — одной транзакцией. Причина каждой
150
+ записи — `mcp: letopis_write`.
151
+ - Команда `letopis mcp` (stdio): `--dsn`, `--schema`, `--token` или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`;
152
+ `--write`; только токен сессии пользователя. Сервер — `serveStdio` SDK: ревизию протокола выбирает клиент.
153
+ - Необязательная peer-зависимость `@modelcontextprotocol/server` ^2.4.0 (dev — вместе с `@modelcontextprotocol/client`
154
+ для тестов): основная точка входа и CLI её не загружают, `letopis/mcp` грузит её динамическим `import()`; нет
155
+ пакета — ошибка с командой установки.
156
+ - Коды библиотеки: `user_session_required` — `createMcpServer` без сессии пользователя или от корня сервиса без
157
+ `as()`, `letopis mcp` с ключом сервиса или без токена; `confirmation_required` — удаление через MCP без подтверждения
158
+ человека.
159
+
160
+ ### Changed — этап 5
161
+ - `createMcpServer` отклоняет подключение в обход RLS (`bypass_rls`): признак — внутренний модуль `lib/src/internal.ts`
162
+ (фасад → свойства подключения; `makeDb` регистрирует каждый фасад, в `exports` модуля нет), а не слова вызывающего;
163
+ корень сервиса без `as()` и подключение без сессии — по `ctx()` базы (`user_session_required`); фасад `db.begin()` —
164
+ `invalid_query`.
165
+ - CLI: строгий `parseArgs` принимает `--write`; `letopis mcp` подставляет `LETOPIS_*` до проверки `--dsn` и
166
+ `--schema`, отклоняет `--api-key` (код 2), загружает `mcp.js` и SDK лениво и держит процесс до закрытия stdin.
167
+ В stdout — только протокол.
168
+ - Отступление от плана: имена SDK 2.4.0 сверены по типам — `McpServer`, `fromJsonSchema`, `InMemoryTransport`,
169
+ `createMcpHandler`, `serveStdio` (`@modelcontextprotocol/server/stdio`), `inputRequired`/`inputResponse`. В ревизии
170
+ 2026-07-28 подтверждение идёт ответом `input_required` (запрос клиенту `elicitInput` там отключён), поэтому сервер
171
+ поддерживает оба пути. Обработчик HTTP в ревизии 2025 без состояния возможностей клиента не знает — удаление по нему
172
+ отклоняется (`confirmation_required`).
173
+
174
+ ### Tests — этап 5
175
+ - `mcp` (план 1.1, §7.6 №1–12): модель и `meta` только в результатах; права — пользователь арендатора A не видит
176
+ строк и классов B; JSON-цепочка — шаги по ролям и классам, операторы, неизвестный класс, роль, параметр и оператор,
177
+ зарезервированные имена — `isError` с подсказкой в обёртке; 1000 строк — 100 и курсор, обход всех страниц,
178
+ сортировка по полю данных, предел байт; строка с текстом-инструкцией и поддельной меткой — внутри обёртки, суффикс
179
+ меняется; без `write` инструмента записи нет; `update` без `rev` — отказ, чужая версия — `conflict`; удаление —
180
+ превью, подтверждение человека в ревизиях 2025 и 2026-07-28, отказ человека, код операции от агента, клиент без
181
+ elicitation; причина в `blame`, `versions`, `diff`; учётные данные — `letopis mcp --api-key` и с токеном сервиса,
182
+ подключение в обход RLS, корень сервиса, подключение без сессии, `db.begin()`, `svc.as(пользователь)`; схемы
183
+ инструментов; `index.ts` и `cli.ts` не тянут `mcp.ts` и SDK статически. Дополнительно: `letopis mcp` по stdio
184
+ (клиент SDK запускает команду, `LETOPIS_*`, `--write`) и обработчик HTTP `createMcpHandler` (ревизии 2025 и
185
+ 2026-07-28, удаление с подтверждением через новый экземпляр сервера на запрос).
186
+ - `engine-attacks` №35 (§7.7): агент через MCP пишет без `write` и удаляет без подтверждения человека — отказ, строка
187
+ цела.
188
+ - `docs`: сценарий `mcp` README (обе версии) — обработчик HTTP из примера, клиент SDK через `fetch` приложения;
189
+ `letopis/mcp` в примерах — исходники пакета.
190
+
191
+ ### Changed — этап 6
192
+ - Документация — по проверке понятности (§7.9): агент, видевший только README, решил все пять задач, но угадывал
193
+ форму ответов. README (обе версии): `diff` и `blame` — пример ответа, форма `from`, `to`, `changes` и `fields`
194
+ (`path` — массив сегментов, листья по порядку пути, пустые `agent`, `reason`, `before`, `after` опускаются), типы
195
+ `DiffResult`, `DiffChange`, `BlameResult`, `BlameField` — и в «Экспортах пакета»; сигналы — канал с полным именем
196
+ схемы (`LISTEN "v2.shop"`), нагрузка по режимам, служебные `session:<16 hex>`, `session:*` и `*`; проекторы — когда
197
+ `before` равен `null` и когда его нет вовсе, значения `status().state`; MCP — `structuredContent` по терминалам
198
+ `letopis_query`, отказ (`isError`, `structuredContent.error`), `sort: { field, dir }`, вызов `letopis_write` без
199
+ `write` — ошибка протокола SDK, проверка обработчика HTTP клиентом SDK без сети (как в тесте `docs`). Шпаргалка
200
+ (обе версии) — то же коротко.
201
+ - Сверка документации с кодом 1.2 (план 1.1, §5, этап 6, п. 1 и 5). README (обе версии): во введении — производные
202
+ данные, MCP и `diff`/`blame`, в требованиях — необязательная peer-зависимость `@modelcontextprotocol/server`; `secret`
203
+ хранит и чекпоинты проекторов; администраторское подключение нужно и проекторам, и `rebuildAggregates`; ревизия
204
+ движка — 6, а не 5. Новые рецепты: «Баланс агрегатом» (§8) — операция без `inc`, корректировка операцией на разницу
205
+ под `forUpdate()`; «Дневная выручка» (§12) — проектор со свёрткой по дню из данных продажи и таблица «агрегат или
206
+ проектор». `describe()` отдаёт `derivedBy` и `aggregate`. MCP: ответы `letopis_model` (краткая модель с
207
+ `brief: true`, если полная больше `maxBytes`), `letopis_history` и `letopis_write`, концы записи `links`, предел
208
+ `maxBytes` — по JSON строк (строка больше предела приходит на странице одна), отказ фасаду `db.begin()`
209
+ (`invalid_query`), код выхода 2 у `letopis mcp` без токена и с `--api-key`. «Справочник API»:
210
+ типы проекторов, `AggregateInfo` и `AggregateDef`, опции `createMcpServer` с умолчаниями и пределами, флаги и
211
+ переменные `letopis mcp`, число исправленных строк у `rebuildAggregates`, подраздел «Проектор» (опции, методы,
212
+ `ProjectorReport`, `ProjectorStatus`, `ProjectorFailure`, `ProjectorChange`, `ProjectorOut`). «Ошибки»:
213
+ `admin_required` — и у `rebuildAggregates`, `user_session_required` — и без токена, `derived_class` — и у проектора
214
+ без участников. «Ограничения», «Проекторы»: только администраторское подключение и журнал всех арендаторов в обход
215
+ RLS, участник без опции `tenants` — любой арендатор с пометкой `derivedBy`, производные строки пишет только проектор,
216
+ ревизия движка. Сверка нашла и расхождение кода с README: источник с `history: false` проектор принимал молча —
217
+ исправлено (раздел «Security», Н1, продолжение). Шпаргалка (обе версии): `derivedBy` и `aggregate` в полной форме
218
+ `hub()`, свёртка дневного итога, право на описание агрегата, `LETOPIS_*`, `maxRows` и `maxBytes`, администраторское
219
+ подключение у проектора и `rebuildAggregates` (грабли №8).
220
+ - Замеры этапа 6 (план 1.1, §5, этап 6, п. 2): новый `lib/bench/derived.bench.mjs` — цена агрегата при записи (один
221
+ писатель, 20 писателей одного и разных кошельков, сырой SQL), догонка проектора на 100 тыс. изменений, перестройка
222
+ атомарная и пачками, задержка подписки во время перестройки, цена триггеров агрегатов у классов без агрегатов;
223
+ повтор `lib/bench/vs-101.bench.mjs` (четыре прогона — `lib/bench/results/vs-101-1.2.md`). Итог и выводы —
224
+ `docs/checkpoint-1.2.md`. По замерам README (обе версии): §17 — числа 1.2; горячий счётчик — около 150–190 операций
225
+ в секунду на объект, а не «тысячи записей» (§5, «Рецепт: баланс агрегатом», «Ограничения»); §12 — скорость
226
+ проектора задаёт число правок строк, а не изменений (свёртку `inc` в `out` добавила точка Б); в таблице «агрегат или
227
+ проектор» проектор применяет изменения пачками; «Ограничения» — цена атомарной перестройки
228
+ и очередь фиксаций с `NOTIFY` у писателей сырым SQL; §2 «Сигналы» — `db.sql` и `db.transaction()` шлют сигналы
229
+ отложенно, как другие транзакции библиотеки. Шпаргалка (обе версии) — горячий счётчик.
230
+
231
+ ### Security — ревью безопасности №2
232
+ - Н1 (проекторы, отказ в обслуживании между арендаторами). Участником проектора становится любой арендатор, который
233
+ пометит свои классы его `derivedBy`, а пачка всех арендаторов была одной транзакцией и одним `derived_apply`: ошибка
234
+ данных одного (например, `DayTotal` с `maximum: 0`) откатывала пачку, курсор стоял, `rebuild()` падал, перестройка
235
+ пачками успевала стереть производные строки всех и оставалась в `rebuilding`, а `status()` молчал. Теперь
236
+ (`lib/src/project.ts`: `apply`, `atomic`, `chunked`) пачка делится по арендаторам: `handle` получает изменения одного
237
+ арендатора, запись его результатов идёт под точкой сохранения. Ошибка данных арендатора — отказ базы при записи его
238
+ результатов (коды letopis, неверные значения, пределы размера) или отказ `out` по его строке или приращению —
239
+ откатывает только его группу: арендатор выбывает — `meta.failed[арендатор] = { cursor, code, message }` пишется в
240
+ чекпоинт в той же транзакции — и пропускается до `rebuild({ tenant: id })`, а остальные записываются. `status()`
241
+ отдаёт `failed`. Производные строки выбывшего больше не меняются: выбывший в `run()` сохраняет их на момент до
242
+ пачки с ошибкой, а выбывший посреди перестройки остаётся без них (частично построенные удаляются). Новая опция
243
+ `rebuild({ tenant })` перестраивает одного арендатора одной транзакцией: удаляет его производные строки, строит их из
244
+ его текущих строк источников и снимает отметку; снимок перестройки встаёт в чекпоинт (`meta.resync`), и хвост
245
+ журнала не применяет учтённые изменения второй раз. Полная перестройка начинает всех участников заново. Текст ошибки
246
+ `derived_apply` называет проектор, арендатора и id строки, а `fromDbError` сохраняет у `ValidationError` текст базы
247
+ целиком. Опция `tenants` в `ProjectorOptions` — явный список участников. Исключение `handle`, нарушение границ `out`
248
+ и сбой окружения (взаимная блокировка, отмена запроса, обрыв связи) по-прежнему откатывают пачку целиком: это не
249
+ данные арендатора, и выводить его из проекции не за что. Тип `ProjectorFailure`.
250
+ - Н1, продолжение: ярус журнала и журнал источников — по арендатору. Тем же путём арендатор останавливал проектор
251
+ описанием своих классов: предел `cursor_expired` был общим — самый короткий ярус `all` политики хранения источников
252
+ всех участников, и ярус в одну секунду у классов одного арендатора ронял `run()` для всех (а при
253
+ `onExpired: 'rebuild'` зацикливал полные перестройки). Теперь ярус — по арендатору (`resolve`, `step`): курсор старше
254
+ яруса у всех участников — `cursor_expired`, как прежде; у части — выбывают они с кодом `cursor_expired`, а с
255
+ `onExpired: 'rebuild'` перестраиваются сами, каждый своей транзакцией (`rebuild({ tenant })`). Источник без журнала
256
+ (`history: false`) проектор принимал молча: изменений такого класса он не видит, и проекция арендатора расходилась с
257
+ данными. Теперь такой арендатор выбывает с кодом `invalid_class` (и при перестройке, атомарной и пачками), его
258
+ производных строк нет, а `rebuild({ tenant })` отказывает, пока журнал класса не включат.
259
+ - Н2 (известна с 1.0). Право WRITE на `Class` позволяло сменить у класса `parent` или `alias`, и его строки попадали
260
+ под правила READ, WRITE и DELETE нового предка или алиаса: план прав берёт для класса имена предков и их алиасы.
261
+ Теперь `class_admit` при правке, которая меняет `resolved.ancestors`, `alias` или `resolved.derivedBy`, зовёт функцию
262
+ владельца `class_acl_admit` (`lib/sql/60-write.sql`, по образцу `agg_source_admit`): для самого класса и каждого его
263
+ потомка своего арендатора `acl_check_data` по READ, WRITE и DELETE должна разрешать без условий, иначе `acl_denied`;
264
+ потомки класса System у арендаторов пропускаются, как у агрегатов. Проверка идёт после проверок агрегатов. Алиас не
265
+ совпадает с именем другого класса (в том числе `Class` и системных) и с чужим алиасом арендатора и System —
266
+ `invalid_class`, и у нового класса тоже. И наоборот: имя класса не совпадает с чужим алиасом арендатора и System
267
+ (автоматическое ревью коммита `6c66d02`): иначе правило, написанное для нового класса, действовало бы и на строки
268
+ класса с этим алиасом. `class_acl_admit` — не `security definer`: приложению она не выдана, словарь
269
+ негативных тестов `roles` №4 не изменился.
270
+ - О1. Чекпоинт проектора искался по виду `PROJECTOR` и имени, без аккаунта. Теперь `projector_state`,
271
+ `projector_advance` и `status()` берут только строку System. Накат помечает удалёнными строки `PROJECTOR` других
272
+ аккаунтов (идемпотентно), а `projector_state`, не найдя своей строки, так же помечает чужую с тем же именем: иначе
273
+ уникальный индекс `secret_kind_ident` не дал бы записать свою.
274
+ - О2. `derived_clear` удалял строки всех классов с пометкой проектора во всех арендаторах. Теперь
275
+ `derived_clear(имя, арендаторы[], классы[])` удаляет только пары «арендатор, класс», которые передаёт проектор
276
+ (участники и их семейства `into`), и только у классов с его пометкой; прежняя `derived_clear(text)` удаляется при
277
+ накате.
278
+ - О7 (MCP). Окно подтверждения удаления показывало алиас класса так, как его прислал агент, — текст арендатора. Теперь
279
+ в окне каноническое имя класса (`registry.resolve(cls).name`) и id целей (первые 10, дальше «и ещё K»); алиас и
280
+ `meta` в текст не попадают.
281
+ - О8 (MCP). `countBy` и `versions` читали всё и обрезали в памяти. Теперь предел — в самом запросе: `countBy` читает
282
+ самые частые `maxRows` + 1 групп, `versions` — последние `maxRows` + 1 версий. Для этого в цепочке `limit()` и
283
+ `offset()` перед `countBy()` относятся к группам (по убыванию числа), а `versions()` учитывает `sort()`
284
+ (`sort('at', 'desc').limit(n).versions()` — n последних версий). Ответ `countBy` — без `groups`, ответ `versions` —
285
+ без `total`. Шагов `steps` — не больше 16, условий `$or` в одном фильтре — не больше 64, иначе `invalid_query`.
286
+ README советует `statement_timeout` для роли базы сервера MCP: время запроса пределы не ограничивают.
287
+ - Смысл `before` у проектора. У первой версии нового id после `reclass` и `rekey` `before` была последняя версия
288
+ прежнего id, а у надгробия прежнего id — тоже она: свёртка вычитала её дважды (пример README §12 после `rekey` давал
289
+ 0 вместо 10). Теперь `before` — всегда прошлая версия того же id (`fillBefore`): у первой версии нового id — `null`,
290
+ прежние данные вычитает надгробие прежнего id.
291
+ - Документация: README (обе версии) — §12 «Проекторы»: `failed` в `status()`, `rebuild({ tenant })`, опция `tenants`,
292
+ смысл `before`, обработка ошибки `run()` в примере (`catch`, а не `void`); §10 «Права» — смена `parent`, `alias` и
293
+ `derivedBy`; «Агенты и MCP» — форма ответа `countBy`, пределы шагов и `$or`, окно подтверждения; «Агрегаты» и
294
+ «Версии» — `limit()` у `countBy()` и `sort()` у `versions()`; §15 «Цепочка» — терминалы `diff()` и `blame()`.
295
+ Шпаргалка (обе версии) — то же коротко.
296
+
297
+ ### Tests — ревью безопасности №2
298
+ - `projector`: арендатор x с `DayTotal` (`derivedBy: 'daily'`, у `total` — `maximum: 0`) и строки `Sale` у x и t —
299
+ `run({ once: true })` проходит, итог t записан, `status().failed` содержит x с `invalid_data` и текстом с арендатором
300
+ и id строки (и арендатора y, чью сумму-строку отклонил `out.inc`); `rebuild()` и `rebuild({ chunked: true })` дают
301
+ верные итоги остальных; пока описание не исправлено, `rebuild({ tenant })` отклоняется; после снятия ограничения
302
+ `rebuild({ tenant: x.id })` восстанавливает x, а хвост журнала применяет его изменения ровно один раз; опция `tenants`
303
+ отсекает x — его изменения и строки, не участник — `tenant_denied`. Арендатор z, выбывший посреди перестройки
304
+ (`batch: 1`: первая строка источника проходит, вторая — нет), после `rebuild()` и `rebuild({ chunked: true })`
305
+ остаётся без производных строк, а не с их частью. Ярус `all` источника одного арендатора (1 день) при курсоре
306
+ старше трёх дней — выбывает только он (`cursor_expired`), изменения остальных применены; `rebuild({ tenant })`
307
+ возвращает его, `onExpired: 'rebuild'` перестраивает его сам; ярус истёк у всех — `cursor_expired`, как прежде.
308
+ Источник с `history: false` — арендатор выбывает с `invalid_class` (и при перестройке, атомарной и пачками),
309
+ `rebuild({ tenant })` отказывает до включения журнала, после — возвращает его. Подложенная строка `PROJECTOR`
310
+ другого аккаунта не мешает проектору, накат её удаляет. Перестройка не трогает класс с пометкой вне семейства
311
+ `into`. `rekey` строки источника — итог свёртки верный, `before` первой версии нового id — `null`.
312
+ - `acl`: участник с одним правом WRITE на `Class` меняет у класса `alias`, затем `parent` — оба раза `acl_denied`,
313
+ `rev` строки `Class` не меняется; условного права READ и READ без WRITE и DELETE мало, запрет DELETE на потомка —
314
+ отказ; с правами без условий — проходит; алиас, совпадающий с именем класса, системным именем или чужим алиасом, —
315
+ `invalid_class`, у нового класса тоже. Подкласс класса System, чьи строки закрывает запрет System весом 1000, не
316
+ снять с наследования (`parent` убран, поля скопированы) — `acl_denied`, случай (г) ревью. Новый класс с именем,
317
+ совпадающим с алиасом класса арендатора или System, — `invalid_class`.
318
+ - `engine-attacks` №36: то же сырым SQL — `alias`, `parent` и `derivedBy` без прав на строки, алиас — имя класса.
319
+ - `mcp`: окно подтверждения удаления по алиасу — имя `Task`, первые 10 id и «и ещё 2», без алиаса и `meta`;
320
+ `countBy` по 1000 группам и `versions` по 150 версиям читают из базы не больше 101 строки (`onQuery`); 17 шагов и
321
+ 65 условий `$or` — `invalid_query`, 16 и 64 — можно.
322
+ - `aggregate`: правка описания без изменения агрегата — `meta` вместо `alias` (смена алиаса теперь требует прав на
323
+ строки класса); перенос `Secret` под `Sale` — ещё и с правами WRITE и DELETE на его строки.
324
+
325
+ ### Changed — точка Б (решения владельца)
326
+ - Перестройка проектора в автоматических путях — пачками: первый `run()` новой проекции и `onExpired: 'rebuild'`
327
+ перестраивают пачками, и горизонт подписок кластера стоит не дольше пачки; `rebuild()` без опций остаётся
328
+ атомарным (`lib/src/project.ts`: `run`, `rebuildNow`, `chunked`). Второй процесс того же проектора во время такой
329
+ перестройки не падает с `projector_busy`: занятую блокировку он пережидает, а начатую другим процессом перестройку
330
+ продолжает, а не начинает заново. Перестройка одного арендатора по `onExpired: 'rebuild'` по-прежнему идёт одной
331
+ транзакцией.
332
+ - Свёртка `out.inc` (предложение по замерам этапа 6): приращения строки, заданной одними полями ключа или одним `$id`,
333
+ `out` складывает до отправки в базу, пока между ними нет `upsert`, `delete` или `get`, — одна правка строки за пачку
334
+ (`Out`, `Projector.foldKey`; поля ключа классов `into` проектор берёт из их описаний). Правило класса проверяется над
335
+ итогом приращений строки в пачке, а не над каждым промежуточным значением; строку с другими полями `inc` не
336
+ складывает. Повтор серий 2 и 3 бенча (`BENCH_SERIES=2,3` — новый выбор серий, итог —
337
+ `lib/bench/results/derived-23.md`): handle с `out.inc` на каждое изменение догоняет 6 076 и 33 395 изменений в
338
+ секунду (`batch` 500 и 2000) вместо 992 и 1 203, атомарная перестройка 75 тыс. строк — 13,3–13,5 с вместо 101 с,
339
+ перестройка пачками — 13,0–13,2 с вместо 58–63 с.
340
+ - О5 ревью безопасности №2 — отказывать по умолчанию: `letopis_write` не пишет, не правит и не удаляет строки классов
341
+ прав — `rule`, `Resource`, `member`, `Account`, `Class` и их потомков (`acl_denied`), а `createMcpServer` отклоняет
342
+ сессию в арендаторе System (`user_session_required`): так работает системный администратор, и его роль `admin`
343
+ действует только там. Разрешают это только новые опции `McpOptions` — `allowAclWrite` и `allowSystemAdmin`; у
344
+ `letopis mcp` таких флагов нет. Об отказе говорят описание `letopis_write` и `instructions` сервера.
345
+ - Документация: README и шпаргалка (обе версии) — §12 (перестройка `run()` пачками, свёртка `inc`, рецепт), «Агенты и
346
+ MCP», «Справочник API», «Ошибки», «Ограничения», §17; `docs/checkpoint-1.2.md` — решения владельца и повтор замера;
347
+ `reports/PLAN-LETOPIS-1.1.md` — решения точки Б в шапке.
348
+
349
+ ### Tests — точка Б
350
+ - `projector`: три приращения строки в пачке — одна правка (`created` 1, `updated` 0); −5 и +5 по новой строке с
351
+ `minimum: 0` — итог 0, а не выбывание; `delete` и `get` свёртку сбрасывают; строка с полем вне ключа не складывается,
352
+ и порядок правок сохраняется. Первый `run()` новой проекции снаружи виден как `rebuilding`; два процесса одного
353
+ проектора на новой проекции (`batch: 1`) — ни один `run()` не падает, перестройка одна, итог верный.
354
+ - `mcp`: `create` классов `rule`, `Resource`, `member`, `Account`, `Class`, правка и удаление правила — `acl_denied`, об
355
+ этом говорит описание `letopis_write`; с `allowAclWrite` — превью удаления правила; сессия системного
356
+ администратора — `user_session_required`, с `allowSystemAdmin` — сервер работает; `letopis mcp` с её токеном — код 1;
357
+ неверный тип опций — `invalid_data`.
358
+ - `engine-attacks` №37: агент через MCP выдаёт постороннему членство с ролью `admin`, правило, ресурс и класс —
359
+ `acl_denied`, строк классов прав не прибавилось; сессия системного администратора — отказ.
360
+ - Без исправлений все новые тесты падают: каждое исправление отключалось по отдельности.
361
+
362
+ ### Security — доработки после точки Б
363
+ - Производный класс — только у администратора (`/security-review` после точки Б). Пользователь арендатора с правом
364
+ WRITE на `Class` (по умолчанию — любой) заводил класс с `derivedBy` проектора, проектор без `tenants` брал его
365
+ арендатора в участники и писал туда итоги по всем строкам источников, в том числе по тем, которые пользователю читать
366
+ запрещено. Теперь `class_admit` (`lib/sql/60-write.sql`) пропускает вставку и правку строки `Class` производного
367
+ класса — свой `derivedBy` или унаследованный, до правки или после — и `trg_entity_ad` её удаление только с правом
368
+ `letopis.projector` (`letopis_allowed`): оно есть у администраторской роли подключения и по правилам System —
369
+ у системного администратора по маске `letopis.*`; иначе `acl_denied`. Правила сида не меняются. Производные классы
370
+ синхронизирует администраторское подключение с сессией арендатора; `letopis sync` принимает такое подключение
371
+ (`allowBypassRls`, как `load`, `verify` и `maintain`). README и шпаргалка: кто заводит производные классы, рецепт
372
+ дневной выручки, «Ограничения» — итоги производного класса видит каждый, кому правила дают на него READ, а с
373
+ `history: true` его журнал показывает изменение каждой пачки.
374
+ - `rekeyClass` предка не переносит строки производного потомка (укрепление из ревью №2). Производный потомок
375
+ непроизводного предка наследует его ключ, а `rekey_class` (`lib/sql/90-time.sql`) проверял `derivedBy` только у
376
+ самого класса и переносил строки потомка на новые id мимо проектора. Теперь класс с производным потомком —
377
+ `derived_class`.
378
+ - MCP: `structuredContent` с пояснением (укрепление из ревью №2). Данные базы в текстовом дубле стоят внутри
379
+ `<untrusted-data-…>`, а `structuredContent` повторял их без пояснения — клиент, который отдаёт модели его, обходил
380
+ смягчение. Теперь `structuredContent` — `{ notice, data }` у ответа и `{ notice, error }` у отказа (`ok` и
381
+ `failure` в `lib/src/mcp.ts`): данные базы — только в `data` и `error`, `notice` — постоянный текст пакета. Прежние
382
+ формы ответов — внутри `data`. README и шпаргалка (обе версии): «Агенты и MCP», «Ограничения».
383
+ - Удаление и пересоздание класса (остаток Н2 ревью №2). Пустой класс можно было удалить и создать заново с другим
384
+ `parent` или `alias`: история его удалённых строк осталась в журнале, а правила прав находят строки по именам
385
+ класса, предков и алиасов — старые версии попадали под правила новых имён. Теперь `trg_entity_ad` не удаляет класс,
386
+ пока в журнале есть версии его строк (кроме записей `purge`, `trim`, `reset`; у класса System — у любого арендатора),
387
+ — `invalid_class`, историю удалённых строк сначала стирает `purge()`; а `trg_entity_bi` создаёт заново класс,
388
+ удалённый с историей раньше (надгробие строки `Class` хранит прежнее описание), только с прежними
389
+ `resolved.ancestors`, `alias` и `resolved.derivedBy` — иначе `invalid_class`. Если у истории строк нет надгробия
390
+ своего класса (строки класса System, удалённого в 1.1, у арендатора), класс с этим именем не создаётся вовсе, пока
391
+ историю не сотрёт `purge()` (автоматическое ревью коммита). Функция владельца `class_has_history`.
392
+ - Ужесточение класса — не оракул по невидимым строкам (ревью №2, вне его области: было до 1.2). Пробное ужесточение
393
+ (`maximum`, `enum`, `pattern` и т. п.) отвечало `tightening_conflict` с числом невидимых актору строк, которые ему не
394
+ соответствуют (`others`), и двоичным поиском узнавались значения чужих строк. Теперь ужесточение (`class_evolve`) и
395
+ смена ключа (`rekey_class`) до проверки строк требуют права READ без условий на все строки класса и его потомков
396
+ своего арендатора (`class_rows_admit`, функция владельца; через неё теперь и `class_acl_admit`), иначе `acl_denied`
397
+ без деталей. Описание агрегата — тоже ужесточение цели. Число строк других арендаторов при ужесточении класса System
398
+ системным администратором остаётся: это уровень установки. В тексте `problem` id ссылающейся строки — только видимой.
399
+ - Кэш реестра `db.as()` (ревью №2, вне его области: было до 1.2). Реестр арендатора, общий для всех `db.as()`,
400
+ перечитывался под контекстом первого вызвавшего: если тот выбыл из арендатора или его сессия кончилась, ошибка
401
+ перечитывания опустошала реестр для всех пользователей арендатора (`load([])`), два одновременных `as()` нового
402
+ арендатора создавали два реестра (первый больше не обновлялся), а каждый сигнал `Class` перечитывал реестры всех
403
+ арендаторов процесса. Теперь (`lib/src/index.ts`: `registryFor`, `refresh`, `reload`) перечитывание идёт под
404
+ контекстом последнего успешного `as()` арендатора, ошибка или контекст не в этом арендаторе оставляют прежний
405
+ реестр, одновременные `as()` получают один реестр, а сигнал `Class` с токеном арендатора (режимы `tenant` и
406
+ `opaque`) перечитывает только его реестр.
407
+ - Политика хранения истории (ревью №2, вне его области: было до 1.2). Одно право WRITE на `Class` задавало
408
+ `history: { all: '1 day' }` или `false`, и ближайший `maintain()` стирал аудит строк, на которые у автора правки
409
+ прав нет (`trimHistory` требует DELETE и READ на каждую цель). Теперь `class_admit` пропускает смену развёрнутой
410
+ `history` существующего класса только с правом READ и DELETE без условий на все строки класса и его потомков своего
411
+ арендатора (`class_rows_admit`), иначе `acl_denied`.
412
+ - `reclass` (ревью №2, вне его области: было до 1.2). Смена класса проверяла WRITE на прежнюю и новую строку, но не
413
+ DELETE: с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там. Перевод ссылок (`move_row`)
414
+ переписывал строки глубже первого уровня (ссылающиеся на строку, чей id сменился вместе с ключом) без проверки
415
+ прав, а текст отказа называл класс невидимой ссылающейся строки. Теперь `reclass` и сырая смена класса
416
+ (`trg_entity_bu`) требуют права DELETE на прежнюю строку; `move_row` получает план WRITE актора и проверяет каждую
417
+ переписываемую строку на всех уровнях (`reclass`, `rekey`, `rekey_class`; пути владельца — без плана); класс
418
+ неправимой ссылающейся строки называется, только если актор её видит. Число невидимых мешающих строк в
419
+ `reclass_denied` остаётся, как у `restrict` (решение плана 1.0).
420
+ - Цена чтения не растёт с данными других арендаторов (ревью №2, вне его области: было до 1.2). `find_ids`
421
+ (`lib/sql/70-read.sql`) не называла арендатора: строки и версии классов с тем же именем у всех арендаторов
422
+ перебирались, и только `row_visible` отсекала чужие, а срез `asOf` и `withDeleted` по классу шёл по индексу
423
+ `log_class_at (class, at)` без арендатора. Теперь ветви по классу, вложению и концам берут строки арендатора сессии и
424
+ System явным условием, а свои членства в других арендаторах — отдельным запросом по концу `User`: это ровно то, что
425
+ пропускает `row_visible`. Индекс журнала — `log_class_tenant_at (class, tenant, at)` вместо `log_class_at`; первый
426
+ `up()` после обновления строит его, запись в журнал на это время ждёт (README, «Обновление пакета и схемы»: как
427
+ построить его заранее `create index concurrently`). Ревизия движка остаётся 6.
428
+ - MCP: подпись подтверждения удаления привязана к пользователю и истекает (укрепление из ревью №2). В ревизии
429
+ 2026-07-28 `requestState` был HMAC одного хэша операции ключом процесса: подпись, выданная серверу одного
430
+ пользователя, проходила у сервера другого пользователя того же арендатора и процесса (у той же операции тот же хэш)
431
+ и не истекала. Теперь `requestState` — срок и HMAC аккаунта и арендатора сессии сервера, срока и хэша операции
432
+ (`stateOf`, `stateError` в `lib/src/mcp.ts`); срок — 10 минут, сравнение — `timingSafeEqual`. Подделанная подпись,
433
+ подпись другого пользователя и устаревшая — `confirmation_required`.
434
+
435
+ ### Changed — доработки после точки Б
436
+ - Проекторы: перестройка одного арендатора пачками и повтор автоматической перестройки (`lib/src/project.ts`).
437
+ `rebuild({ tenant, chunked: true })` перестраивает арендатора пачками — каждая своей транзакцией со своим снимком,
438
+ пока хвост журнала идёт для всех: изменения арендатора выше последней пачки хвост пропускает (их прочтёт пачка), в
439
+ пройденной пачке — если транзакция видна в её снимке; карта пачек — `meta.retenant` чекпоинта. `onExpired: 'rebuild'`
440
+ перестраивает выбывшего по ярусу пачками, а не одной транзакцией; состояние лежит в чекпоинте, и перестройку
441
+ продолжает `run()` любого процесса. Прежде неудавшаяся перестройка (занятая блокировка, ошибка) молча оставляла
442
+ арендатора выбывшим навсегда, а выбывшего в другом процессе не перестраивал никто. Теперь занятая блокировка — повтор
443
+ на следующем проходе, ошибка данных — выбывание с её кодом без повторов. Ярус перестроенного арендатора
444
+ отсчитывается от начала его перестройки (`meta.since`), пока курсор её не догнал: прежде отстающий проектор выводил
445
+ его по `cursor_expired` сразу после перестройки. `status().rebuildingTenants` — кого перестраивают пачками.
446
+ Арендатор, который к автоматической перестройке перестал подходить (не участник, источник без журнала), выбывает
447
+ с причиной и не останавливает `run()` для всех (автоматическое ревью коммита).
448
+
449
+ - Подписка (хвосты выпуска 1.1.0, `lib/src/watch.ts`). `sub.ready()` — обещание начальной точки: у `from: 'now'` —
450
+ горизонт и снимок момента подписки (`watch_head()`), у `'start'` и курсора — сразу; запись после него в поток
451
+ придёт. `next()` ставит вызовы в очередь: параллельные вызовы получают разные события — прежде два вызова читали
452
+ журнал с одной позиции и отдавали событие дважды, а второй затирал ожидание первого. Тестовый помощник
453
+ `watchStarted` (читал приватное поле) снят: тесты ждут `sub.ready()`.
454
+
455
+ - Первая заливка (хвост выпуска 1.1.0): `esc` без замен для значений без обратной косой, табуляции и переводов строки,
456
+ голова строк `entity` и `log` собирается один раз, `uuidv5` — `crypto.hash` и кэш байтов пространства имён (байты
457
+ `COPY` прежние). Бенч `initial-load.mjs` — выбор прогонов `BENCH_RUNS`. Замер на полном полигоне: первая заливка
458
+ 78,5 с, обычный загрузчик 233,3 с — 2,97×; предел первой заливки — запись `COPY` на сервере (генерируемые колонки
459
+ `ends` и `hash`, WAL), поэтому подготовку в `worker_threads` по решению владельца не делали
460
+ (`docs/checkpoint-1.2.md`, «Доработки после точки Б: первая заливка»).
461
+
462
+ - `check-docs`: ссылки, якоря и управляющие символы сверяются во всех `docs/*.md` (прежде из отчётов — только
463
+ `docs/checkpoint-a.md`); новая проверка `paths` — пути в обратных кавычках в `docs/checkpoint-*.md` (`lib/…`,
464
+ `docs/…`, `reports/…`, `.github/…`) существуют.
465
+
466
+ - Ревью безопасности №3, №1: пустой последний проход перестройки пачками (полной и одного арендатора) теперь тоже
467
+ пачка со своим снимком — до конца диапазона id. Прежде хвост применял изменения id выше последней прочитанной
468
+ строки, уже учтённые этим проходом: надгробие строки с наибольшим id, удалённой во время перестройки, вычиталось
469
+ лишний раз.
470
+
471
+ - Ревью безопасности №3, №6: арендатор, которого хвост выводит посреди его перестройки пачками (ошибка данных,
472
+ источник без журнала), остаётся без производных строк — частично построенные удаляются, как обещает README.
473
+
474
+ - Ревью безопасности №3, №3: перенос строки (`move_row`: `reclass`, `rekey`, `rekeyClass`) не переписывает ссылки
475
+ строк производного класса, которые на неё ссылаются, — `derived_class`: их пишет только проектор (прежде перевод
476
+ ссылок шёл мимо него, и проекция расходилась).
477
+
478
+ - `/security-review` после ревью безопасности №3: имя строки-замка лежит в `meta`, а не в `ident`. Уникальный индекс
479
+ `ident` таблицы `secret` сравнивает без регистра, поэтому строка-замок семейства, чей корень отличается от корня
480
+ другого семейства того же арендатора только регистром (`Kase` и `kase`), не вставлялась, и его блокировка молча не
481
+ бралась: ужесточение шло мимо незафиксированной записи. Так же отключала блокировку строка вида `LOCK`, заведённая в
482
+ 1.1 функциями кредов (тогда этот вид был им открыт), под именем блокировки: накат теперь помечает такие строки
483
+ удалёнными. Если строки-замка нет и после вставки, `family_lock` отвечает ошибкой, а не пишет без блокировки.
484
+
485
+ - Ревью безопасности №3, №5: `purge()` удалённого объекта отказывает новым кодом `projector_pending` (LT023), пока
486
+ курсор проектора, чьи источники `from` включают класс объекта, а у арендатора объекта есть его производный класс,
487
+ не прошёл надгробие (в том числе надгробие этой же транзакции). Прежде `purge` стирал непрочитанное надгробие, и
488
+ вклад объекта навсегда оставался в итогах. Чекпоинт проектора хранит `from` (`meta.from`; чекпоинт без него касается
489
+ всех классов). Брошенный проектор, чьи производные классы удалены, `purge` не держит.
490
+
491
+ - Ревью безопасности №3, №4: смена класса без смены id (классы без ключа: сырой `UPDATE … SET class`, `reclass()`)
492
+ журнал пишет версией уже нового класса, и подписка проектора по классам `from` ухода строки из семейства не видела —
493
+ её вклад оставался в итогах; вошедшая так строка приходила с `before` — версией чужого класса, и свёртка вычитала
494
+ её. Теперь уход приходит удалением (`op` `delete`, `row` — последняя версия в семействе с `$deleted`), а у
495
+ вошедшей `before` = `null`. `watch_read(…, p_leaving)` отдаёт такие версии проектору; подписке `db.watch()` — нет.
496
+
497
+ - Ревью безопасности №3, №2 (было до 1.2): внутренние блокировки — семейства классов (`family_lock`), проектора и
498
+ обслуживания — больше не advisory, а строки-замки: строки вида `LOCK` таблицы `secret`, которой у приложения нет
499
+ (`lock_row`, `lock_try`). Advisory-блокировку берёт любая роль базы, а её ключ виден всем в `pg_locks`: посторонняя
500
+ роль занимала блокировку с ключом из `hashtext` открытого имени (или подсмотренным) и останавливала запись
501
+ семейства, проектор или обслуживание для всех. Писатели берут строку-замок семейства `for key share`, ужесточение и
502
+ `rekeyClass` — `for update`. Строковые блокировки очереди не держат — поток записей не дал бы ужесточению случиться
503
+ никогда, — поэтому ужесточение сначала занимает шлюз семейства (вторую строку-замок), и новые писатели ждут у него.
504
+ Писатель проходит шлюз в подтранзакции один раз за транзакцию: справка о взятых блокировках
505
+ (`letopis.family_locks`) пропускает только шлюз, а строку-замок функция берёт на каждую строку — повторно взятая
506
+ своя блокировка не ждёт. Справку клиент выставляет сам, и в первой редакции, где по ней пропускалась и блокировка,
507
+ запись с подделанной справкой шла мимо ужесточения (автоматическое ревью коммита 1bc6584). Строки-замки заводятся
508
+ заранее — при записи строки `Class` и в `seed_system()` (установка, `up()` после обновления, `reset({ all })`).
509
+ Блокировку обслуживания берёт сама функция `maintain()`. Цена — строковая блокировка на строку записи и
510
+ подтранзакция на первую запись в семейство за транзакцию; замер (`bench/family-lock.bench.mjs`, медиана трёх
511
+ кругов) — в пределах шума против advisory 1.1: построчная запись 159 и 157 строк/с, 20 писателей 576
512
+ и 544 строк/с, 1000 строк одним оператором 303 и 300 мс (`bench/results/family-lock.md`). id строки-замка —
513
+ `lock_id`: первые 16 байт `sha256` имени (с `v5_id` вставка 1000 строк одним оператором стоила 352 мс против 321).
514
+
515
+ ### Tests — доработки после точки Б
516
+ - `engine-attacks` №38: сессия приложения заводит класс с `derivedBy`, потомка производного класса, правит
517
+ (`history`, снятие `derivedBy`) и удаляет производный класс — `acl_denied`, строк не прибавилось;
518
+ администраторское подключение с сессией арендатора (сырой SQL и `syncModels`) и системный администратор в System —
519
+ проходят. Производные классы тестов заводит `defineDerived` (`lib/test/_engine.ts`, администраторский пул с
520
+ токеном арендатора), правит — `asAdmin`; бенч `derived.bench.mjs` — так же.
521
+ - `engine-attacks` №39: `rekey_class` непроизводного предка с производным потомком — `derived_class`, строки потомка
522
+ и ключ предка прежние.
523
+ - `mcp` №5: у ответа `structuredContent` — ровно `notice` и `data`, у отказа — `notice` и `error`; текст-инструкция из
524
+ базы и из запроса — только внутри `data` и `error`. Остальные проверки `mcp`, `engine-attacks` №35, №37 и `docs`
525
+ читают ответ из `data`.
526
+ - `engine-attacks` №40: класс Master, закрытый пользователю, с историей удалённых строк — удаление класса
527
+ `invalid_class`; удалённый раньше (надгробие) — создать его потомком `Note` или с алиасом нельзя, с прежним
528
+ описанием можно, история пользователю по-прежнему не видна; история без надгробия своего класса — класс с этим
529
+ именем не создать. `engine-class`: класс с историей строк удаляется только
530
+ после `purge()`, класс с `history: false` — сразу.
531
+ - `engine-attacks` №41: условное право READ на `Org` — ужесточение и `rekey_class` отклонены `acl_denied` без строк и
532
+ их числа; READ без условий — `tightening_conflict` с перечнем и `others: 0`. `aggregate`: описанию агрегата нужно ещё
533
+ право READ на строки цели.
534
+ - `engine-attacks` №42: `reclass` и сырая смена класса без права DELETE на прежнюю строку — отказ; `reclass`, чей
535
+ перевод ссылок переписал бы невидимую неправимую строку третьего уровня, — `acl_denied`, имени её класса в тексте
536
+ нет, строка не переписана.
537
+ - `engine-attacks` №43: пользователь с WRITE на `Class` и без DELETE на строки `Note` задаёт `history: { all: '1 day' }`
538
+ и `false` — `acl_denied`, описание прежнее; с правом DELETE — проходит.
539
+ - `impersonation`: одновременные `as()` — один реестр; пользователь последнего `as()` выбыл из арендатора — реестр не
540
+ пустеет, после `as()` другого пользователя перечитывается; в режиме `tenant` сигнал `Class` арендатора А не
541
+ перечитывает реестр арендатора Б.
542
+ - `projector`: `rebuild({ tenant, chunked: true })` по две строки, пока пишутся источники и хвост идёт в другом
543
+ процессе, — итоги арендатора и остальных равны сумме источников; выбывший по ярусу в процессе без `onExpired`
544
+ перестраивается процессом с `onExpired: 'rebuild'`; отстающий курсор после перестройки не выводит его снова; ошибка
545
+ данных при автоматической перестройке — выбывание с `invalid_data` и ни одной повторной попытки.
546
+ - `watch`: `ready()` у `'start'` и `'now'`; два параллельных `next()` — два разных события; ожидающие вызовы при
547
+ `close()` — `done`.
548
+ - `find-ids-parity`: свои членства в других арендаторах (живое и удалённое) и строки `Class` System — сейчас, `asOf`
549
+ и `withDeleted`: ответы `find_*` и прямого чтения под RLS совпадают. `explain`: срез `asOf` по классу у арендатора с
550
+ двадцатью версиями `Staff` читает из индекса `log_class_tenant_at` не больше 40 записей при пяти тысячах версий
551
+ `Staff` у другого арендатора (`pg_stat_get_xact_tuples_returned`); прежнего индекса нет.
552
+ - `mcp`: клиент ревизии 2026-07-28 повторяет вызов с подменённым `requestState` — подделанная подпись, подпись сервера
553
+ другого пользователя того же арендатора и истёкшая (`mock.timers` для `Date`) — `confirmation_required`, объект цел;
554
+ своя подпись до срока — удаление проходит.
555
+ - `projector` (ревью безопасности №3, №1 и №6): после начала перестройки арендатора пачками удалена строка с
556
+ наибольшим id, пачки её уже не прочтут — итог равен сумме живых строк; источник арендатора выключил журнал посреди
557
+ его перестройки пачками — арендатор выбывает (`invalid_class`) без частичных строк.
558
+ - `engine-attacks` №44 (ревью безопасности №3, №3): `reclass` строки, на которую ссылается строка производного
559
+ класса, — `derived_class`, ссылка и класс прежние.
560
+ - `projector` (ревью безопасности №3, №4): сырая смена класса строки без ключа из `Mv` в `Away` — изменение `delete`
561
+ с `$deleted`, классом `Mv` и прежними данными, вклад вычтен; `reclass()` обратно с новыми данными — изменение
562
+ `reclass` с `before` = `null`, итог верен.
563
+ - `engine-attacks` №45 (ревью безопасности №3, №2): пока идёт запись семейства, в `pg_locks` нет advisory-блокировок;
564
+ `secret` вида `LOCK`, `lock_try` и `lock_row` прикладной роли недоступны (`42501`); пока она держит advisory с
565
+ прежними ключами семейства, обслуживания и проектора, проходят запись, ужесточение, `maintain()` и `run()`
566
+ проектора. `engine-races` №3а: ужесточение во время непрерывной перекрывающейся записи в семейство проходит меньше
567
+ чем за 3 с (без шлюза ждало, пока писатели не остановятся). `maintain`: блокировку обслуживания держит транзакция
568
+ другого процесса (`lock_try`) — `skipped`. Бенч `bench/family-lock.bench.mjs` — цена блокировки семейства: строка-
569
+ замок со шлюзом против строки без шлюза и advisory 1.1.
570
+ - `projector` (ревью безопасности №3, №5): удаление, которого проектор ещё не прочёл, — `purge` отклонён
571
+ `projector_pending`; после `run()` — проходит, итог верен; удаление и `purge` одной транзакцией — отказ; класс вне
572
+ источников и арендатор без производного класса проектора — `purge` сразу. 7.5 №9: `purge` источника до `run()`
573
+ проекторов — `projector_pending`, после — служебное изменение `purge`.
574
+ - `engine-races` №3б и №3в (`/security-review` после ревью №3): корни семейств `Kase` и `kase` одного арендатора —
575
+ ужесточение `kase` ждёт незафиксированную запись в него и находит её строку; строка вида `LOCK` чужого аккаунта с
576
+ именем блокировки ещё не созданного семейства — блокировка семейства работает, накат помечает строку удалённой. Без
577
+ исправления ужесточение шло, не дожидаясь записи.
578
+ - `engine-attacks` №46 (автоматическое ревью коммита 1bc6584): транзакция выставляет справку `letopis.family_locks`
579
+ «семейство уже взято» и пишет строку, пока ужесточение держит семейство, — запись ждёт его и получает
580
+ `invalid_data`, строк, нарушающих новое описание, нет. Без исправления запись проходила сразу.
581
+
582
+ ## [1.1.0] — 2026-10-11
583
+
584
+ letopis 1.1 по плану `reports/PLAN-LETOPIS-1.1.md` (редакция 5, этапы 0–3 и контрольная точка А): `diff` и `blame`
585
+ по журналу с причиной изменения, режимы сигналов, первая заливка; ревью безопасности №1. Совместимость с 1.0 не
586
+ сохраняется: процессам 1.0 и 1.1 не нужно работать с одной схемой; обновление схемы 1.0.1 (`up({ upgrade: true })`,
587
+ миграция `0006`) тестом не проверяется.
588
+
589
+ ### Changed — этап 0 (подготовка)
590
+ - Версия `1.1.0` (во время работы — `1.1.0-alpha.0`, не публиковалась).
591
+ - `db.describe()` и `describeText()` отдают условия класса по нескольким полям `anyOf` и `oneOf` (из
592
+ `resolved.schema`, с учётом наследования). Раньше агент, читавший описание, их не видел.
593
+
594
+ - Бенч против 1.0.1 (`bench/vs-101.bench.mjs`, dev-зависимость `letopis-1` = `npm:letopis@1.0.1`): засевка полигона
595
+ salon, загрузка без истории, построчный `create`, чтение по ключу, цепочка, `asOf`, двадцать писателей,
596
+ `maintain`. Опорный прогон на одинаковом коде (`bench/results/vs-101.md`) показывает шум замера: до ±21 % у
597
+ построчной записи и ±11 % у писателей — разницу меньше этого на точках А и Б считать нельзя.
598
+ - Карта: заготовки карточек модулей M-DERIVED и M-MCP (этапы 4 и 5 плана 1.1).
599
+
600
+ ### Fixed — этап 0
601
+ - Контракт API (`docs/api-contract.json`): поля `chain`, `db` и `batch` перечисляют методы цепочки, корня и батча,
602
+ а `removed` — снятое API с заменами. В 1.0 генератор писал `chain` пустым, а `removed` не находил ни одного
603
+ охранника: искал вызовы с тремя аргументами и только в `errors.ts`.
604
+
605
+ ### Tests — этап 0
606
+ - `telemetry`: условия `anyOf` и `oneOf` в `describe()`, наследование и замена своими условиями потомка, вывод
607
+ `describeText()`.
608
+
609
+ ### Added — этап 1 (`diff`, `blame`, причина изменения)
610
+ - Терминалы `diff({ from, to, follow })` и `blame({ at, limit })`: что изменилось между двумя версиями или моментами
611
+ и кто, когда и почему последним менял каждое поле. Пути — массивы с разделом строки в начале (`data`, `links`,
612
+ `tags`, `owner`, `class`); объекты данных — по ключам, массивы — целиком, множественные концы и теги — по
613
+ элементам. `exact: false` и `complete: false` — история неполная (прореживание, обрезка, невидимые версии,
614
+ предел `limit`, по умолчанию 1000 версий).
615
+ - Функции базы `jsonb_changes`, `version_changes`, `version_leaves`, `diff_versions`, `blame`
616
+ (`lib/sql/75-history.sql`) с правами вызывающего: сырой SQL получает то же под RLS журнала.
617
+ - Причина изменения из библиотеки: опция `reason` у `create`, `update`, `upsert`, `delete`, `anonymize`, `reclass`,
618
+ `restore`, `purge`, `rekey`; `db.begin({ reason })` и `db.batch(имя, { reason })` — для всех операций блока.
619
+ В 1.0 причину мог задать только сырой SQL настройкой `letopis.reason`.
620
+ - Зарезервированные имена `diff` и `blame`.
621
+ - Замер `bench/history.bench.mjs` (`docs/checkpoint-1.1.md`): `blame` по 1000 версиям — около 0,26–0,29 с, по
622
+ 10 000 — 1,5 с; `diff` — 4–12 мс.
623
+
624
+ ### Added — этап 1б (колонка изменённых путей, ревизия движка 6)
625
+ - Колонка журнала `changed`: пути изменения каждой версии против предыдущей. `blame` берёт их вместо сравнения
626
+ соседних версий; версии без путей (создание, загрузчик, импорт) досчитывает сравнением. Миграция
627
+ `lib/sql/migrate/0006-changed-paths.sql`; в снимок и хэш версии колонка не входит.
628
+ - `blame` и `diff_versions` — без JIT (`set jit = off`), соседние версии — окном `lead()`: `blame` по 1000 версиям —
629
+ 13,5 мс вместо 264, по 10 000 — 0,12 с вместо 1,5 (`docs/checkpoint-1.1.md`).
630
+
631
+ ### Tests — этап 1
632
+ - `history` (план 1.1, §7.2 №1–9; колонка `changed`: пути от триггера, `blame` одинаков с колонкой и с досчётом);
633
+ `roles` — новые функции приложения в белом списке.
634
+
635
+ ### Added — этап 2 (режимы сигналов)
636
+ - Режим сигналов установки `up({ signals })`: `class` (по умолчанию, как в 1.0) — имя класса, `tenant` — имя класса
637
+ и токен арендатора, `opaque` — только токены, `none` — сигналов нет. Токен — первые 96 бит HMAC-SHA256 на ключе
638
+ сигналов, выведенном из ключа строки `secret` с id System: без ключа псевдоним не сопоставить с именем, а
639
+ одинаковые сигналы транзакции PostgreSQL по-прежнему склеивает. Режим хранится в схеме (`meta` строки ключа):
640
+ повторный `up()` без опции его не меняет, `reset({ all })` сохраняет.
641
+ - Ключ сигналов — функция владельца `signal_key()` и право `letopis.signals` (системный сид выдаёт его сервисам:
642
+ ресурс `signals:API`, правило для `service:ACCOUNT`). Процесс с ключом сбрасывает кэш с точностью до арендатора
643
+ (строки System — во всех) и узнаёт классы по токенам; процесс без ключа сбрасывает класс во всех арендаторах, а в
644
+ режиме `opaque` — весь кэш на любой сигнал. Реестр, ключ и пропуска прав по `*`, незнакомым токенам, нагрузке не
645
+ своего режима (и у процесса без ключа в `opaque` — по любому сигналу) перечитываются не чаще раза в 5 с.
646
+ - Режим `none`: без LISTEN и без отправки сигналов, кэш только по сроку (`ttlOnly`), подписка опрашивает журнал,
647
+ реестр раз в 5 с сверяет отпечаток классов `class_stamp()`.
648
+ - Подписка просыпается только от своих классов и своего арендатора; без ключа — от любого сигнала, кроме сессий.
649
+ - `UpResult.signals`, `UpResult.warnings`, `InstallResult.signals`; `up()` предупреждает о PostgreSQL ниже 18.2
650
+ (сервер мог усечь CLOG, который ещё нужен непрочитанным сигналам), `checkServer()` возвращает предупреждения.
651
+ Тип `SignalMode`.
652
+ - Функции базы `signal(классы, арендаторы)` вместо `signal(классы)`, `signal_raw()`, `signal_token()`,
653
+ `signal_mode()`, `signal_key()`, `class_stamp()`; маркер установщика `@SIGNALS@`.
654
+ - Замер `bench/signals.bench.mjs` (`docs/checkpoint-1.1.md`): цены режима в скорости записи не видно на фоне шума;
655
+ `signal()` в режимах `tenant` и `opaque` дороже на 15 и 26 мкс на оператор; при записи одного арендатора ответы
656
+ другого остаются в кэше процесса с ключом (100 % попаданий против 0 % в режиме `class`).
657
+
658
+ ### Changed — этап 2
659
+ - Сигнал записи строится из пар «класс, арендатор»: триггеры, функции владельца, загрузчик и импорт (загрузчик и
660
+ импорт больше не зовут `pg_notify` сами); сессии и сброс — `signal_raw()`. `reset({ all })` шлёт `*` и
661
+ `session:*` без отдельного `Class`.
662
+ - После переподключения слушателя процесс перечитывает ключ и реестр, а не только сбрасывает кэш: сигнал `Class`,
663
+ пришедший во время обрыва, потерян. Перечитывания реестра по сигналу `Class` не идут одновременно: сигналы во
664
+ время перечитывания сливаются в одно следующее.
665
+ - Общий список классов прав — `ACL_CLASS_NAMES` в `types.ts` (был продублирован в `cache.ts` и `load.ts`).
666
+ - Бенчи `core-concurrency`, `core-write`, `core-notify`, `core-load` — на `signal_raw()` и новую `signal()`. Заодно
667
+ `core-concurrency` и `core-write` снова запускаются: их подмены `targets_bad` искали сигнатуру с двумя параметрами,
668
+ а замена `trg_entity_ai` в `core-write` была без `security definer` (сломались ещё до выпуска 1.0.0).
669
+
670
+ ### Fixed — этап 2
671
+ - Смена класса без ключа (сырой `update set class`) сигналит и прежний класс: в 1.0.1 сигнал шёл только по новому,
672
+ и ответы прежнего класса в кэше других процессов устаревали до срока.
673
+ - `Notifier` возвращает в очередь сигналы, которые не удалось отправить, и повторяет их следующим интервалом: в
674
+ 1.0.1 они терялись.
675
+
676
+ ### Tests — этап 2
677
+ - `signals` (план 1.1, §7.3 №1–9, №12–17): нагрузка у посторонней роли в каждом режиме, точность кэша до
678
+ арендатора, строки System, классы прав, процесс без ключа и тысяча записей, один путь у библиотеки и сырого SQL,
679
+ загрузчик, подписка, ключ, `none`, склейка, версия сервера, смена класса, повтор отправки, режим после `up()` и
680
+ `reset({ all })`. `engine-attacks` №31–32; `roles` — `signal_key` (негативный тест), `signal_mode`, `signal_key`,
681
+ `class_stamp` в белом списке; `system-classes` — 12 ресурсов и 8 правил, право сервиса `letopis.signals`;
682
+ `security-final` — формат нагрузки любого режима.
683
+
684
+ ### Added — этап 3 (первая заливка)
685
+ - `db.load(rows, { initial: true })`, `letopis load --initial`, `letopis import --initial`: загрузка в пустую схему
686
+ одной транзакцией — блокировка таблиц, проверка пустоты (кроме строк системных классов и записей `reset` —
687
+ `not_empty`), строки системных классов и определения ограничений, индексов и триггеров (с режимом включения) из
688
+ каталога, TRUNCATE, снятие индексов, ограничений и триггеров, `COPY … FREEZE`, перестройка, триггеры с прежним
689
+ режимом, проверки целей, владельцев и арендаторов по перестроенным индексам, сигнал, `ANALYZE`. Несовместима с
690
+ `commitEvery`, `existing` (кроме `'error'`) и `{ as }`; импорт с `--initial` — без `--update` и `--commit-every`,
691
+ строки системного сида пропускает. Код ошибки `not_empty`.
692
+ - Замер `bench/initial-load.mjs` (план 1.1, §7.4 №11; `docs/checkpoint-1.1.md`): 1,07 млн объектов полигона salon
693
+ первая заливка пишет за 113 с, обычный загрузчик — за 182 с (в 1,6 раза быстрее; цель плана — втрое), WAL — 1,4 ГБ
694
+ против 4,0 ГБ. Главный вклад — индексы GIN. `FREEZE` при загрузке ничего не стоит, а первое чтение и первый VACUUM
695
+ после неё не переписывают таблицу: около 12,5 с и 2,5 ГБ WAL экономии.
696
+
697
+ ### Changed — этап 3
698
+ - Загрузчик отправляет строки `COPY` кусками до 256 тысяч символов, а не отдельным сообщением `CopyData` на строку:
699
+ процессорное время загрузчика — вдвое меньше. Обычную загрузку ограничивает база, её скорость не изменилась; первая
700
+ заливка 52 тысяч объектов — 6,8 с вместо 8,3–9,2 с.
701
+
702
+ ### Fixed — этап 3
703
+ - `load({ verify: true })` в режиме истории ничего не проверял: список записанных строк оставался пустым.
704
+
705
+ ### Tests — этап 3
706
+ - `initial-load` (план 1.1, §7.4 №1–9): равенство с обычным загрузчиком (строки, журнал, хэши, `verify`), структура
707
+ и `ENABLE ALWAYS` после перестройки, `seq` и `tx` системных строк, `not_empty`, вставка во время запуска, откат,
708
+ `MULTI_INSERT` в WAL (`pg_walinspect`), `commitEvery`, импорт одной транзакцией.
709
+
710
+ ### Security — точка А (ревью безопасности №1)
711
+ - Колонка журнала `changed` приложению больше не выдана (право на `log` — по колонкам). Её пути описывают разницу
712
+ с предыдущей версией, и когда условие ACL скрывало версию, а следующую пропускало, в путях были видны снятые теги,
713
+ цели множественных концов и имена ключей скрытой версии. `blame` берёт пути функцией владельца `version_paths` —
714
+ только когда видны обе версии пары. `select * from log` от приложения теперь отказ: колонки перечисляются явно;
715
+ `version_changes(log, log)` и `version_leaves(log)` заменены функциями по колонкам `row_changes` и `row_leaves`.
716
+ - Перечитывание реестра по сигналу `Class`: три подряд — сразу, дальше не чаще раза в 5 с — заливка канала
717
+ поддельными `Class` держала процессы в непрерывном перечитывании. Счётчиков сигналов в кэше — не больше 100 тысяч в
718
+ каждой карте: заливка разными поддельными именами копила их без предела.
719
+
720
+ ### Changed — точка А
721
+ - Первая заливка — конвейером: следующая пачка готовится, пока база пишет текущую (та же транзакция, `FREEZE`).
722
+ На 1,07 млн объектов — 77 с вместо 113 с, в 2,2 раза быстрее обычного загрузчика (169 с); обычный загрузчик
723
+ по-прежнему пишет пачки по очереди (`docs/checkpoint-1.1.md`).
724
+
725
+ ### Fixed — точка А
726
+ - `blame` после пропуска версии (прореживание, обрезка, невидимая версия) приписывал лист, изменённый выпавшей
727
+ версией, более старой версии с `exact: true`: пути колонки `changed` берутся только у пары без пропуска, а `exact`
728
+ учитывает пропуски от текущей версии до найденной.
729
+ - Пути изменения в триггере правки: множественный конец, который не менялся, больше не раскладывается на элементы,
730
+ а изменившийся считается разностью множеств — правка любого поля объекта с тысячами целей стоила секунды.
731
+ - Причина `db.batch(имя, { reason })` записывается в глагол при постановке в очередь: `run()`, вызванный с другого
732
+ фасада той же очереди, её терял.
733
+
734
+ ### Tests — точка А
735
+ - `history`: версия, скрытая условием ACL, — колонка закрыта, в `blame` и `diff` её нет; лист, изменённый выпавшей
736
+ версией; `row_changes` на 20 тысячах целей; причина батча с другого фасада. `roles` — права на колонки журнала и
737
+ `version_paths`; `cache` №17 — предел счётчиков; `engine-attacks` №32 — поддельные `Class`.
738
+ - Гонки в тестах, ронявшие CI (библиотека не менялась): подписка `'now'` пишет только после начальной точки
739
+ (`watchStarted` — `watch`, `retention`, сценарий салона), `take` после таймаута не зовёт `next()` второй раз; прокси
740
+ обрыва `'after'` рвёт связь, когда база уже исполнила COMMIT, а подключение через него — без продления сессии
741
+ (`balance` №13–14, №25–27); С15 (`scenario-real-life`) — оба порядка отмены и переноса через ожидание блокировки;
742
+ 5.2 (`auth`) — замеры парами вперемешку.
743
+
5
744
  ## [1.0.1] — 2026-10-09
6
745
 
7
746
  Исправления, найденные сразу после выпуска 1.0.0.