# armdb roadmap
Приоритизированный список примитивов, которые стоит добавить в движок. Составлен 2026-07-30.
**Источники.** Три research-документа:
- [`26-07-17-…-claude-fable-high.md`](../../docs/research/26-07-17-decoded-value-cache-and-lego-architecture-claude-fable-high.md)
— decoded value cache и «Lego-архитектура», взгляд от hot path мессенджера.
- [`26-07-17-…-gpt-5.6-sol-high.md`](../../docs/research/26-07-17-decoded-value-cache-and-lego-architecture-gpt-5.6-sol-high.md)
— то же, но взгляд от позиционирования «замена реляционки»; ставит транзакции фундаментом.
- [`26-07-30-msg-back-armdb-usage.md`](../../docs/research/26-07-30-msg-back-armdb-usage.md)
— измерение крупнейшего потребителя (117 коллекций, ~37k строк `crates/db`): что именно болит и
сколько строк снимает каждый примитив. **Цифры ниже — оттуда.**
**Принцип отбора.** Новые возможности — как слои над существующими примитивами, а не как новые оси
матрицы (`2 backend × Const/Zero/Typed/Var × Tree/Map × кодеки × 2 стека репликации`). Каждая новая
ось умножается на остальные и оплачивается в тестах и документации.
**Легенда волн.** Волна 0 не трогает write path — её можно делать параллельно с проектированием
транзакции. Волна 1 — два больших независимых трека. Волны 2–3 зависят от транзакции.
---
## Состояние на 2026-08-20
Сверено с кодом, а не только со статусами ниже.
| | Пункт | Статус |
|---|---|---|
| Волна 0 | **T1** bulk-операции по префиксу | закрыто: `Scope`/`TxScope` v1 API; границы ниже остаются намеренными |
| | **T2b** backward finger | **закрыто** — read-only `BackFinger` для reverse walk |
| | **T2d** переиспользование search-фрейма | **закрыто** — гипотеза измерена и не подтвердилась, [#126](https://github.com/libfunc/armour/issues/126) |
| | T2, T2c, T3, T4 | закрыто |
| Волна 1 | T5 | закрыто |
| | **T6** TTL | закрыто, кроме отложенного sidecar `ttl.idx` (Task 6) |
| Волна 2 | T7, T8, T9, T10 | закрыто |
| Волна 3 | T11, T12, T13, T14 | закрыто |
| Known behaviour | пп. 1–2 (lenient `range`, `count(true)`) | **открыты намеренно** — wire-visible |
| | п. 3 (`scan_keys`) | закрыто (PR 3c) |
| Seam | S1 | закрыто |
| | **S2** разрешение харнесса | **открыто** — 8-поточные строки совещательные |
| P3 / стабилизация | #149, #80, #145, #106, #83, #82 | **закрыто** 2026-08-20 (PR #151–#159) |
| Релиз | `0.10.0` | **опубликован** 2026-08-16 (crates.io, состояние на `20fd5cf`) |
| | `0.11.0` + диапазоны у потребителя | **открыто** — манифест уже `0.11.0`, CHANGELOG держит `Unreleased (0.11.0)` |
Итог: всё из роадмапа либо закрыто, либо отложено по явному решению. Незакрытый
блокер для потребителя один — **релиз `0.11.0`**: `0.10.0` опубликован и
потребитель на нём, но T1, T2b и шесть P3-исправлений (включая дедлок
`Tx::commit` ↔ checkpoint, #149) приземлились **после** публикации, поэтому в
проде их нет. Диапазоны `"0.9"` → `"0.10"` уже сдвинуты; следующий шаг —
`"0.10"` → `"0.11"`. Миграционный документ готов:
[`26-08-20-armdb-0-11-0.md`](../../docs/breaking-changes/26-08-20-armdb-0-11-0.md).
---
## Волна 0 — аддитивные примитивы
Ничего не меняют в write path и ни от чего не зависят. Делаются, пока проектируется T5.
### T1. Bulk-операции по диапазону
**Статус. Реализовано 2026-08-18** по spec
[`26-08-18-bulk-range-operations.md`](../../docs/superpowers/specs/26-08-18-bulk-range-operations.md).
Доставленный v1 API: `Scope::{delete_all, delete_up_to, retain, count,
is_empty}` и collection-bound `TxScope` + `Tx::delete_scope`. В транзакции
каждый chunk атомарен вместе с его derived index/counter/queue rows; финальная
derived-row admission происходит до append в лог, поэтому её отказ оставляет
данные нетронутыми и допускает повтор с меньшим (обычно вдвое) лимитом.
Сознательные границы v1: только шесть managed families (`TypedTree`,
`TypedMap`, `ZeroTree`, `ZeroMap`, `VarTypedTree`, `VarTypedMap`); raw `Var*`
и `LsmTree` не участвуют в `TxScope`; `delete_all`/`retain` остаются
weakly-consistent non-transactional operations, а `delete_scope` ограничивает
источники до развёртывания derived rows. Прототип `count_prefix` из PR #111
остался удалённым — публичный шов называется `Scope::count`.
**Зачем.** 58 мест вида «собрать ключи в `Vec` → цикл `delete`». Причина названа в коде прямым
текстом (`tables/space/read_state.rs:64`, `tables/space/activity.rs:213`):
`/// Collect-then-delete (no bulk prefix-delete in armdb); called on membership loss.`
Плюс O(N)-подсчёты вида `prefix_iter(...).count()`. Плюс делает читаемым `delete_space` — 379 строк
каскада, из них 18 блоков ровно этого вида.
**Эффект.** ~350 строк. Самый высокий ROI во всём списке — дни работы.
**Зависимости.** Нет.
### T2. Типизированные префиксы, диапазоны и cursor-пагинация
**Статус.** Закрыто 2026-08-03. Spec:
[`26-08-03-typed-prefixes-ranges-pagination.md`](../../docs/superpowers/specs/26-08-03-typed-prefixes-ranges-pagination.md).
```rust
tree.scope::<2>(&key) // field-boundary prefix
scope.range::<3>(lower, upper) // tail-saturates 0x00/0xFF
scope.page(after, limit) // -> Page { items, next }
scope.page_by(after, limit, f) // tops up across Ok(None)
```
**Зачем.** 119 ручных сборок байтового префикса, 25 конструкций sentinel-границ
`[0x00;8]`/`[0xFF;8]`, 15 почти идентичных функций пагинации (`history_*`/`after_*`/`around_*` ×
5 типов сообщений). Худший случай — `by_space_user_range` (`tables/space/activity.rs:157-211`):
**55 строк**, чтобы выразить «префикс `(space,user)`, диапазон по `msg_id`, хвост ключа min/max».
**Обязательное требование к `page`:** добирать до `limit` при отсеве строк фильтром. Сейчас
`take(limit)` стоит **до** join'а, и страница молча возвращается короче лимита, если primary-строка
удалена (`joins/forum.rs:240`).
**Эффект.** ~1 080 строк.
**Зависимости.** Нет.
### T2b. Backward finger cursor (SkipList)
**Статус. Закрыто** (2026-08-18). `BackFinger` хранит read-only predecessor staircase между
шагами reverse walk: первый seek ожидаемо занимает **O(log n)**, а обход из *m* строк —
ожидаемо/амортизированно **O(log n + m)**. `find_last_lt` сохранён как oracle свежего поиска;
узлы и on-disk формат не получили `prev` pointer'ов или нового состояния.
Покрытие: loom проверяет конкурирующие insert/delete и fallback при недействительном
сохранённом predecessor; Miri покрывает паузу после проверки liveness перед чтением tower.
Normal/application-facing public API не изменён; storage/`typ_hash` не изменены.
### T2c. Финализация scope-шва (до релиза `0.8.0`)
**Статус.** Закрыто 2026-08-04. Spec:
[`26-08-04-scope-seam-finalisation.md`](../../docs/superpowers/specs/26-08-04-scope-seam-finalisation.md).
Три механические правки публичной поверхности `Scope` / `OrderedScan`, поднятые
[ревью PR #120](../../docs/superpowers/reviews/26-08-04-typed-prefixes-ranges-pagination-pr120-review.md).
Spec: [`26-08-04-scope-seam-finalisation.md`](../../docs/superpowers/specs/26-08-04-scope-seam-finalisation.md).
- **A. `Scope<'l, S, const N: usize>`** — `N` перестаёт быть runtime-полем, и весь
контракт `range::<M>` (`N < M <= fields`) уходит в `const {}`. Сейчас `M > N` —
рантайм-`assert!` в билдере, у которого все остальные отказы — `DbResult`.
- **B. Схлопнуть вырожденные GAT.** Ни один из 11 имплов не использует параметр
`Iter<'i>`/`Row<'r>`, и при `fn scan(self, …)` использовать не сможет: шов
обещает гибкость, которой нет, ценой ~84 клауз `where Self: '_`.
- **C. Эргономика:** `page`/`page_by` с `&'l self` → `&self`, `OrderedScan: Clone`
→ `Copy`, `Clone`/`Debug` на `Scope`.
**Почему сейчас.** Поведение, формат и `typ_hash` не меняются, но `Scope`,
`OrderedScan`, `TreeScan`/`MapScan` и типы итераторов публичны с PR #120 — после
релиза `0.8.0` каждая из трёх правок становится breaking.
**Не входит:** `macro_rules!` на `scope/iters.rs` (13 пар обёрток, ~730 → ~150
строк) — переписывание уже отревьюенного файла ради строк.
**Эффект.** Компайл-тайм контракт вместо паники; шов, на который сядут T1
(`count_prefix`) и T13 (`join_page`), перестаёт врать про лайфтаймы.
**Зависимости.** T2 (закрыт).
### T2d. Переиспользование search-фрейма SkipList — один спуск на `upsert`
**Статус.** Закрыто 2026-08-20 — **гипотеза измерена и не подтвердилась**
(measured, not confirmed). Реализация была доведена до конца, прошла ревью и
откачена коммитом `7d835714`; ветка `feat/t2d-skiplist-frame-reuse` сохранена в
origin, если к идее захочется вернуться.
Спека: [`26-08-19-skiplist-frame-reuse.md`](../../docs/superpowers/specs/26-08-19-skiplist-frame-reuse.md).
**Результат замера.** Три прогона против `a34e785b`, 8 закреплённых ядер:
| прогон | команда | решающие строки |
|---|---|---|
| горячая `write`, 9 повторов | `armdb/benches/ab.sh raw a34e785b HEAD write 9 0-7` | −1.6…+5.0%, вердикт FAIL |
| горячая `write`, 27 повторов | `armdb/benches/ab.sh raw a34e785b HEAD write 27 0-7` | −0.4…+1.2%, вердикт PASS |
| `cold`, 15 повторов | `armdb/benches/ab.sh raw a34e785b HEAD cold 15 0-7` | −1.0…+0.3% на строках с CV ≈ 1% |
Разброс 9-повторного прогона оказался свойством инструмента, а не ветки:
self-check `HEAD HEAD` напечатал +4.41% на той же строке, которую гейт назвал
регрессией. На 27 повторах разброс схлопнулся до ±2%, и все решающие строки
легли в ±1.2% от нуля. Это же наблюдение — материал для S2.
Холодная группа (`bdbeaee0`) строилась специально, чтобы дать гипотезе лучший
случай: индекс 512 MiB против 32 MiB L3 этого хоста, ключи `(chat, msg)` по 16
байт, значения 128 байт, обход перестановкой — 1716 нс/оп против 183 нс/оп на
горячем профиле, то есть каждый уровень спуска платит промахом DRAM. Даже там
две самые точные строки дали −0.27% и −0.47%.
**Почему не окупается.** Второй спуск идёт по узлам, которые первый только что
поднял в кеш: он платит попаданиями, а не промахами — сотни наносекунд из ~2000,
а не половину поиска. Ровно столько же курсор возвращает обратно на
present-ключах: `lookup` обязан спуститься до уровня 0, тогда как `get` выходил
на первом же совпавшем узле. Раздел «Accepted cost» в спеке предсказывал оба
эффекта, но исходил из того, что экономия перевесит; она не перевешивает, а
сходится с расходом в ноль.
**Что осталось в дереве** (от курсора не зависит): группа `cold`, сценарий
`write/delete_const_tree`, отчёт о прогрессе A/B, починка `role_for` для
`pair == "self"` и два фикса агрегатора `ab.sh` (`AB_SKIP_TX`, TTL-строки).
**Побочные находки прогона.** Дедлок `Tx::commit` ↔ `checkpoint::run`
([#149](https://github.com/libfunc/armour/issues/149), воспроизводится на `main`,
не связан с T2d) и часть 1
[#134](https://github.com/libfunc/armour/issues/134) — агрегатор `ab.sh` падал
с `KeyError` на TTL-строках, из-за чего write-гейт вообще не выдавал вердикт.
**Ниже — исходная постановка гипотезы, как она выглядела до замера.**
Фьюзед `upsert` на `ConstTree`/`ZeroTree` делает один спуск на `Keep` и
present-`Put`, но **два** на absent-`Put` и present-`Delete`: `existing`
приходит от `index.get`, после чего `index.insert` / `index.remove` внутри
запускают собственный `presearch` (`skiplist/mod.rs:381`, `:470`).
**Шов уже написан и уже так работает.** `presearch` (`:178`) собирает
`Frame { preds, succs, found }`; `insert_with_frame` (`:408`) /
`remove_with_frame` (`:486`) валидируют его под `write_lock`
(`validate_link:241`, `validate_unlink`) и падают в `search_locked` при
устаревании — тесты устаревания уже есть (`:1127`, `:1160`). Все три приватны
для модуля, то есть работа — `pub(crate)` плюс протаскивание фрейма из
`const_tree`, а не новая модель конкурентности.
**Два вопроса дизайна.**
1. *Guard contract уезжает наружу.* Фрейм обязан быть собран под живым
`seize`-guard; сегодня это гарантировано тем, что оба шага внутри одного
метода скиплиста. При протаскивании гарантия становится обязанностью
вызывающего — это safety-контракт, а не эргономика.
2. *Порядок с высотой инвертируется.* `validate_link` разыменовывает
`preds[0..node_height]` без null-проверки, поэтому фрейм нужен от поиска с
`search_height >= node_height`. Сегодня `insert` поднимает `self.height`
**до** своего `presearch` (`:366-381`); при переиспользовании `random_height()`
и height-bump уезжают до первого спуска — то есть до того, как замыкание
решило, нужна ли вставка вообще. На `Keep` это работа впустую, а `keep/1` —
ровно та строка, на которой провалил гейт фьюзед `ConstMap` (+4.32%).
**Замер.** Харнесс готов: T3 добавил
`write/upsert_{const_tree,const_map}/{present_put,absent_put,present_delete,keep}/{1,4}`.
До T3 гипотеза была неизмерима — write-сценарий только перезаписывал
present-ключи. Гейт: decisive-строки 1 и 4 писателя, то же правило «не хуже
+3%», и `keep` не должен просесть.
**`ConstMap` — независимо.** У хеш-индекса та же двойная работа
(`map_index.rs:829`, `:839`, `:850`), но общего кода со скиплистом нет; entry-шов
там проектируется отдельно и гейтится отдельно, как в T3.
**Эффект.** Один спуск вместо двух в двух ячейках из четырёх, на самых горячих
семействах (Zero* — 71 из 117 коллекций потребителя).
**Зависимости.** T3 (закрыт) — дал и фьюзед-тело, и decision-mix в харнессе.
### T3. Атомарные операции над значением
**Статус.** Закрыто 2026-08-03. Спека:
[`26-08-03-atomic-value-upsert.md`](../../docs/superpowers/specs/26-08-03-atomic-value-upsert.md).
Вместо семи именованных скалярных операций — один обобщённый `upsert` (`Upsert` /
`IntoUpsert`) на всех семействах, открываемых через `Db` (facade + shard). Fused
путь для `ConstTree` — landed; для `ConstMap` — нет (см. журнал решений).
**Зачем (исходная формулировка).** 21 место `.atomic(key, |shard| …)` по 5–15 строк, и это **четыре повторяющиеся идиомы**:
монотонный максимум (`read_state.rs:40`, `forum_read_state.rs:37`, `activity_seen.rs:43`),
битовые флаги (`activity.rs:107`, `:122`), init-or-increment / decrement-or-delete
(`threads.rs:160`, `:182`, `space_threads.rs:112`, `:129`), insert-if-absent
(`space_acks.rs:94`, `forum_acks.rs:94`).
**Эффект.** ~150 строк, плюс фундамент под T9.
**Зависимости.** Нет.
### T4. Decoded value cache (`Arc<T>`) для VarTyped-коллекций
**Статус.** Закрыто 2026-08-05 (PR #125, `armdb/src/decoded_cache.rs`). Спека:
[`26-08-04-decoded-value-cache.md`](../../docs/superpowers/specs/26-08-04-decoded-value-cache.md).
Замер потолка: [`decoded-cache-ceiling.md`](decoded-cache-ceiling.md); реализованный
выигрыш — 764 → 214 ns/op (≈72%) на парном `get_direct`/`get_shared`, см. журнал
решений 2026-08-05. Adoption у потребителя — по-прежнему открытый follow-up в другом
репозитории (`msg-back` читает через `get`/`get_or_err`).
Кеш **декодированных** значений поверх `VarTypedTree`/`VarTypedMap` как опция
конфига (`Config::decoded_cache`, по умолчанию 0 во всех пресетах) — не новое
семейство коллекций. Ключ — физический `ValueKey (shard_id, file_id, offset)`,
поэтому инвалидации нет нигде: update меняет `DiskLoc`, старая запись дозревает
до eviction; compaction → новый loc → промах.
**API.** `get_shared` / `try_get_shared` на обоих семействах, `get_many_shared`
только на Tree (у Map нет `get_many`). `get`/`try_get`/итераторы не меняются и
кеш не читают. Возврат — `Arc<T>`; идентичность указателя между вызовами
**оппортунистическая** (её ломают eviction, конкурентный холодный промах и
compaction), инвариант один — корректность версии.
**Гейт — двухступенчатый.** «Бенчмарк до реализации» из первой редакции буквально
невыполним (третий вариант требует прототипа). Вместо него:
1. **Потолок без прототипа** — `VarTypedTree::get − TypedTree::get` (у `TypedTree`
горячее чтение это «поиск в индексе и отдать указатель», то есть ровно то, чем
является decoded-хит). Порог-убийца: потолок < 20% во всех классах размера.
2. **A/B перед мерджем** на `ab_harness`/`ab.sh` — сценарии `get_shared` плюс
подтверждение, что байтовые строки не сдвинулись. База для `get_shared` —
парный `get_direct` (та же setup-функция), **не** `read/var_typed_tree/get`:
последний идёт через RPC-хендлер и приписал бы кешу чужой выигрыш.
**Coalescing промахов — сознательно отложен.** Без него кеш не хуже сегодняшнего
поведения (сегодня декодирует *каждое* чтение), а стенда для подтверждения
(Zipf-читатели в 4–8 потоков) в харнессе нет.
**Не входит:** `LsmTree` (fjall не даёт физической версии значения — нужен ключ
по `K` с инвалидацией, другой дизайн); adoption в msg-back (другой репозиторий,
отдельный пункт со своим замером).
**Эффект.** Строк снимает мало (~15). Единственный пункт с прямым выигрышем по
CPU на горячем read-path — но выигрыш реализуется только после adoption у
потребителя.
**Зависимости.** Нет.
---
## Волна 1 — два больших независимых трека
### T5. Crash-atomic multi-collection write batch
**Статус.** Закрыто 2026-08-06 (все три части на ветке `feat/t5-part3-fixed-crypto`).
Part 1 — лог (`txlog/`); part 2 — `db.tx()` на Bitcask
([`26-08-05-t5-commit-protocol`](../../docs/superpowers/plans/26-08-05-t5-commit-protocol.md));
part 3 — FixedStore и шифрование лога
([`26-08-06-t5-fixed-store-and-encryption`](../../docs/superpowers/plans/26-08-06-t5-fixed-store-and-encryption.md)).
**Статус (спека).** Спека:
[`26-08-05-crash-atomic-multi-collection-tx.md`](../../docs/superpowers/specs/26-08-05-crash-atomic-multi-collection-tx.md)
(дизайн утверждён 2026-08-05, переработан в тот же день по внешнему
[ревью](../../docs/superpowers/reviews/26-08-05-crash-atomic-multi-collection-tx-gpt-5-codex-review.md)).
Поверхность — `db.tx()` с плоским списком операций `put`/`delete`/`upsert`/`guard`,
no-steal redo-лог в корне `Db`, group commit, replay при открытии коллекции до
миграций. Эффекты хуков — **вне** crash-гарантии (сегодняшнее поведение), участники
— шесть managed-семейств, Fixed только у `Zero*`.
Транзакция на N коллекций: intent-log/WAL + commit marker, replay all-or-none при recovery.
**Зачем.** Это самая дорогая боль потребителя. `Db::atomic2/3/4` (удалены в 0.8.0) давали **только**
write-isolation (без rollback и crash-atomicity) и **не использовались ни разу**; prior art для T5 —
`armdb/docs/cross-collection-transactions.md`. Отвергнут письменно
(`crates/db/tests/suite/notification_outbox.rs:183`):
> «Rejected for now — `NotificationJobs::put` spans four collections, putting a transaction at
> `atomic4`'s exact ceiling.»
При том что `send_space_text_message` трогает **14 коллекций**, а `delete_space` — ~35.
Вместо транзакций в msg-back: **7 глобальных мьютексов + 3 таблицы полосатых локов + 3 ручных
rollback-замыкания + 6 семейств коллекций ради crash-recovery**.
Запрос сформулирован потребителем дословно
(`msg-back/docs/superpowers/specs/26-06-12-db-write-path-hardening.md:203-209`):
> «The one primitive still **absent** is an **atomic multi-key write batch** … N records across
> collections applied as one durable commit, all-or-nothing across a crash. Feasible on Bitcask
> (append the batch + single commit marker; replay all-or-none on recovery). It would eliminate the
> "careful ordering + compensation" tax rather than working around it.»
**Требования.**
1. **Потолок по числу коллекций — зависит от формы API.** *Решение 2026-07-30:* если API требует
генерировать N методов `atomicN` (типизированные кортежи, мономорфизация на арность) — потолок
**16**. Если удастся выразить транзакцию без per-arity методов (builder, гетерогенный список,
dyn-слой) — **без жёсткого лимита**. Форма API решается на этапе дизайна; потолок 4 —
доказанный блокер adoption. *Закрыто 2026-08-05:* выбран builder операций со стиранием типов
в точке вызова — потолка по коллекциям нет, есть лимиты `max_ops`/`max_bytes` на размер
write-set'а (следствие no-steal: он целиком живёт в памяти до коммита).
2. Intent-log/WAL + commit marker; recovery применяет всё или ничего.
3. Авто-rollback при ошибке в замыкании (сейчас 3 ручных `rollback`-замыкания в `tables/media.rs`).
4. `insert_unique` внутри транзакции — предусловие для T10 и для снятия `username_lock` /
`user_identity_lock`.
5. Совместимость с обоими backend'ами (Bitcask append-friendly; для FixedStore нужна отдельная
схема — открытый вопрос дизайна).
**Эффект (замерено по msg-back).**
| Что | Снимается |
|---|---|
| `task_operations` + 25 стадий автомата + `recover.rs`(876) | ~80% → −900…−1 100 строк; создание задачи с 7 записей интента до ~2 |
| `media_refs` + `Releasing` + `media_audit`(634) | ~60% → −350…−450 строк |
| `recount.rs`(1 010) | ~70% → −600…−700 строк + полный стартовый скан |
| `notification_outbox` / `task_assignment_outbox` | коллекции остаются (это очереди), но доставка переходит из «best-effort» в **at-least-once** |
Плюс **−2 полных скана коллекций каждые 60 с** (`reconcile_pending` + `sweep_expired_task_operations`)
и **−1 полный стартовый скан**. Итого **~2 000–2 400 строк** recovery-кода, ~300 строк каскадов,
~150 строк локов.
**Не снимает:** HTTP-идемпотентность (`task_http_idempotency`), очереди как таковые, и всё, что
пересекает границу с S3/tantivy (`search_outbox`, `MediaConfirmed`, `ForumPostEnsured`).
**Открытые вопросы — закрыты дизайном 2026-08-05** (детали и обоснования — в спеке):
форма API — плоский список операций у `db.tx()`, per-arity методов нет, потолок только
`max_ops`/`max_bytes`; FixedStore — участвует, правило replay по `(slot, target_version)` плюс
recovery-карта удалённых слотов, отдельной фазой после Bitcask; репликация — остаётся
транзакционно-неосведомлённой (non-goal), формат лог-записи самодостаточен под будущий log shipping;
consistent read snapshots — остаются в [Отложено](#отложено).
### T6. TTL с внутренним временным индексом
**Статус.** Частично реализовано 2026-08-05 (ветка `spec/t6-ttl`, 0.8.0):
carrier (`TtlHook` / `CollectionMeta::TTL`), in-memory time-index, sweeper,
replication promotion, Var cold build. **Отложено:** sidecar `ttl.idx` (Task 6)
— cold build на Var при 100k строк ~398 ms в debug; sidecar не нужен, пока
release-профили не покажут хуже. Spec:
[`26-08-05-ttl-time-index.md`](../../docs/superpowers/specs/26-08-05-ttl-time-index.md).
Per-entry TTL или поле `expire_at`, с индексом по времени и фоновым sweeper'ом (естественно ложится
на compactor).
**Зачем.** В msg-back **нет ни одной коллекции с временным индексом**, кроме `notification_jobs` —
и та построила его ценой **трёх дополнительных коллекций** плюс комментария на 10 строк про
`reversed: true` и `.rev()` перед `take_while`, продублированного дважды (на эту ловушку наступали
два раза). Всё остальное — «полный скан + фильтр по полю»: `task_assignment_sweeper` делает
**4 полных скана за тик каждые 60 с**.
**Три существующие утечки, которые это закрывает:** `temp_links`, `invites`/`space_invites`
(поле `expires_at` есть, уборщика нет вообще), `invite_activations` (растёт линейно с числом join'ов).
**Требование:** TTL должен переживать рестарт и не зависеть от wall-clock таймера в процессе.
Сейчас `media_temp_expire_job` с периодом 1 час теряет прогресс при каждом деплое — если деплоят
чаще раза в час, temp-медиа не удаляются никогда.
**Эффект.** −5 периодических задач, −1 коллекция (`jobs_terminal` со всей обвязкой, ~80 строк),
−4 полных скана персистентных коллекций, минус класс багов «таймер перезапустился».
**Зависимости.** Нет — независим от T5.
---
## Волна 2 — поверх транзакции
Общая мысль: сегодня schema-слой **описывает, но не выполняет работу**. `fk`, `denormalized`,
`counter` — только декларации для валидатора; `secondary_index` единственный возвращает рабочий
хук, и тот непригоден. Отсюда нулевое использование всего слоя. T7–T9 это чинят.
### T7. `BrandedKey` → `KeyShape` для составных ключей
**Статус.** Закрыто 2026-08-07. Спека:
[`26-08-06-armdb-branded-composite-keys.md`](../../docs/superpowers/specs/26-08-06-armdb-branded-composite-keys.md)
(дизайн утверждён 2026-08-06, переработан в тот же день по
[ревью](../../docs/superpowers/reviews/26-08-06-armdb-branded-composite-keys-gpt-5-codex-review.md)).
Релизная цель — **`0.9.0`**: `0.8.0` уже опубликован. Реализация прошла ревью
2026-08-07 (шесть дефектов матчинга, см. журнал решений). Публиковать в порядке
зависимостей: `armour-derive 0.29.0` → `armour-typ 0.5.0` → `rapira 0.14.0` →
`armour-core 0.5.0` → `armour-rpc 0.5.0` → `armdb 0.9.0` (плюс `armour 0.35.0`).
Миграция для downstream:
[`26-08-06-armdb-schema-composite-keys.md`](../../docs/breaking-changes/26-08-06-armdb-schema-composite-keys.md).
Цель ссылки — **форма ключа** (мультимножество брендов), целей может быть
несколько (`register_entity` = «здесь живут ключи такой формы»), сборка ссылки
автоматическая там, где бренды в скоупе различимы, и декларативная там, где нет.
Возможность привязать бренд к **компоненту** составного ключа (`MessageKey.msg_id` → бренд `"msg"`).
**Зачем.** Сейчас `BrandedKey` реализован только для одиночных `Fuid<H>` / `Id64<T>`
(`armdb/src/schema/access.rs:27,40`), а `register_entity` требует `S::K: BrandedKey`. В msg-back:
```
register_entity → 4 из 117 коллекций (users, spaces, notification_jobs, temp_media)
reg.register(…) → 113 из 117
```
**113 коллекций не могут быть целью FK в принципе**, и каждое branded-ID поле, указывающее на них,
вырождается в `UnvalidatedEdge` — warning, который молча тонет. Поле с брендом `"msg"` не
проверяется никогда: нет коллекции с ключом `MsgId`, сообщения лежат под `(chat_id, msg_id)`.
**Причина, найденная при дизайне 2026-08-06, — не одна, а две.** Вторая независима от составных
ключей и бьёт уже на одиночных ID: `channels`, `groups`, `personal_chats` все трое имеют ключ
`ChatId`, `BrandedKey` у них выполняется, но `finish()` отвергает второго претендента на бренд
(`DuplicateEntityBrand`, `registry.rs:245-254`). То же у трёх коллекций тел сообщений на бренде
`"msg"`. Значит правка одних только составных ключей оставила бы два самых частых бренда
кодовой базы непроверяемыми.
**Предусловие** для того, чтобы валидатор и ER-экспорт стали полезны хоть на чём-то, кроме
четырёх коллекций.
**Зависимости.** Нет.
### T8. `multi_secondary_index` — проекция 1→N с диффом и propagation ошибок
**Статус: реализовано** (PR #137, 2026-08-08; armdb 0.9.0). Спека:
[`docs/superpowers/specs/26-08-07-multi-secondary-index.md`](../../docs/superpowers/specs/26-08-07-multi-secondary-index.md);
миграция:
[`docs/breaking-changes/26-08-07-armdb-multi-secondary-index.md`](../../docs/breaking-changes/26-08-07-armdb-multi-secondary-index.md).
```rust
// проекция возвращает 0..N строк через sink; движок сам диффит old→new
reg.secondary_index::<GroupMessage, _>(
IndexTargets::new().with_index(&idx),
|k, v, sink| { /* sink.row(&idx, …) / sink.key(…) */ Ok(()) },
);
```
**Зачем.** 48 коллекций-индексов, **ни одна** не использует `reg.secondary_index`. Причина
конкретная: сигнатура `P: Fn(&SV::SelfId, &SV) -> (I::K, I::V)`
(`armdb/src/schema/registry.rs:278`) требует строго **1→1**, а в мессенджере проекция 1→N (из
сообщения выпадает N тегов, M упоминаний, K ссылок) или 1→0..1.
Итог: все индексы поддерживаются руками, и **матрица мутаций покрыта полностью ровно у одного
из 48** (`DocumentsHook`, `tables/document/documents.rs:33-79`, 47 строк ручного `match (old, new)`).
**Что чинится немедленно (найденные баги):**
- `forwards` — `delete` не вызывается **нигде**, строки-сироты копятся навсегда;
- `notification_jobs.rs:274` — контракт «callers must delete the previous due-index entry» вынесен
в комментарий на 5 вызывающих;
- `forum_bump` — стале-строки штатны, read-path их фильтрует, GC нет;
- `forum_by_tag` — ручной `HashSet::difference` в 4 местах;
- `task_*_index` — удаление строк одной задачи полным сканом канала, три раза подряд.
**Требования.**
- дифф `project(old)` vs `project(new)` → `delete` исчезнувших, `put` новых, no-op для совпадающих —
закрывает всю матрицу мутаций автоматически;
- ошибка записи индекса **возвращается вызывающему**, а не `log_if_err!` (сейчас глотается во всех
8 хуках msg-back);
- `IndexTarget` для key-only ZeroTree-маркеров — 60 из 117 коллекций именно такие;
- после этого `validate()` начинает реально проверять `IndexMissing`/`IndexOrphan`, и потребитель
сможет снять фильтр находок в CI-тесте.
**Эффект.** ~500 строк write-path + ~1 500 строк обёрток.
**Зависимости.** Нет. *Исправлено 2026-08-06:* прежняя запись «T7 (иначе работает на 4 коллекциях
из 117)» неверна — `secondary_index` не требует брендов вообще (`SV: CollectionMeta`,
`SV::SelfId: Key`), а `IndexMissing`/`IndexOrphan` считаются без единого FK. T7 и T8 независимы;
делать их вместе по-прежнему осмысленно, потому что оба чинят один и тот же нулевой adoption
schema-слоя. Сильно выигрывает от T5 — индекс должен обновляться в той же транзакции, что и
источник.
### T9. Движковые счётчики
**Статус: реализовано** (PR #140, 2026-08-10; armdb 0.10.0, ещё не опубликован). Спека:
[`docs/superpowers/specs/26-08-09-engine-maintained-counters.md`](../../docs/superpowers/specs/26-08-09-engine-maintained-counters.md);
миграция:
[`docs/breaking-changes/26-08-09-armdb-engine-maintained-counters.md`](../../docs/breaking-changes/26-08-09-armdb-engine-maintained-counters.md).
Проекция эмитирует **вклады** (`sink.add(&target, key, weight)`), движок диффит
`project(old)` vs `project(new)`, сводит в дельту на `(цель, ключ)` и применяет
её под локом цели; вся арифметика — в одной точке (`resolve`), которую зовут оба
пути записи и обе процедуры починки. Внутри `db.tx()` дельта разрешается в
абсолют и логируется обычной записью (формат журнала не меняется, exactly-once
достаётся из существующего правила replay); вне транзакции — буфер после снятия
лока источника плюс пересчёт при открытии после нечистого завершения.
`reg.counter` должен не только валидировать, но и **вести** значение: инкремент внутри той же
транзакции, что и запись источника.
*Решение 2026-07-30: берём.* Это осознанное расширение ответственности движка — armdb начинает
поддерживать производные данные, а не только их описывать.
**Зачем.** 5 отдельных коллекций-счётчиков + 7 деклараций `reg.counter`. Внутри одной коллекции
гонок нет (везде `update_fn`/`.atomic`), **все реальные расхождения межколлекционные**: счётчик и
его источник — две независимые записи, крэш между ними даёт дрейф. Отсюда `recount.rs` (1 010 строк,
11 функций), стартовый пересчёт 6 счётчиков полным сканом и метрика `db.counter_drift`.
С движковым счётчиком `groups.member_count`, `spaces.member_count`, `channels.subscriber_count`,
`invites.used_count`, `thread_meta.reply_count` перестают дрейфовать by construction.
**Эффект.** До ~800 строк (`recount.rs` + `media_audit.rs`); `db.counter_drift` уходит как класс.
**Зависимости.** T5 (инкремент обязан быть в одной транзакции с источником), T3.
### T10. Уникальный индекс с CAS-резервированием
**Статус: реализовано** (PR #142, 2026-08-12; armdb 0.10.0, ещё не опубликован). Спека:
[`docs/superpowers/specs/26-08-10-unique-index-cas-reservation.md`](../../docs/superpowers/specs/26-08-10-unique-index-cas-reservation.md);
миграция:
[`docs/breaking-changes/26-08-10-armdb-unique-namespaces.md`](../../docs/breaking-changes/26-08-10-armdb-unique-namespaces.md).
Уникальность становится **коллекцией**, которой владеет движок (`UniqueIndex<N>`, строка
`ключ → владелец`, публичного write-API нет), а декларация — третьей проекцией на том же
карриере: `sink.claim(&usernames, k)`. Проверка исполняется в коммите `db.tx()`, где
участники уже залочены в каноническом порядке; вне транзакции запись разрешена, пока
набор claim'ов не меняется, и отвергается **до применения** (`DbError::UniqueIndexRequiresTx`),
если меняется. Резервация (`tx.reserve(&ns, key, &source, &owner_key, ttl)`) — та же строка
владельца со сроком, поэтому «резервация → владение» попадает в обычную ветку «владелец — я»;
`tx.release(...)` идемпотентно её снимает. Восстановления после крэша нет и не нужно: строка
источника и её claim — одна запись транзакционного лога (T5's контракт без изменений,
`.durable()` всё ещё нужен для потери питания). Adoption и промоушен реплики — одна и та же
`schema::repair`: namespace открывается пустым, первый прогон его наполняет, а дубликат,
уже существующий в данных, всплывает как `Finding::UniqueConflict`/`NamespaceConflict` —
руками разобрать до включения enforcement. Полный контракт (декларация, гарантии, границы,
находки): [`docs/schema-metadata.md`](../../docs/schema-metadata.md#unique-namespace).
**Зачем.** Сейчас уникальность username/email/phone/handle держат `scc::HashIndex` в RAM +
глобальный мьютекс, а индекс восстанавливается **полным сканом коллекции при каждом старте**
(`NEEDS_INIT = true` в 8 хуках). Убирает 4 из 7 глобальных мьютексов и стартовый скан.
**Эффект.** ~150 строк + время старта.
**Зависимости.** T5 (`insert_unique` внутри транзакции) — закрыта.
---
## Волна 3 — событийные примитивы
### T11. Change stream / подписка на изменения коллекции
**Статус: реализовано** (PR #143, 2026-08-13; armdb 0.10.0, ещё не опубликован). Спека:
[`26-08-12-durable-queues-and-change-stream.md`](../../docs/superpowers/specs/26-08-12-durable-queues-and-change-stream.md).
Из двух вариантов ниже не выбран ни один в чистом виде: durable-лог с курсором
(A) и in-memory broadcast (B) оба отвергнуты в пользу **фан-аута на записи** —
проекция эмитит в N объявленных очередей (T12), потребитель ест свою очередь
через lease/ack, курсоров нет. Курсор не выражает ни ретрая, ни отравленной
строки, и требует тотального порядка, которого у коллекции нет (GSN непрерывен
на `(коллекция, шард)`). Change stream становится **четвёртой проекцией** на
карриере T8 — рядом с индексом (T8), счётчиком (T9) и claim'ом (T10).
**Зачем.** Эффект здесь — не строки, а класс ошибок. В msg-back шина событий построена целиком в
приложении: **142 конструкции `Event::…`** в non-test коде (69 в одном `dispatch.rs` на 7 895 строк),
**6 параллельных каналов** в `DispatchResult`, каждый write-path обязан вручную не забыть положить
событие в правильный вектор. Открытый баг ровно этого класса уже в бэклоге потребителя:
«Membership/ownership changes emit no events».
Частичный прецедент уже есть в движке: `TypedWriteHook` используется как «change stream на одну
коллекцию» (`tables/media.rs:59-98`) — но синхронный, in-process, не переживает рестарт.
**Два варианта — решать на brainstorming при планировании:**
| | A. Durable с курсором | B. In-memory broadcast |
|---|---|---|
| Курсор | durable, монотонный (GSN из репликации) | нет, только «с этого момента» |
| После рестарта | потребитель продолжает с места | пересканирует коллекцию или теряет события |
| Стоимость | высокая; сильно пересекается с T12 | низкая; фактически publish-обёртка над хуками |
| Что снимает | `persist_fanout_outbox`(91) + `notification_outbox`(116) + `outbox_sweeper`(190) + `search_outbox`(384) + `search/outbox.rs`(117) ≈ **900 строк** и 15-секундный свипер с полным сканом | класс «забыли отправить событие», но outbox'ы остаются |
Отчёт по msg-back настаивает на варианте A; вариант B дешевле и может быть промежуточным шагом.
**Зависимости.** Пересекается с T12 — решать вместе.
### T12. Очередь / лог-коллекция с lease, backoff и retention
**Статус: реализовано** (PR #143, 2026-08-13; armdb 0.10.0, ещё не опубликован). Спека:
[`26-08-12-durable-queues-and-change-stream.md`](../../docs/superpowers/specs/26-08-12-durable-queues-and-change-stream.md).
Очередь — коллекция, которой владеет движок (публичного `put`/`delete` нет,
только `push`/`lease`/`ack`/`nack`), ключ строки `(класс, visible_at, id)` —
это момент, раньше которого строку не видно, поэтому реклейм аренды не требует
ни свипера, ни таблицы аренд в памяти и переживает падение как есть. Две ветки
резидентности: фиксированная нагрузка инлайном на FixedStore (компакции нет) и
переменная — заголовок на Fixed + нагрузка отдельной строкой в Bitcask, чтобы
`lease` не переписывал тело.
Минимальный контракт: `push(payload, not_before)`, `lease(n, timeout) -> Vec<Entry>`, `ack(entry)`,
`nack(entry, backoff)`, `retention(duration)`, durable-курсор/lease, переживающий рестарт.
**Зачем.** Внешних брокеров в msg-back нет вообще (ни Redis, ни Kafka) — всё в armdb. При этом
**три из четырёх durable-очередей не имеют ни курсора, ни time-index** и полностью
пересканируются на каждом тике. Единственная, что построила `(timestamp, id)`-индекс —
`notification_jobs`, ценой **трёх дополнительных коллекций** (`jobs_due`, `jobs_terminal`,
`jobs_by_endpoint`) плюс коллекции дедупа (`jobs_dedupe`).
**Эффект.** ~800–1 000 строк и −3…−4 коллекции. Плюс исчезает класс комментариев-предупреждений
про `reversed: true` и `.rev()` перед `take_while`.
**Зависимости.** T6 (retention по времени), пересекается с T11.
### T13. Sorted merge и chunked pagination
**Статус: реализовано** (PR #144, 2026-08-16; armdb 0.10.0, ещё не опубликован). Spec:
[`26-08-13-merge-and-chunked-pagination.md`](../../docs/superpowers/specs/26-08-13-merge-and-chunked-pagination.md).
`merge_sorted` даёт ленивый k-way merge N уже упорядоченных префиксов, а
`page_chunks` / `Scope::page_by_chunks` делают один batch-prefetch на chunk и
сохраняют top-up. Курсор должен сужать каждый источник до merge; для
multi-prefix hydration нужен full-key `get_many`, а не range scan единственного
префикса.
### T14. Realtime watch — in-memory подписка на изменения коллекции
**Статус: реализовано** (PR #146, 2026-08-16; armdb 0.10.0, ещё не опубликован). Спека:
[`26-08-13-realtime-watch-subscriptions.md`](../../docs/superpowers/specs/26-08-13-realtime-watch-subscriptions.md),
документация: [`watch.md`](watch.md).
Вторая половина пары к T11/T12, а не альтернатива ей: durable-половина не имеет
права **потерять** событие, realtime-половина не имеет права **стоить** чего-либо,
когда никто не слушает. Отсюда все решения. `WatchHook<T, H, OLD>` — обёртка над
любым хуком с compile-time константой `WATCHES`, так что коллекция без watch'а
сворачивается на мономорфизации целиком, а коллекция с watch'ем без получателя
платит один `Acquire`-load. Доставка at-most-once, кольцо на подписчика с
вытеснением старейшего и явным `Lagged(n)` — «тихо потерять» нельзя по построению.
Рецепт потребителя — `watch()`, затем скан, и рескан по `Lagged` — держится на
одной гарантии регистрации: к моменту возврата из `watch()` каждая запись,
решившая не публиковать этому получателю, уже сделала свою строку видимой.
Обеспечивает это двухфазный gate участия (`ProducerPhases`): producer входит в
фазу до выборки счётчика подписчиков и выходит после публикации, а
регистрирующийся получатель переключает фазу и ждёт слива предыдущей. Ждать один
общий счётчик нельзя — под непрерывной записью `watch()` голодал бы.
**Границы.** Шесть типизированных managed-семейств; `LsmTree` отвергается на
открытии (`WatchUnsupported`); watched Map требует `Config::iterable(true)`
(`WatchRequiresIterable`) — иначе у рецепта нет половины про скан. Follower,
миграция и crash-replay не эмитят ничего. Порядка между ключами, между
коллекциями и границ транзакций нет: кому нужно — тому в T11/T12.
---
## Known behaviour (schema / RPC)
Deferred wire-visible fixes — preserved on purpose during the collection access
refactor (PR 3a). Spec:
[`26-07-31-collection-access-traits-and-adapters.md`](../../docs/superpowers/specs/26-07-31-collection-access-traits-and-adapters.md)
("Known behaviour left as is").
1. **Lenient RPC `range` on `LsmTree` / `VarTyped*`.** **Still open (2026-08-16).**
When a row fails to decode, `LsmIter` / VarTyped iterators log `warn!` and skip
the row (`lsm/iter.rs:40,80`; the policy is stated as deliberate at
`access/lsm.rs:130`). An RPC `range` can therefore return a short page with no
signal to the client. Fixing it is a separate wire-visible change.
2. **`VarTypedTree` RPC `count(true)` vs LSM.** **Still open (2026-08-16).**
VarTyped counts only decodable rows; LSM's equivalent counts keys so a corrupt
collection is not under-reported — `access/adapter.rs:405-410` keeps the split
explicit ("NOT `inspection_exact_count`"). The inconsistency looks like a bug
but correcting it is a second wire-visible change — sibling to the range item
above.
3. ~~`VarTypedTree::scan_keys` includes keys for undecodable values.~~ Resolved
in PR 3c: `Access::scan_keys` for `VarTypedTree` now walks the decoding
`range_bounds` iterator, matching the live lenient `range_keys` behaviour —
an undecodable row's key stays off the wire, same as before the seam. The
raw inner key walk (`as_inner().range_bounds`) remains available if a future
spec deliberately wants corrupt-row keys exposed.
---
## Открытые задачи по seam (PR #111)
### S1. Детерминированный CPU/contention-харнесс + замер всех семейств
**Статус.** Закрыто 2026-08-03. Причин оказалось две, независимые; обе найдены и
исправлены, обе замерены против pre-seam baseline. Подробности, provenance и
полный вывод: [`docs/ctx-seam-bench.md`](ctx-seam-bench.md).
**Первая причина.** Не форма шва. `Family::Locked` у map-семейств — кортеж
`(engine shard, index)`, а кортеж дропает `.0` раньше `.1`, поэтому шов отпускал
**внешний** мьютекс движка, продолжая держать внутренний индекс. До шва порядок
был явно LIFO в обоих контекстах (`drop(index); drop(inner);`). Корректность это
не задевало — порядок захвата не менялся, — но превращало одно пробуждение на
операцию в два: разбуженный поток брал shard движка и тут же парковался на
индексе. Отсюда и характерная сходимость обеих map-семей к ~1.05 мкс/оп при
восьми писателях независимо от кодека.
Инверсия была замечена при мердже шва и записана в `CHANGELOG.md` как безобидная —
по корректности верно, цена оказалась в пропускной способности.
**Фикс.** `Family::Locked` теперь обязан реализовать `ShardGuards`, чей impl для
кортежа освобождает внутренний guard первым; и `Owned::with_shard`, и teardown
всех `atomic()` освобождают через него, а не через `drop`. Порядок стал свойством
формы guard'ов, а не кода каждого семейства, — ошибиться не может ни одно
семейство, включая будущие. Два sentinel-теста в `armdb/src/ctx.rs` фиксируют
порядок детерминированно и падают при инверсии.
**Замер против pre-seam baseline** (`62e6134f`, 10 повторов, CPU 0-7):
| сценарий | было | стало |
|---|---:|---:|
| `typed_map/8` | +131.279% | **+4.009%** |
| `var_map/8` | +106.876% | **+0.184%** |
| `var_map/4` | +40.528% | **+0.215%** |
| `typed_map/4` | +25.162% | **+3.831%** |
| `const_tree/4` | +5.147% | +4.684% |
| `const_tree/8` | +1.213% | +6.638% |
| `typed_tree/4` | −0.331% | −0.639% |
| `typed_tree/8` | −3.827% | −4.422% |
**Второй, независимый дефект.** После правки порядка блокировок остался
воспроизводимый остаток на `const_tree/4` — самой тугой строке харнесса: +5.147%
и +4.684% в двух независимых прогонах при шумовом поле 0.252%. Это та самая
граница generic-замыкания, которую локализовало прошлое исследование. Конкретная
причина оказалась в **форме значения**, пересекающего границу: замыкание
возвращало `DbResult<(R, Option<F::Event<'v>>)>`, и этот агрегат оптимизатор
через дженерик-границу не сворачивал.
Исправлено out-параметром: событие уходит через `&mut Option<F::Event<'v>>`, а
замыкание возвращает `DbResult<R>`. Применено ко всем 66 call site; у `Ctx`
по-прежнему ровно один метод. `const_tree/4` после этого — **+0.191%** против
pre-seam (и +0.136% / −0.068% в двух более ранних прогонах той же правки).
В воспроизводимом прогоне семь строк из восьми проходят гейт; восьмая,
`const_tree/8`, проваливает и нулевой A/B.
**Вариант «seam-wide reshaping» снят.** Он был выбран потому, что регрессия
выглядела кросс-семейной; кросс-семейный паттерн оказался расслоением по числу
guard'ов, а оставшийся остаток вылечился сменой сигнатуры, а не переработкой
формы шва.
**Отдельная находка: харнесс не различает собственный гейт.** Нулевой A/B — один
и тот же коммит с обеих сторон — проваливает +3% на `const_tree/8` (+5.560%) и
`var_map/8` (+8.315%). Причины: восемь воркеров ровно на восьми закреплённых CPU
без запаса под драйвер, и две отдельные сборки в разных путях (различие раскладки
кода систематично внутри прогона и не усредняется повторами). Восьмипоточные
строки до устранения этого — совещательные. Направления в `ctx-seam-bench.md`,
пункт 4 раздела «What is still open».
Ограничение результата — CPU/no-flush; rotation-aware long-run view остаётся
отдельным измерением.
**Требования к харнессу.** Фиксированное число операций (не накапливающееся
линейное расписание Criterion); писатели стартуют один раз за повтор, без
барьеров между батчами; свежая база на каждый повтор; объём аппенда **ниже**
порогов флаша и ротации, чтобы флаш/ротация не попадали в замер вообще; вывод
всех повторов и медианы, прогоны чередованием pre/post на одном хосте; гейт —
падение медианы более чем на 3%.
**Почему нельзя мерить существующим `contended_shard`.** Bitcask append-only,
поэтому даже чистый overwrite растит файл; Criterion линейно повышает число
итераций на одной персистентной базе, и поздние сэмплы пересекают флаш и ротацию
256 MiB, а ранние — нет. Отсюда интервалы в два-три порядка у пяти конфигураций
из восьми. Подробности — в `docs/ctx-seam-bench.md`.
**Зависимости.** Нет. Полезно сделать до T3, потому что T3 — первая реальная
проверка того, что мутирующий метод пишется один раз (forcing function это не
доказала: `count_prefix` — чтение, а шов покрывает мутации).
### S2. Разрешение харнесса — восьмипоточные строки остаются совещательными
**Статус. Открыто** (проверено 2026-08-16). Хвост S1, вынесенный сюда явно, чтобы
не терялся внутри закрытого пункта: [`ctx-seam-bench.md`](ctx-seam-bench.md),
раздел «What is still open», пункт 4.
Нулевой A/B — один и тот же коммит с обеих сторон — проваливает гейт +3% на
`const_tree/8` (+5.560%) и `var_map/8` (+8.315%). Пока это не устранено, любая
восьмипоточная строка совещательна, и будущее утверждение вида «+4%» непроверяемо.
Два направления записаны, **оба не пробованы**:
- собирать обе стороны в **один и тот же путь** (собрать одну, отложить бинарь,
собрать вторую), чтобы раскладка кода перестала систематически различаться между
сборками — тот же механизм, что подозревался в записи журнала от 2026-08-05
(«каждый вызов `ab.sh` клонирует в новый `mktemp`-путь, путь входит в package id
и хеши символов»);
- оставить запас под драйвер — закреплять `threads + 2` CPU вместо ровно `threads`,
либо гнать воркеры из непосчитанной нити.
Смежные, тоже открытые: rotation-aware long-run view держать **отдельным**
измерением от CPU-гейта (пункт 5 там же), и `ab.sh` до сих пор не запускает два
контроля — пара с обменом сторон падает на сборке (журнал, 2026-08-05), то есть у
байтового гейта нет контроля со сменой порядка. Плюс issue
[#134](https://github.com/libfunc/armour/issues/134): суммаризатор A/B умирает до
вердикта, а write-группа не покрывает FixedStore.
**Зависимости.** Нет. Требуется до того, как какой-либо пункт роадмапа будет
защищаться числом на восьми писателях.
## Отложено
- **Consistent read snapshots / MVCC-итераторы.** Документы расходятся: GPT-5.6 ставит вторым
фундаментом после транзакций, Fable — явно против («snapshot поверх RCU + seize — большой класс
сложности за редкий кейс»). Держим открытым вопросом внутри дизайна T5.
- **Транзакционные constraints** — unique/non-unique составные индексы, FK с cascade/restrict,
check-constraints. Осмысленно только после T5 + T8.
- **`Table<T>`-фасад и curated-профили** — `EntityTable`, `CounterTable`, `DocumentTable`,
`BlobStore`, `TtlMap`, `TimeSeries`. Оба документа согласны, что это правильное направление
(«composable mechanisms internally, curated recipes publicly»), но строить фасад поверх
недоделанных примитивов рано.
- **Backup / checkpoint / restore / inspection tooling.**
- **Capability matrix в документации** + устранение дрейфа `armdb/docs/design.md`: там описаны
2 стратегии значений при 10 живых вариантах коллекций и устаревшая форма `DiskLoc`.
Оси: `Index: Ordered|Hash`, `Residency: InlineBytes|InlineTyped|DiskBytes|DiskTyped`,
`Durability: Bitcask|FixedStore`, `Encoding: Raw|Zerocopy|Codec`,
`Cache: None|Blocks|EncodedValues|DecodedValues`.
- **Merge-операторы (RocksDB-style).** Изначально предлагались Fable как отдельный примитив, но
замер не подтвердил: инкременты в msg-back уже идут через `.atomic`/`update_fn` в 13 коллекциях,
а не через ручной read-modify-write. Ближайший кандидат — коалесеры просмотров/реакций
(`transport/src/view_coalescer.rs:149-192`, ~560 строк), которые буферизуют дельты 1 с в памяти и
теряют их при падении. Покрывается T3 + T5.
- **Maintaining-хук для denormalized.** Симметрия с T8 напрашивается, но денормализаций 8 против
48 индексов — приоритет ниже.
## Не делаем
- **SQL-veneer / декларативный query-слой.** Чужая весовая категория: парсер, статистика,
cost-based планировщик, joins, covering-индексы, сортировка, временные результаты. Убьёт фокус.
Типизированные хелперы (T2, T13) покрывают ~80% реальных «SELECT WHERE».
- **Full-text и vector-индексы как built-in.** Лучше отдельными крейтами-потребителями armdb поверх
Var-коллекций. msg-back уже так и делает (tantivy).
---
## Журнал решений
| Дата | Решение |
|---|---|
| 2026-08-13 | **T13 implemented as sorted merge plus chunked pagination, not `join_page`.** `Scope::page_by` already tops up accepted rows. Single-prefix `*_for_page` work is a contiguous range scan, while merged multi-prefix pages need caller-owned full-key `get_many` inside chunk `prepare`; a fixed `join_page` cannot choose correctly. `PageSpec::max_examined` and `BudgetExhausted` preserve bounded forward progress. |
| 2026-07-30 | **`atomicN` removed (PR 1 of collection-contexts).** `Db::atomic2/3/4`, `MultiTx`, `SyncNeeds`, ten `*Tx` types gone. T5's API shape is no longer constrained by per-arity monomorphisation. Level-A prior art: `armdb/docs/cross-collection-transactions.md`. |
| 2026-07-30 | **Порядок волн.** msg-back-фичи вперёд, но транзакция (T5) проектируется параллельно, чтобы не переделывать write path позже. После замера потребителя T5 поднят из «фундамента на потом» в волну 1 — он же оказался самой дорогой болью (~2 000–2 400 строк recovery-кода). |
| 2026-07-30 | **Потолок T5** — зависит от формы API: N генерируемых `atomicN`-методов ⇒ потолок 16; API без per-arity методов ⇒ без жёсткого лимита. |
| 2026-07-30 | **T9 (движковые счётчики) — берём.** Осознанное расширение ответственности движка: armdb начинает вести производные данные, а не только валидировать. |
| 2026-07-30 | **T11 (change stream)** — оба варианта (durable с курсором / in-memory broadcast) записаны как альтернативы; выбор на brainstorming при планировании. |
| 2026-08-03 | **S1: две независимые причины, обе исправлены; seam-wide reshaping не нужен.** (1) Порядок освобождения блокировок: `Family::Locked` у map-семейств — кортеж, дропающий внешний guard первым; исправлено обязательным `ShardGuards`. (2) Форма значения на границе замыкания: `(R, Option<Event>)` заменён out-параметром на всех 66 call site. `const_tree/4` +0.191% против pre-seam при прежних +5.147%; семь строк из восьми проходят гейт. |
| 2026-08-03 | **Гейт +3% ниже разрешения харнесса на 8-поточных строках.** Нулевой A/B (один коммит с обеих сторон) проваливает гейт на `const_tree/8` и `var_map/8`. До устранения эти строки совещательные; направления записаны в `ctx-seam-bench.md`. |
| 2026-08-03 | **T3 redefined: one generalized `upsert` instead of seven named scalar operations.** Not one of the 18 live sites in `msg-back` stores a bare scalar — a "maximum" compares a newtype field and an "increment" rewrites two neighbouring fields in the same write. The named list would need a projection closure per operation and would cost up to 154 methods (7 × 11 × 2). One closure `Option<&T> -> put/keep/delete` covers all 18. |
| 2026-08-03 | **RPC `upsert` renamed to `write`.** The name is needed for the collection operation; the RPC one means "write with an optional precondition". Wire bytes unchanged (`OpCode::Write = 0x10`), no consumers outside the workspace. The rename spans **two** RPC stacks — `armdb`'s collection stack and the legacy `armour/src/rpc` server + typed client — and excludes `armour`'s own collection-level `upsert`, which is a different API. `armour-rpc` 0.4.0. |
| 2026-08-03 | **Fused Const/Zero path is gated on measurement, per body.** The generic body over `HeldOps` is cost-identical to today's `atomic()` sites. Fusing removes the separate `try_get` — one traversal fewer in each writing case, none on `Keep` — and is **not** single-pass: `SkipList::insert`/`remove` each run their own `presearch`. `ConstTree` and `ConstMap` are gated independently, and only after the A/B harness gains 1-writer rows, a `const_map` scenario, and an absent-`Put`/`Delete`/`Keep` decision mix. |
| 2026-08-03 | **SkipList frame reuse deferred, not rejected.** `presearch` + `insert_with_frame` / `remove_with_frame` already implement the staleness validation and fallback needed to reach one traversal everywhere; they are private to the module, so this is a visibility change rather than a new concurrency model. Kept out of T3 as a separate hypothesis with a separate measurement — written up as **T2d** / issue #126 on 2026-08-05, once T3's decision-mix scenarios made it measurable. |
| 2026-08-03 | **LSM hook delivery recorded as a pre-existing exception.** `LsmTree::atomic` holds the stripe lock across the closure and fires `on_write` inline, contradicting the `TypedWriteHook` contract that promises buffered replay after unlock. Independent of T3, which inherits the behavior unchanged; the LSM test asserts the actual behavior and the divergence gets its own issue. |
| 2026-08-03 | **ConstTree fused upsert landed.** A/B (`4cb2a524` generic vs fused): все decisive `write/upsert_const_tree` × {present_put, absent_put, present_delete, keep} × {1, 4} — улучшение или ≤+3%; крупнейший выигрыш present_put/1 −30.5%. Сырой отчёт прогона не коммитился; сводка — в описании PR #121. |
| 2026-08-03 | **ConstMap fused upsert not landed.** A/B (`4cb2a524` generic vs `592a206e` fused, 6 reps, CPUs 0–7): 7/8 decisive rows PASS (−5%…−12% на writing cases); `keep/1` **+4.32%** regression (>+3% noise floor). Generic `atomic` + `upsert_via_held` остаётся. |
| 2026-08-04 | **RPC conditional write остаётся existence-only.** Промежуточный коммит перевёл ветки `flag: Some(false\|true)` на `upsert_via_held`, который декодирует значение и превращает present-but-undecodable строку в `CorruptedEntry` вместо `KeyExists`/`KeyNotFound`. Откат к `try_contains` + `put` под одним `atomic_at`: это точное до-PR поведение, т.е. PR не меняет клиентский контракт RPC вообще; decode-first вдобавок сделал бы самую дешёвую преконду самой дорогой на `VarTyped*`/`Lsm` (чтение + кодек вместо попадания в индекс). Асимметрия закреплена тестами: insert-only не трогает битую строку, update-only её заменяет. Спека (`26-08-03-atomic-value-upsert.md`, «First in-repo consumer») описывает несостоявшийся вариант со сворачиванием обеих веток в `upsert`. |
| 2026-08-04 | **A/B harness детектит upsert-API по исходнику, а не по Cargo-фиче.** Маркерная default-фича `upsert-api` не гейтила ни строки кода, но навсегда попадала в публичный список фич и врала о поверхности API, а её переименование молча выключило бы upsert-сценарии (при зелёном `PASS`). Заменено на `git cat-file -e HEAD:armdb/src/upsert.rs` в `ab.sh`; `--cfg armdb_ab_skip_upsert` прокидывается только в example через `cargo rustc -- …`. Отдельно закрыты два дефекта: Python-репортёр не был согласован с bash-гейтом (16 строк `INCOMPLETE` при `whole_run_verdict: PASS`), и `RUSTFLAGS=""` подавлял `target.*.rustflags` из `.cargo/config.toml` (clang/lld) на **каждом** прогоне — возвращён `env -u RUSTFLAGS`. |
| 2026-08-04 | **T4 gate split in two.** "Benchmark before implementation" is not literally satisfiable — the third variant needs a prototype. Task 1 measures the *ceiling* without one: `TypedTree::get` is a proxy for a hot decoded hit, so `VarTypedTree::get − TypedTree::get` bounds the win. Kill threshold: ceiling < 20% of hot `VarTypedTree::get` in every size class. The merge decision stays with the existing A/B harness. |
| 2026-08-04 | **T4 surface: `get_shared` / `try_get_shared` / `get_many_shared`, VarTyped only.** `get_many` exists on the Tree family only, so the batch form is Tree-only. `LsmTree` is excluded: fjall exposes no physical value version, so its cache would need `K`-keying plus invalidation — a different design. |
| 2026-08-04 | **Both caches keep independent budgets.** A decoded miss reads and populates `value_cache` exactly as a byte read does; an open-time warning flags the double-caching of value-cache-routed values and points at `value_cache: 0`. Making the decoded path stop populating the byte cache, or making the two mutually exclusive, are behavioural couplings that cannot be judged without the numbers Task 1 and the final A/B will produce. |
| 2026-08-04 | **A parallel `read_decoded` rather than a generalised `read_value_cached_result`.** Generalising the byte read path over its output type is the exact change S1 had to undo (+5.147% on `const_tree/4` from a value shape crossing a generic closure boundary). ~35 duplicated lines per family buy an untouched byte path, so the final A/B measures only new code. |
| 2026-08-04 | **No coalescing in v1.** Without it the cache is never worse than today: every read decodes today anyway, so a race costs the status quo for the window before the first insert. No Zipf multi-reader scenario exists to justify the placeholder machinery — the reason T3 did not land fused `ConstMap` either. |
| 2026-08-04 | **Weight is the encoded length, with no multiplier — but never zero.** `len × m` under budget `B` equals `len` under `B/m`, so a multiplier adds a knob without expressive power. The documentation states the 1.5–3× heap factor instead; exact accounting waits for a `MemSize` trait. The weight floor of 1 is not cosmetic: `quick_cache` ignores zero-weight items when choosing eviction candidates (`lib.rs:169-171`), and this cache has no removal path, so a legal zero-byte encoding would make it unbounded. |
| 2026-08-04 | **`Arc` identity is opportunistic, not contractual.** The first draft promised that two `get_shared` calls without an intervening write return the same `Arc`. Eviction and the deliberately non-coalesced cold-miss path both break that with no write involved, so the promise is withdrawn: the invariant is value correctness, and the test asserts "a hit does not decode" via a counting codec, with pointer equality checked only under written-out residency preconditions. |
| 2026-08-04 | **T4 ships an engine primitive with no consumer on merge.** `msg-back` reads through `get`/`get_or_err` and projects from an owned `&T`, so nothing there can call `get_shared` until its wrappers and point-read projections change — work that lives in another repository. The spec therefore claims a measured *ceiling*, not a realised CPU saving; adoption is a named follow-up with its own measurement. |
| 2026-08-04 | **Send-path evidence corrected.** T4's "sending one message decodes it three times" counted the write, which is an *encode* — and these collections use `NoHook`, so `VarTypedHookAdapter::on_write` returns before decoding (`hook.rs:194-196`). The `dispatch.rs:2159` citation is the forward path (`dispatch_forward_forum`); normal send reads `message_view` at `dispatch.rs:959`. The motivation is repeated point reads of the same rows, not a per-send decode count. |
| 2026-08-04 | **T4 ceiling measured** ([`decoded-cache-ceiling.md`](decoded-cache-ceiling.md)). `ceiling %` (string/numeric, 256 B/4 KiB/32 KiB): 67.05/50.56, 85.10/87.72, 94.57/97.49; `decode-only share`: 52.20/48.16/94.78 and `[18.04%, 50.56%]`/57.61/96.41 — tightest cell `numeric/256 B` is bracket `[18.04%, 50.56%]`, not a single end. Verdict: **Build** (gate rule is on `ceiling %`; every cell clears 20%). |
| 2026-08-05 | **T4 realised win измерен на парном сценарии; прежние 50% были из разных путей вызова.** `read/var_typed_tree/get` идёт через RPC-хендлер, `get_shared` — прямой вызов, поэтому их разность приписывала кешу стоимость хендлера. Добавлен `read/var_typed_tree/get_direct` с общей setup-функцией; на 16-байтовом значении и полностью прогретом кеше **764 → 214 ns/op (≈72%)**, воспроизводимо. Больше потолка Task 1 потому, что `get_shared` пропускает и байтовый ярус, а `ceiling %` изолирует только кодек. |
| 2026-08-05 | **Байтовый гейт неинформативен на диффе такого размера — вывод держится на идентичности кода.** `merge-base → HEAD` даёт FAIL 4/4 (`var_typed_tree` scans +1.8…+6.5%), но: self-check на одинаковом SHA — PASS в пределах ±2.13%; вся промежуточная цепочка (Task 2 / Task 4 / **Task 7 — поле и весь API** / Task 8, и Task 7 → HEAD дважды) — PASS; `typed_tree`, чей исходник побайтово одинаков с обеих сторон, ходил до −5.68%. 0% + 2% ≠ 6.5%, значит хотя бы одно из чтений — измерение, а не код. Вероятный механизм — кодогенерация: каждый вызов `ab.sh` клонирует в новый `mktemp`-путь, путь входит в package id и хеши символов. |
| 2026-08-05 | **T5 спроектирован: `db.tx()` — builder операций, no-steal redo-лог, group commit.** Спека: [`26-08-05-crash-atomic-multi-collection-tx.md`](../../docs/superpowers/specs/26-08-05-crash-atomic-multi-collection-tx.md). Форма поверхности выбрана против замыкания с объявленными участниками: `upsert` (T3) и `guard` уже переносят read-modify-write и предусловия под лок, поэтому замыкание покупало бы только динамические ключи — ценой гетерогенного списка участников, `seize`-гуарда через них и owned-поверхности чтения. |
| 2026-08-05 | **Durability у T5 — опция на транзакцию (`durable()`), не всегда.** Без fsync лога гарантия покрывает kill процесса (page cache жив), но не потерю питания; контракт говорит это прямым текстом. Дефолт `false` совпадает с сегодняшним write path, который не fsync'ит вовсе. Group commit — в v1: no-steal ставит fsync **внутрь** критической секции, без группировки горячий шард упирается в ~2–5k tx/s и `durable()` становится декоративным. |
| 2026-08-05 | **Replay при открытии коллекции, а не по явному вызову.** Гарантия, зависящая от того, вспомнило ли приложение вызвать `replay_transactions()`, — не гарантия. Цена: запись живёт, пока какой-то участник не открыт (ограничено местом в логе, replay идемпотентен). Чекпоинт fsync'ит **все** открытые коллекции, а не только участников: хук пишет в неучастника, и обрезка по durability участников оставила бы hook-backed индекс рассинхронизированным навсегда. |
| 2026-08-05 | **Правило replay различает «не применилось» и «применилось, потом перезаписали».** Bitcask: непрерывный блок GSN на `(collection, shard)`, replay при `base+i > durable_recovered_gsn`; установка watermark'а перестаёт быть под `#[cfg(feature = "replication")]`. FixedStore: `(slot, target_version)`, резервируемые на eval (слот для отсутствующего ключа аллоцируется заранее и освобождается `write_discard` при аборте), плюс recovery-карта, включающая удалённые слоты. Fixed идёт отдельной фазой после зелёного Bitcask; формат записи несёт оба version-тега с самого начала. |
| 2026-08-05 | **T5 после ревью: эффекты хуков вынесены за crash-гарантию.** Правило replay пропускает операцию, которая уже легла на диск, — а это ровно тот crash, где недостаёт хука; зажечь хук для пропущенной операции нельзя (`WriteHook` получает `(old, new)`, в логе только финальное значение, старое могло быть скомпактировано, идемпотентность публичных хуков не гарантирована). Хуки сохраняют сегодняшнюю гарантию — не лучше и не хуже. Следствие: чекпоинт fsync'ит **участников**, а не все открытые коллекции, и ему нужен новый `Collection::durable_flush` (ни `periodic_flush`, ни `flush`, ни `close` не являются fsync-барьером для Bitcask). Crash-safe производные данные становятся фичей T8 (проекция внутри батча). |
| 2026-08-05 | **T5: участие FixedStore замерено по msg-back, а не предположено.** Fixed-коллекций **15**, а не 13 как в документе от 2026-07-30 (добавились `task_session_links`, `agent_active_sessions`); все 15 открыты через `open_zero_tree_fixed`/`open_zero_map_fixed`, то есть исправленная managed-матрица (Fixed только у `Zero*`) покрывает потребителя целиком. **13 из 15 пишутся в той же функции, что и Bitcask-коллекции** (`messages.rs:1132-1135,1211-1216,1273-1276,1346-1348`, `forum.rs:195-239`, `members.rs:36-39`, `tasks/assignment.rs:169-201`). Отдельно найден случай Fixed+Fixed без Bitcask вообще (`acks.rs:126-165`). Вывод: граница фаз Bitcask/Fixed — граница риска, а не отгружаемый v1: без Fixed транзакция не покрывает ни `send_space_text_message`, ни создание форум-поста, ни назначение задачи. |
| 2026-08-05 | **T5 после ревью: три фактические ошибки спеки исправлены по живому коду.** (1) Managed-матрица — шесть семейств, Fixed только у `Zero*`: `TypedTree`/`TypedMap`/`VarTree`/`VarMap` не generic по `Durability`, а `Const*`/`Var*` вообще не открываются через `Db`. (2) FixedStore: версии слотов 30-битные модульные (`is_newer`), порванная запись восстанавливается как FREE **с той же версией**, `write_discard` — это запись (бамп версии + pwrite + replication delete), поэтому аборт освобождает in-memory резервацию, а apply пишет зарезервированную meta точно. (3) Crash-тесты: `Db::drop` → `shutdown` → `close`, `Shard::drop` флашит — «drop без close» был graceful shutdown; нужен hard-exit в дочернем процессе плюс failpoint'ы и проверка порядка fsync. |
| 2026-08-05 | **`ab.sh` не запускает два контроля (не починено).** Пара, у которой *новая* сторона предшествует `armdb/src/decoded_cache.rs` (обмен сторон местами, self-check на merge-base), падает на сборке: `rustc` компилирует текст git-ошибки `fatal: path … does not exist in 'HEAD'`. 3 попытки из 3; те же ревизии в обратном порядке собираются. `</dev/null` на обеих сборках не помогает — вектор неизвестен. Практический эффект: у байтового гейта нет контроля с обменом сторон. |
| 2026-08-05 | **TTL Var cold build affordable at 100k; Task 6 sidecar deferred.** Debug build on this host: ~398 ms to walk + pread + decode 100k `VarTyped` rows at reopen (`ttl_var::var_cold_build_timing_100k`). Cold pass is acceptable for expected Var TTL collections; `ttl.idx` sidecar (Task 6) deferred unless production profiles show worse on release + direct_io. **Caveat (PR #129 review):** the timed span includes `Db::open_test` + recovery + hint replay and has no TTL-off control, so ~398 ms is an upper bound on the build, not a measurement of it — which only strengthens the deferral. |
| 2026-08-06 | **The record nonce is `tx_id`, not `(segment, offset)`.** A checkpoint with no survivors truncates the active segment to offset 0, so positions repeat under one key; `tx_id` comes from a durable ceiling and never does. It also makes copy-forward re-sealing byte-identical, which is what "copy the sealed record verbatim" needs. |
| 2026-08-06 | **The CRC stays under the GCM tag.** The CRC is what tells a legal torn tail from a corrupt record — the scanner needs that verdict before it can ask the cipher — so the tag is added, not substituted. |
| 2026-08-06 | **Apply uses `write_slot_with_meta`, not the follower path.** `apply_foreign_slot*` emits no replication event, so routing a leader-side transaction through it would silently desynchronise replicas. |
| 2026-08-06 | **A FixedStore delete of an absent key logs `slot_id = NO_SLOT`.** The operation is a legal no-op with no slot to name; a sentinel keeps the record format unchanged. |
| 2026-08-06 | **T7 спроектирован: цель ссылки — форма ключа, целей может быть несколько.** Спека: [`26-08-06-armdb-branded-composite-keys.md`](../../docs/superpowers/specs/26-08-06-armdb-branded-composite-keys.md). Семь решений: (1) авто там, где сборка вынуждена, декларация там, где нет; (2) `DuplicateEntityBrand` удаляется, проверка — «существует хотя бы в одном доме»; (3) скоуп — ближайшая структура/вариант/элемент, без наследования из ключа строки; (4) джойн по форме (мультимножество брендов), а не по типу ключа — тип различал бы `SpaceMemberKey` и `JoinRequestKey`, но на форме `[chat, msg]` кандидатов четыре, и авто не сработало бы там, где массовость; (5) группировка полей — derive-атрибут (компайл-тайм проверка, наследуется через вложенность, не называет коллекцию), выбор конкретной цели — прежний `reg.fk`; (6) «собрать нельзя, декларации нет» — error-находка `AmbiguousRef`, статическая, один раз на (коллекция, скоуп, форма); (7) «id в двух домах» — вне v1 (дома одной формы не всегда взаимоисключающи: `space_members`/`space_join_requests`). Замер по `msg-shared`: 81 ключ из 102 различим и деклараций не требует, 12 требуют. |
| 2026-08-06 | **T7 после ревью: четыре контрактных дыры закрыты, релизная цель — `0.9.0`, а не `0.8.0`.** [Ревью](../../docs/superpowers/reviews/26-08-06-armdb-branded-composite-keys-gpt-5-codex-review.md). (1) Обещанная находка «дома расходятся в порядке полей» неисполнима: два дома формы `[space, msg, msg]` дают идентичный срез брендов, а расходятся в семантике слотов; триггер заменён на «повтор бренда **и больше одного дома**», который выводится из брендов. Пути компонентов, предложенные ревью, делают расхождение видимым, но не разрешимым (нет межтипового понятия «тот же слот»), а исход тот же — `reg.fk`. (2) Проверка декларации требовала `KeyShape` и отвергала `Option<MsgId>` — ровно тот пример, ради которого вводился `ref_with_key`; сторона источника выделена в `IdLeaf` (наблюдать id, возможно отсутствующий), `KeyShape` остаётся стороной цели (конструировать ключ). (3) Модель скоупов теряла `RefIn`, на котором живой валидатор строит подавление само-ссылки; `RowPart` протащен в план, находки и их идентичность, подавление ограничено корнем ключа. (4) `contains -> bool` смешивал отказ сборки с отсутствием строки (`Option<bool>`, как у живого `ContainsAnyFn`), а `ids: Vec<u64>` не описывал небрендовую цель явного `fk` (`RefDesc::{Assembled, Explicit}`). Плюс: одно ребро на (источник, **ссылку**, дом), `Option<T>::refs()` перестаёт добавлять `Elem`, и версии четырёх опубликованных крейтов — `armour-typ 0.5.0` → `armour-core 0.5.0` → `armour-derive 0.29.0` → `armdb 0.9.0`. |
| 2026-08-06 | **Порядок полей ключа как сигнал матчинга отвергнут по коду.** `ForumGroupMentionKey [space, chat, post_id, msg_id]` и `ForumGroupMentionByMsgKey [space, chat, msg_id, post_id]` — одна и та же ссылка, обратный порядок (второй индекс существует ради другой сортировки). Позиционное правило спарило бы одну из двух наизнанку и молча. Отсюда: сопоставление только по брендам, а порядок полей используется ровно в двух местах — `from_ids` целевого дома и части объявленной группы с повторяющимся брендом. |
| 2026-08-07 | **T7 после ревью реализации: шесть дефектов матчинга, из них четыре — тихие.** Три ревьюера на PR #136. Тихие: (1) корень ID-типа помечался `PathSeg::Index(0)`, потребитель этот сегмент срезал — неотличимо от поля 0 безымянной структуры, поэтому ссылка из tuple-структуры/варианта не находилась в плане и не проверялась вовсе; `RefVisitor::id` теперь принимает `Option<PathSeg>`, `None` = корень, коллизия невыразима. (2) Флаш при `leave(Elem)` сравнивал скоуп на равенство — всё, что глубже элемента `Vec` (вариант enum, вложенная структура), не проверялось. (3) `ref_with_key` требовал ровно один ключевой компонент нужного бренда, поэтому правило «двойной бренд на стороне ключа → неоднозначность» было недостижимым кодом, а декларация просто выбрасывалась (оставался только warning). (4) Одно-компонентный `ref(...)` планировался и декларацией, и авто-проходом — дубли находок и рёбер графа. Шумные: (5) проверка «бренд присутствует» вместо мультимножества собирала дом `[space, msg, msg]` из одного `msg`, дублируя id в два слота, — ложный `DanglingFk` на согласованной строке; (6) неоднозначность выписывалась до подавления собственной формы, поэтому дом с повторяющимся брендом в своём ключе обвинял сам себя, и `register_entity(&forum_messages)` ронял бы CI в первый же день. Общий корень (1) и (2) — расхождение статического пути из `refs()` с путём обхода `visit_refs`; закрыто тестом на совпадение обоих каналов в обоих крейтах. Релизное: порядок публикации начинался с `armour-typ`, который зависит от `armour-derive` обычной (не dev) зависимостью — `cargo publish` не разрешил бы; и в план не входили `rapira`/`armour-rpc`/`armour`, которые несут уехавшие типы в публичных сигнатурах, поэтому downstream затащил бы в один граф две линии `armour-typ`. |
| 2026-08-09 | **T9 после ревью: пять блокирующих дыр в контрактах восстановления и выбора цели; релизная цель — `0.10.0`, а не `0.9.0`.** [Ревью](../../docs/superpowers/reviews/26-08-09-engine-maintained-counters-gpt-5-codex-review.md). (1) `DbInfo.clean` не является корректным срезом: `close()` документированно допускает записи после возврата, пишет `clean = true` несмотря на накопленные ошибки флаша и делает чекпоинт **после** пометки — вместо починки общего пути завершения T9 заводит собственный per-collection `counters_dirty`, который ставится на **пути записи** (ленивая персистентность раз в сессию через `Weak<Persist<DbInfo>>`, снятие только при успешной финализации), поэтому запись после `close()` просто ставит флаг заново. (2) «Нет строки цели → no-op» убирало не только `chat_type_index.get`, но и единственный живой сигнал `KeyNotFound` о потерянной родительской строке; заменено на явную `OnMissingTarget::{Error, Skip, Create}` плюс группы `one_of` с кардинальностью «ровно один держатель» — ловится и «ни одной», и «обе». (3) У флага не было владельца: путь проекции (`ctx.rs:134-138`) не видит `Db`, а `set_projections_dirty` достижим только из `open_managed`; шов теперь связывается при managed-открытии, а карриер со счётчиками на `open_hooked` отвергается. (4) Миграция цели не доживает до открытия источника (`mutated_any` отбрасывается, версия перезаписывается сразу) — evidence персистится на самой мигрировавшей коллекции, источник смотрит флаги своих целей. (5) Насыщение в нуле консервирует дрейф через чистое закрытие — вся арифметика переведена на checked `i128`, underflow/overflow валит операцию и метит источник грязным, `Delete` легален только при нуле. Плюс: гарантия сужена до пишущего узла (репликация поколлекционная, промоушн пересчитывает), и adoption у потребителя оказался шире «удалить инкременты» — два цикла открытия (`InvitesHook`/`SpaceInvitesHook` захватывают источник) и три инициализатора счётчиков (`member_count = 1 + others.len()` и т.п.), которые обязаны стартовать с нуля. |
| 2026-08-09 | **T9 спроектирован: вклады вместо строк, гибридная гарантия, один источник на счётчик.** Спека: [`26-08-09-engine-maintained-counters.md`](../../docs/superpowers/specs/26-08-09-engine-maintained-counters.md). Пять решений: (1) движок ведёт поле там, где оно уже лежит, — альтернатива «агрегат в памяти на источнике» (`members.count_of(chat)`) сильнее по гарантии и дешевле по write path, но это другой пункт роадмапа (инкрементальный `count_prefix` из T1) и правка DTO/wire у потребителя; (2) гарантия гибридная — exactly-once внутри `db.tx()` (дельта разрешается в абсолют под локом цели, формат журнала не меняется), буфер + пересчёт при открытии вне транзакции; неявная транзакция на каждую запись отвергнута, потому что после adoption T5 потребитель и так пишет источник транзакционно; (3) арифметику владеет движок (`resolve`), форму строки — приложение, поэтому `validate`/`repair`/write path сравнивают одну величину; отсутствие строки цели = `Keep`, что выражает две цели от одного источника (`groups`/`channels`) без `chat_type_index.get` перед каждым инкрементом; (4) один карриер с двумя проекциями (`Indexed` → `Projected` + `and_counters`), а не цепочка хуков (потребовала бы визитор целей в публичном трейте) и не единый sink (одно замыкание на две разные декларации); (5) один источник на пару (цель, поле) — с одним источником сумма скана и есть абсолют, что и делает пересчёт при открытии корректным без второго режима. Плюс: план в `ProjectionIncomplete` начинает нести остаток (дельта неидемпотентна), `DbInfo.clean` перестаёт быть под `feature = "fjall"`, пересчёт обходит **цель**, а не только источник (опустевшая группа обязана получить ноль). |
| 2026-08-10 | **T10 после ревью: две «блокирующие» находки стояли на неверной посылке, семь взяты как есть.** [Ревью](../../docs/superpowers/reviews/26-08-10-unique-index-cas-reservation-gpt-5-codex-review.md). Неверные посылки: (1) «условной стадии разрешения под локом цели не существует» — её построил T9, `resolve_counters` (`projection_commit.rs:412-441`) идёт после `eval_rank1` под локами целей и читает **сначала оверлей транзакции, потом хранилище**; claim садится туда же соседом. (2) «repair потребует публичной мутации namespace» — repair пишет через приватный `put_bytes` (`repair.rs:143,185`); настоящая опасность в другом месте — `ProjectableIndex` объявлен `pub` с публичными `put_bytes`/`delete_bytes` (sealed только для реализации), поэтому namespace его **не реализует**, и генерический `reconcile_carrier_projections`, удаляющий по expected одного источника, до него не достаёт. Ядра обеих взяты: claim — intent-слот (проекция исполняется **до** локов целей), порядок «releases раньше claims» (без него передача имени A→B в одной транзакции отвергает сама себя), перечень внутритранзакционных случаев, union-починка по всем источникам namespace. Взяты как есть: контракт крэша заменён на T5 (`durable_by_default = false`, `TxFailedAfterLog` не ретраится, replay при переоткрытии); модель накопления карриера и установки хуков; проверка длины владельца перед `from_bytes` (у `Key::from_bytes` только `debug_assert`) и `CollectionMeta: GetType` в макросе; проверка коллизии тега — на декларации, а не в `finish()`; политика перевода `Duration` в абсолютный дедлайн; инвентарь потребителя — **восемь** аллокаций `HashIndex` на шесть источников, плюс гейт `msg-back`/`msg-shared` `"0.9"` → `"0.10"`. Половина отклонена: компайл-тайм-предел 56 байт выразим и так — `Key: Copy` ⇒ `Sized`, `size_of::<S::Key>()` const-вычислим. |
| 2026-08-10 | **T10 спроектирован: уникальность — коллекция, которой владеет движок; проверка внутри `db.tx()`.** Спека: [`26-08-10-unique-index-cas-reservation.md`](../../docs/superpowers/specs/26-08-10-unique-index-cas-reservation.md). Шесть решений: (1) точка проверки — коммит транзакции, потому что обычный путь записи коммитит источник **до** проекции (`ctx.rs:132-137`) и отказать не может; двухфазный резерв покупает нетранзакционную запись ценой окна, запирающего имя навсегда, а вложенный лок — ценой второго порядка захвата навстречу каноническому; (2) вне транзакции запись разрешена, пока набор claim'ов не меняется (у потребителя 62 места записи в шесть источников, уникальное поле меняют около дюжины) — проверка до применения, поэтому отказ настоящий; (3) владельца пишет движок, а не приложение: `UserID` и `ChatId` оба по 8 байт, и численное совпадение молча дало бы двух владельцев одному username; (4) namespace — тип коллекции **без публичного write-API**, иначе `idx.put(k, чужой)` компилируется; (5) FixedStore + хеш-индекс: строка фиксированная по построению, Bitcask сделал бы коллекцию вечным участником компакции, а его страничное шифрование бесполезно, раз сам `users` не шифрован; (6) владелец резервации — **будущий ключ источника**, поэтому перехват попадает в обычную ветку «владелец — я», а срок ограничивает утечку. Плюс: аналога `counters_dirty` нет — обе записи в одном коммите; adoption, промоушн и обслуживание — одна процедура `repair`. |
| 2026-08-12 | **T11+T12 спроектированы одной спекой: очередь — коллекция движка, change stream — проекция в неё.** Спека: [`26-08-12-durable-queues-and-change-stream.md`](../../docs/superpowers/specs/26-08-12-durable-queues-and-change-stream.md). Шесть развилок: (1) T11 и T12 — один механизм, а не два пункта: после волны 2 «durable-лог с курсором» это и есть очередь, а lease/ack — то, чего курсору не хватает; (2) потребление внутрипроцессное, но формат строки фиксируется контрактом, чтобы RPC-подписка добавлялась без правки диска; (3) объявленная проекция **и** `tx.push` — первая снимает «забыли отправить», второй нужен для работы, которая не является диффом ни одной строки; (4) фан-аут на **записи** в N объявленных очередей, а не на чтении курсорами: все четыре durable-очереди потребителя work-queue по форме, у курсора нет per-entry ack, а тотального порядка нет (GSN непрерывен на `(коллекция, шард)`); (5) ось — не `Durability`, а резидентность значения: FixedStore не умеет переменные значения вообще, поэтому ветки «инлайн на Fixed» и «заголовок на Fixed + нагрузка в Bitcask», и вторая обязана быть расщеплённой, иначе append-only переписывал бы нагрузку на каждом `lease`; (6) ожидание — блокирующее с самотаймингом плюс `Future` на `core::task::Waker`, обслуживаемый одной **лениво** поднимаемой нитью на `Db`. Плюс: ключ строки *есть* дедлайн видимости (реклейм без свипера и без таблицы аренд, переживает крэш сам собой), токен аренды несёт эпоху (опоздавший `ack` не подтверждает чужую попытку), id из существующего `SeqGen` блоками по 1024 (переиспользования нет — от него зависит токен), и объявленный источник **обязан** писаться в `db.tx()` (`ChangeStreamRequiresTx`): вне транзакции проекция идёт после снятия лока, а потерянное событие, в отличие от счётчика T9, пересчитать не из чего. |
| 2026-08-16 | **Сверка роадмапа с кодом: T1 и T2b зафиксированы как не начатые, T4 — как закрытый.** Прежние статусы расходились с крейтом в обе стороны. T4 стоял как «Спека», хотя `armdb/src/decoded_cache.rs` вмерджен PR #125 ещё 2026-08-05. T1 и T2b не имели статуса вовсе, и их отсутствие читалось как «не дошли руки записать», а не как «не сделано»: `delete_prefix`/`retain_prefix`/`count_prefix` в крейте нет (прототип `count_prefix` из PR #111 был forcing-function шва и удалён перед мерджем), backward finger нет — `skiplist/iter.rs:67,167` по-прежнему зовёт `find_last_lt` с головы. Плюс: открытый хвост S1 (харнесс не различает собственный гейт) вынесен из закрытого пункта в отдельный **S2**, а раздел про бамп `0.8.0` заменён на актуальный — `0.10.0` собран, но `CHANGELOG` держит его `Unreleased`, поэтому гейт потребителя `"0.9"` → `"0.10"` пока неисполним. |
| 2026-08-05 | **T6 review (PR #129): the deadline wipe was a divergence between copies, not a design gap.** `TypedTree`/`TypedMap::migrate` passed a bare `None` into `put_locked_inner`, which `TtlIndex::record` reads as "forget this key"; the other four families recomputed it. Open-time migrations were safe by construction (index built after), so only the **public post-open** `migrate` leaked — and no test covered it. Fixed structurally with `TtlAt::{Precomputed, Derive}` rather than by patching two call sites: a caller must now state intent. Second finding of the same shape: `TtlSweepGate` used Release/Acquire across two atomics (store-buffer litmus), so `set_ttl_sweep(false)` could return while a pass was starting — the plan had specified `RwLock<()>`, which has no such hole. |
| 2026-08-20 | **Стабилизация перед продом: восемь issue закрыты, `0.10.0` оказался опубликованным раньше, чем закончилась работа.** Триаж 23 открытых issue ([`26-08-20-open-issues-triage-for-prod.md`](../../docs/research/26-08-20-open-issues-triage-for-prod.md)) отранжировал их не по тяжести находки, а по тому, лежит ли код на боевом пути `msg-back`. Из четырёх must-fix закрыты все (#149 дедлок `Tx::commit` ↔ checkpoint, #80 F3 double-yield `VarIter`, #145 bare-сборка, #106 флейк), из трёх «желательно» — все три (#82 сборка томбстонов, #83 F7–F10, #85 в основной части). Ключевой факт, вскрывшийся при сверке: `armdb 0.10.0` уже **опубликован** (2026-08-16, состояние на `20fd5cf`), а потребитель пинит именно его — то есть ни T1, ни T2b, ни одно из этих исправлений в проде нет, включая дедлок, способный заморозить весь `Db`. Отсюда `0.11.0` как отдельный релиз, а не «допишем в 0.10.0»: CHANGELOG разрезан, каскад версий на этот раз не нужен (`armour-typ`/`armour-core`/`armour-rpc`/`rapira` побайтово совпадают с опубликованными). Второе следствие — документация: `design.md` описывал ядро, но ни один слой волн 1–3 (транзакции, TTL, схема-карриеры, очереди, watch) в нём не упоминался, `armour.md` обещал `tree_path(name, version)` и директории `name:vN`, `tuning.md` настраивал несуществующий `.cache(...)`, а skill армдб всё ещё утверждал, что кросс-коллекционных транзакций нет. |
### ~~Post-merge checklist — `pre-t5-atomicn` tag~~ — done (2026-08-05)
`pre-t5-atomicn` → `30ed2b4ae907eb7a82573671873d55202d321547`, pushed to
`origin`. PR #110 landed as a squash commit, so its single parent is both the
"first parent of the merge commit" and the removal PR's base on `main`; the SHA
recorded in `armdb/docs/cross-collection-transactions.md` was already that
commit, so only the tag itself was missing (tags are not carried by pull
requests).
### ~~Post-merge checklist — `0.8.0` version bump~~ — done, and superseded
Сам бамп выполнен и с тех пор уехал дальше: на 2026-08-16 манифесты в воркспейсе
— `armdb 0.10.0`, `armour-typ 0.6.0`, `armour-core 0.6.0`, `armour-rpc 0.6.0`,
`armour-derive 0.29.0`, `rapira 0.15.0`, `armour 0.36.0`. Миграционный документ для
потребителя (волны 0 и 1, все source-breaks с правилами перехода, эксплуатация
`txlog/`):
[`26-08-06-armdb-0-8-0-waves-0-1.md`](../../docs/breaking-changes/26-08-06-armdb-0-8-0-waves-0-1.md).
Причина, по которой это не могло уехать как `0.7.4`, остаётся в силе для всех
последующих минорных: под правилами Cargo для `0.y.z` ось совместимости — минор.
### ~~Post-merge checklist — релиз `0.10.0` и диапазоны у потребителя~~ — done (2026-08-16)
`armdb 0.10.0` опубликован на crates.io 2026-08-16 вместе с каскадом
(`armour-typ 0.6.0` → `rapira 0.15.0` → `armour-core 0.6.0` → `armour-rpc 0.6.0`
→ `armdb 0.10.0` → `armour 0.36.0` → `cdb 0.3.0`). Диапазоны у потребителя
сдвинуты: `msg-back/Cargo.toml:38` и `msg-shared/Cargo.toml:40` держат
`armdb = { version = "0.10", … }`, `Cargo.lock` резолвит `0.10.0` из registry.
### Post-merge checklist — релиз `0.11.0`
**Открыто (2026-08-20).** Опубликованный `0.10.0` — это состояние на коммит
`20fd5cf` (T14). Всё, что приземлилось позже, потребителю недоступно:
| Что | PR | Почему это важно потребителю |
|---|---|---|
| T1 bulk range operations | #147 | снимает ~58 мест «собрать ключи → цикл delete» |
| T2b backward finger | #148 | reverse-обход `O(log n + m)` вместо `O(m·log n)` |
| Дедлок `Tx::commit` ↔ checkpoint | #151 (#149) | **зависание всего `Db`** на проде |
| var-collections P3 (F2–F7) | #152 (#80) | `VarIter` мог выдать ключ дважды |
| bare-сборка юнит-тестов | #153 (#145) | CI |
| гейт флейкового теста | #155 (#106) | CI |
| shard-bitcask P3 (F7–F10) | #156, #157 (#83) | IO-ошибка на recovery больше не глотается |
| compaction P3 (C-04/05/07) | #159 (#82) | томбстоны наконец собираются |
Шаги:
1. **Закрыть CHANGELOG.** Раздел `Unreleased (0.11.0)` → `## 0.11.0 — released
<дата>`; `0.10.0` уже помечен как released 2026-08-16.
2. **Опубликовать только `armdb 0.11.0`.** Каскад не нужен: исходники
`armour-typ`, `armour-core`, `armour-rpc` и `rapira` на `main` побайтово
совпадают с опубликованными `0.6.0` / `0.15.0`.
3. **Сдвинуть диапазоны** `"0.10"` → `"0.11"` в `msg-back/Cargo.toml` и
`msg-shared/Cargo.toml` — под правилами Cargo для `0.y.z` `"0.10"` не
подтянет `0.11`.
4. Прогнать у потребителя чек-лист из
[`26-08-20-armdb-0-11-0.md`](../../docs/breaking-changes/26-08-20-armdb-0-11-0.md)
§5 — в первую очередь проверку на ручное обновление индексов внутри
`atomic()`.
Миграционные документы для волны 2 готовы:
[`26-08-07-armdb-multi-secondary-index.md`](../../docs/breaking-changes/26-08-07-armdb-multi-secondary-index.md),
[`26-08-08-armdb-tx-observability.md`](../../docs/breaking-changes/26-08-08-armdb-tx-observability.md),
[`26-08-09-armdb-engine-maintained-counters.md`](../../docs/breaking-changes/26-08-09-armdb-engine-maintained-counters.md),
[`26-08-10-armdb-unique-namespaces.md`](../../docs/breaking-changes/26-08-10-armdb-unique-namespaces.md).
У волны 3 (T11–T14) отдельного документа нет — вся она аддитивна, кроме тех самых
четырёх вариантов enum'ов, которые уже описаны в CHANGELOG. Наблюдаемые
изменения, уехавшие в `0.10.0` без своего гайда (хуки в `atomic()`, серверный
потолок для `limit = 0`, строгий Var-`migrate`), собраны в §4 документа
[`26-08-20-armdb-0-11-0.md`](../../docs/breaking-changes/26-08-20-armdb-0-11-0.md).
~~Land **T2c** (scope seam finalisation) before the bump~~ — **done**, PR #122.
`Scope`, `OrderedScan`, `TreeScan` / `MapScan` and the scope iterator types went
public in PR #120; `const N`, the GAT collapse and the `&self` / `Copy` / `Clone`
polish landed while they were still free, i.e. before the release makes them
breaking.
~~Land the **#123** soundness fix before the bump~~ — **done**. `TypedIter::Item`
and `OrderedScan::Row` for `TreeScan<&TypedTree<…>>` became
`(K, TypedRef<'a, T>)`; the old `(K, &'a T)` outlived its `seize` guard, so safe
code could read reclaimed values. Same reasoning as T2c: it is a breaking row-type
change, so it belongs before the release rather than after it.