armdb 0.14.2

sharded bitcask key-value storage optimized for NVMe
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
# 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 армдб всё ещё утверждал, что кросс-коллекционных транзакций нет. |
| 2026-09-10 | **T5 durability residue closed for the tick.** Spec [`26-09-09-armdb-durable-flush-worker.md`](../../docs/superpowers/specs/26-09-09-armdb-durable-flush-worker.md): Bitcask `periodic_flush` is now a dirty-shard fsync on the existing 1 s `flush` worker; FixedStore engine flush skips `!unsynced` (not `pending_writes`). Loss window `interval + 2·P_max`, conditional. Residue filed as #169 (fsync outside the shard lock) and #170 (Bitcask write-triggered sync thresholds). |
| 2026-09-11 | **T5 durability follow-ups closed.** #170 adds the `1024 / 50 ms` durable write thresholds with the `0 / None` opt-out; recovery reconstructs verified persistent page padding as compaction dead bytes through both hint and full-scan paths, including encrypted compaction output. #160 is closed: `close` barriers dirty data and checkpoints, `shutdown` performs clean finalization, and Drop logs its best-effort close failure. |

### ~~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.