docs(orders): собран черновик спеки второго источника

Зачем: решения семи развилок карты #69 жили только резолюциями тикетов;
перед вычитанием и приёмкой этапу 3 нужна цельная картина одним документом.

Что: docs/specs/2026-08-16-orders.md — черновик спеки заказов бэкенда из
резолюций #70–#74, #80, #81, с отклонёнными вариантами и доводами.
Расхождения с принятым внесены тем же коммитом: мастер-спека §2 (прямой
переход created → cancelled), §4 (механика класса C, чтение долей и
amount_delta), §5 (число пар из описи снято), §8 (строка слепка с хешем,
счётчики наблюдаемых классов, числа идентичности сняты); спека генератора §2
(заказная и событийная стороны вместо «расхождений и опозданий»); ADR 0008
(уточнён сдвиг отправки слепка); CONTEXT.md (термин «судьба заказа»).

Проверка: чтение; вычитание — #84, приёмка владельцем — #88.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:13:51 +03:00
co-authored by Claude Fable 5
parent ede1df765c
commit 815f500127
5 changed files with 567 additions and 14 deletions
+5
View File
@@ -185,6 +185,11 @@ _Избегать_: описание схемы, документация кон
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
событии `purchase` отдельно.
**Судьба заказа**:
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
решается бросками при его рождении и укладывается в окно изменяемости целиком.
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
**Окно изменяемости**:
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
+4
View File
@@ -17,6 +17,10 @@
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает модельный
день генератором, поэтому переливается ровно то, что он положил в топик.
Уточнение со сдвигом отправки, решённым позже (#71, [спека
заказов](../specs/2026-08-16-orders.md), раздел 2): даг, играющий день D,
кладёт и забирает слепок дня D−1 — по-прежнему ровно то, что сам положил, но
это слепок предыдущего дня, а не сыгранного.
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
+20 -13
View File
@@ -196,7 +196,7 @@ Ecommerce (заполнены только у торговых событий):
|---|---|---|
| `order_id` | String | номер заказа; равен клиентскому `purchaseID` |
| `user_id` | UInt64 | пользователь магазина — мост к склейке |
| `status` | String | `created``paid``cancelled` |
| `status` | String | `created``paid``cancelled`; неоплаченный отменяется прямым переходом `created``cancelled` |
| `created_at`, `updated_at` | DateTime64(3, 'UTC') | аудит строки в БД источника: создание и последнее изменение; не бизнес-время покупки и не время загрузки в ClickHouse |
| `items_total`, `discount`, `delivery`, `total` | Decimal(18,2) | деньги бэкенда — в Decimal |
| `items` | String | сырой текст массива позиций `[{sku, qty, price}]`, извлечённый из внешнего JSON |
@@ -233,7 +233,9 @@ Ecommerce (заполнены только у торговых событий):
заказов. Это остаётся носителем навыка «вложенный JSON в ClickHouse», не
превращая приём в преждевременную модель данных.
- Статусы держим все три: смена `created``paid` и есть причина «дыхания»
выручки внутри окна; сужение до двух — резервный срез 1.
выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна
заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда
`created`» не бывает ([спека заказов](2026-08-16-orders.md), раздел 3).
## 3. Каталог товаров
@@ -256,9 +258,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
| | Расхождение | Механика в генераторе | Ориентир доли |
|---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta`только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~12% |
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta`остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~12% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
@@ -267,8 +269,8 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
опись хранит точные счётчики по каждому классу расхождений (отмены,
потери, дубли).
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
только по дням с закрытым окном (раздел 8).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
механически дублирует B.
@@ -300,8 +302,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
Константа мира: каждый двухкуковый покупатель делает минимум по одному
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
(она строится только из покупок) и лаба не воспроизводится. Опись
хранит число именно таких пар.
(она строится только из покупок) и лаба не воспроизводится. Число таких
пар растёт, пока мир едет, и в опись не кладётся
([спека заказов](2026-08-16-orders.md), раздел 5).
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
@@ -501,11 +504,15 @@ Kafka день переигрывается генератором заново:
карты #10) закрыта этим же ходом: версионируется опись.
Контрольные числа описи:
- заказная сторона: заказы и выручка по дням; опись хранит точные
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
самопроверка лабы сверки;
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
покупателей — лаба склейки получает самопроверку.
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями;
- заказная сторона: заказы, выручка и счётчики наблюдаемых классов
расхождений после приоритета, в заказах — по дням с закрытым окном
(самопроверка лабы сверки; при N сыгранных днях таких дней N − 7);
- контрольные числа идентичности не заводятся: uniq известных пользователей
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
названного читателя в опись не кладётся
([спека заказов](2026-08-16-orders.md), раздел 5).
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
при пересборке.
+4 -1
View File
@@ -198,7 +198,10 @@
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
(уточнение при исполнении #38, 2026-08-02);
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
торговые события, расхождения, опоздания — в фиксированном порядке.
торговые события, заказная и событийная стороны судьбы заказов — в
фиксированном порядке (уточнение [спекой заказов](2026-08-16-orders.md),
раздел 3: прежние «расхождения» и «опоздания» были названы по классам
витрины, а компонент называет часть мира).
По построению: параллельный прогон равен последовательному; продление
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
только его часть снимка — в описи меняются хеши только затронутых
+534
View File
@@ -0,0 +1,534 @@
# Заказы бэкенда (этап 3): слепок окна, судьба заказа, мост к склейке
Статус: черновик — впереди проход на вычитание (#84) и приёмка владельцем (#88).
Дата: 2026-08-16. Мандат — карта #69 (этап 3, родитель #5); собрано тикетом #87.
Развилки: приём (#70), генератор слепков (#71), расхождения и опоздания (#72),
мост к склейке (#73), место в стартовом мире (#74), форма записи на проводе
(#81), брак и версии в ODS (#80).
Источники: мастер-спека [«Боевой реализм стенда
(v2)»](2026-07-30-stand-v2-realism.md) — разделы 25 и 7;
[спека генератора](2026-08-01-generator.md);
[ADR 0008](../adr/0008-order-ingestion.md);
[ADR 0010](../adr/0010-order-versions-in-ods.md);
[спецификация приёма заказов](2026-08-16-order-ingestion.md);
[исследование формата слепка](../research/2026-08-16-order-snapshot-wire-format.md).
## Зачем
Мастер-спека решила, что у стенда есть второй источник: раз в модельный день
бэкенд магазина выгружает полный слепок заказов окна изменяемости, и деньги в
витринах считаются по нему, а не по трекеру. Как этот источник устроен — кто
порождает заказ, откуда берётся его судьба, как слепок доезжает до ODS и что из
этого видит менти, — она отложила. Карта #69 прошла семь развилок; эта спека
собирает их решения в одну картину. По ней этап 3 режется на тикеты (#89) —
после вычитания (#84) и приёмки владельцем (#88).
Здесь не переоткрывается решённое мастер-спекой: поля слепка, окно K = 7 как
константа мира, три статуса, приоритет классов расхождений, правило «поведение
и атрибуцию считаем по трекеру, деньги — по бэкенду». Рамка, перед которой
отвечает каждое решение, — «Чем меряется генератор» в спеке генератора:
конструкция внутри оправдана только наблюдаемым эффектом на выходе.
## Целевая картина одним взглядом
- **Заказ — проекция, не порождение.** Торговая половина дня-функции уже
посчитала корзину, цены, купон и номер заказа; заказная половина навешивает
судьбу и собирает слепок. Ни одного нового броска в торговом подпотоке.
- **Слепок дня D — чистая функция (зерно, D)**: состояние заказов, рождённых в
дни D−6…D, снятое на границе суток D|D+1. Отправляет его следующий прогон —
ночная выгрузка бэкенда за вчера.
- **Судьба заказа решается при рождении** и обязана уложиться в окно K либо не
случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled`.
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
заказная и событийная стороны; броски независимы, пересечения выходят
арифметикой, приоритет классов работает по-настоящему.
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным.
- **На проводе** — один JSON-документ на заказ: деньги строками с двумя
знаками, времена RFC 3339 в UTC с миллисекундами, `items` обычным массивом.
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order_snapshot`
принимает версии заказа на `ReplacingMergeTree(updated_at)`.
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу мира.
## 1. Откуда берётся заказ
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71).
Заказ и событие `purchase` — не два порождения, а две проекции одного факта
мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной
дня-функции; заказная половина берёт заказы дня готовой структурой — вторым
выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В
подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается,
согласованность двух источников не удерживается, а получается по построению.
Следствия, которые уже решены соседями и здесь только связываются:
- у всякого заказа изначально ровно одно событие `purchase`; заказ без события
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
выбрасыванием события после присвоения номера (раздел 4);
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`,
читаемый номер «день и порядковый номер покупки» (спека генератора, блок
#40); нумеруются все покупки, дошедшие до потока дня, — до всяких потерь;
- скидка заказа выводится из промокода события по таблице «код → скидка» —
числу мира, которое этап 3 берёт готовым (спека генератора, раздел 8).
В модели строка заказа в базе источника создаётся синхронно с покупкой, поэтому
день рождения заказа и день создания строки совпадают. Смысл полей от этого не
меняется: `created_at` и `updated_at` — аудит строки по часам базы источника,
бизнес-время покупки живёт в `purchase.UTCEventTime` (раздел 6).
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание
всего генератора; *слепок собирает SQL стенда из событий* — второй источник
исчезает вместе с уроком «две версии правды».
## 2. Слепок и его доставка
Та же резолюция #71.
**Запуск.** Третья команда того же пакета — `snapshot --day D [--days N]`, свой
приёмник, топик `orders`. Два источника — два запуска: трекер и бэкенд видны
глазами как два производителя, каждый со своим топиком. Один прогон с двумя
выходами отклонён: экономии он не даёт (со сдвигом отправки окно слепка и
сыгранный день не пересекаются вовсе), а правило «приёмник выбирается тем, что
для него назвали» ломает.
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и собирается
переигровкой этих семи дней: заказы дня — производная всей воронки дня,
дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; подряд
идущие дни одного прогона считаются по разу, у начала оси окно усекается само.
Отклонено: *кэш заказов на томе* — состояние между прогонами; *окно держит
хранилище* — топик перестаёт нести слепок; *K = 1* — это страховочный срез 1
мастер-спеки, он в резерве.
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
бэкенда за вчера, как в бою. Содержимое слепка — чистая функция (зерно, D), от
момента отправки не зависит. Следствия:
- живой день перестаёт быть особым случаем: своего дага у него нет, слепок
живого дня отправит следующий прогон;
- пропущенный день лечится окном: слепок переснимается и даёт те же байты,
отдельного механизма самовосстановления нет;
- покупки текущего дня в сверке всегда `awaiting_order` — сюжет «вчера не
сходилось, сегодня сошлось», ради которого мастер-спека этот класс завела;
- цена — один лишний проигрыш дня на прогон (окно и сыгранный день не
пересекаются, проигрышей всегда восемь).
На старте оси дня −1 нет, поэтому прогон дня 0 не отправляет ничего; первый
слепок — дня 0 — уезжает прогоном дня 1 (исследование формата).
**Случайность.** Судьбу заказов бросает свой подпоток, ветвящийся по дню
рождения заказа: слепок несёт семь дней рождения сразу, и судьбу каждого заказа
обязан читать из его собственного дня. Вся судьба решается при рождении,
поэтому слепок любого дня — чтение готовой судьбы, а не накопление состояния.
Отклонено: *дописывать броски в конец `COMMERCE`* — правка заказа и правка
торгового поведения стали бы одним рычагом; *бросать состояние в подпотоке дня
слепка* — траектория заказа зависела бы от того, какие слепки снимали.
## 3. Судьба заказа
Резолюция развилки [«Расхождения A–D и опоздания: механика, доли и что
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72),
ревизия после двух холодных ревью.
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
решённая при его рождении и уложенная в окно K целиком.
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
дубль. Довод за разделение — различимость по описи: правка заказной механики не
двигает хеши событий, правка событийной не двигает байты слепка, и по
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
компонент называет часть мира; новые стороны занимают те же позиции, имена — при
реализации. Отклонено: *один компонент на всю судьбу* — правка событийной
механики молча меняла бы байты слепка; *компонент на класс* — пять имён под
ручки калибровки, которые крутятся разом.
**Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
становится функцией доли, и правка одной доли перебрасывает весь подпоток после
себя (в генераторе такое место уже есть — `_at_least_one`; здесь оно не
повторяется). Изоляцию даёт форма броска, а не число подпотоков.
**Путь по статусам.** Три исхода, все внутри окна: **оплачен**; **оплачен и
отменён**; **не оплачен и отменён**. Инвариант на выходе из окна: заказ либо
`paid`, либо `cancelled`; `created` — только промежуточное состояние. За окном
будущего у заказа нет, а заказ, навсегда застрявший в `created`, — модельная
небрежность, которой в выгрузке живого магазина соответствия нет. Поэтому
нового значения `mismatch_class` не нужно: `cancelled` покрывает обе дороги
отмены (шестое значение занято `awaiting_order`).
**Форма броска.**
1. **Исход** — таблица долей из трёх строк; доля неоплаченных пишется явной
строкой, а не оставляется читателю складывать хвост в уме.
2. **Моменты** — таблица целых весов «сколько часов от рождения — с каким
весом», строки 0…143, плюс равномерная секунда внутри часа — чтобы разности
времён аудита не давали точных равенств (урок правки #50).
3. Моментов бросается **всегда два, на полную длину дня**; у одномоментных
исходов второй выбрасывается. Где их два по существу, ранний считается
оплатой — порядок выходит сортировкой, условной точки отсчёта не нужно.
143 часа — самый узкий край окна: у заказа, рождённого в конце суток, до
последнего его слепка 144 часа. Таблица кончается там, где кончается окно у
самого невезучего: вылезти нечему, сторожа не нужно. Цена — заказ, рождённый в
начале суток, не использует почти сутки своего окна; в хвосте таблицы веса
мизерные, в данных это не видно. Форма из #71 — «вес за краем окна означает
„не оплачен никогда“» — этим отменена: такой заказ теперь отменяется, а край
окна не выражается числом часов — иначе доля неоплаченных стала бы функцией
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
она делится между двумя дорогами — строки таблицы исходов. Точные числа —
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии
`ods.order_snapshot` до фоновых слияний, а в витринах — выручка дня, которая
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
деться, кроме как остаться брошенным; *мгновенная отмена при рождении*
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
он в резерве.
## 4. Классы расхождений и опоздание
Та же резолюция #72. Классы и ориентиры долей — мастер-спека, раздел 4;
здесь — механика каждого.
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
`items_total` меньше на её стоимость. Момента у неё нет — заказ приезжает
урезанным во всех своих слепках: первый слепок снимается на границе суток,
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
спросом на доле 1–2% статистически ненаблюдаема — менти платил бы за неё
таблицей чисел мира, а увидеть не мог бы ничем. Доводы за вычёркивание:
остаток — сотни рублей, он торчит в витрине сверки сам; расхождение
объясняется сравнением позиций — разбором вложенного JSON и `ARRAY JOIN`,
ровно тем навыком, ради которого позиции разбираются; история рассказывается
словами без легенды про генератор. Отклонено: *переоценка позиции* и *другое
количество* — дельта в десятки рублей, её надо захотеть заметить; *чистая
дельта без истории* — тупик, объяснить нечем; *врёт клиент, а не бэкенд*
заказ у нас проекция той же корзины.
**Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр
`/confirmation` остаётся: события уезжают разными запросами, потерять один и
сохранить другой — обычное дело. Единственный вариант, при котором потеря
видна со стороны трекера: до подтверждения дошли сто, покупок девяносто семь.
**Дубль события (D) — сюжетный.** Обновление страницы шлёт и просмотр, и
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
до следующего визита куки есть место больше таймаута: иначе сборка сессий у
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID`
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с
покупкой, по тому же правилу, что у подтверждения с торговым хвостом. Обе
порчи случаются после присвоения номеров. Отклонено: *обе точечные* — дубль
затирает урок потери; *обе сюжетные* — потеря перестаёт быть видна со стороны
трекера.
**Гарантия моста сильнее порчи.** Назначенные планом покупки не теряются, и
назначенные планом заказы не опаздывают: `dds.identity_map` строится из моста
«`purchase` ↔ заказ», и выброшенное событие — как и заказ, не попавший ни в
один снятый слепок, — уносит куку из карты. Это был бы отказ лабы склейки, а
не расхождение в данных; менти различить не может. Гарантия плана старше
расхождения.
**Опоздание — заказ прячется от ранних слепков.** `created_at` честный, но в
слепках дней d…d+δ−1 строки нет, а с d+δ она появляется в том состоянии, до
которого заказ дожил: отменённый на второй день и опоздавший на третий приедет
в первом же своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка
— таблица весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть
«D+1/D+2» мастер-спеки. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно
в одном слепке, и обещание «пропущенный день ничего не ломает» на нём
перестало бы быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Следствие для
переливки не ново: у прошлых дней окна меняется и состав, а не только суммы —
«дыхание» статусов это уже требовало. Отклонено: *сдвиг `created_at`* — заказ
уехал бы в чужую партицию, и «выручка дня D» перестала бы отвечать покупкам
дня D — сломалась бы та самая сверка, ради которой всё строится.
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
решается только у выживших покупок, и назначенное планом не портится.
Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа
(часть заказов забирают победители по приоритету, часть у класса C недоступна —
однопозиционных заказов больше половины). Отклонено: *один класс на заказ*
приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую.
## 5. Что хранит опись
Резолюции #72 и [«Места заказов в стартовом
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
> Опись хранит только то, чего движение мира не меняет.
> Хеш кладём всегда, счётчик — только когда назван его читатель.
Хеш — растяжка: он не обещает, что числа те же вечно, он обещает, что их сдвиг
не пройдёт молча, и читает его `git diff`. Счётчик — опора: обещание
конкретного числа тому, кто его прочтёт, и платит за него каждая правка
генератора.
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
байты») опись начинает сторожить. Окно изменяемости хешу не мешает: дышат
заказы, а не байты слепка — слепок дня D по-прежнему чистая функция
(зерно, D).
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
итоговой судьбе заказов дня; единица счёта — заказ, и опись называет её
словом. Сойтись с запросом менти они могут только на днях с закрытым окном:
при N сыгранных днях таких N − 7 (день d закрывается слепком d + 6, а
последний отправленный слепок несёт день N − 2). Читатель у них придёт
этапом 4 — проверка сверки; не окажется читателя — та же бритва режет и их.
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
не выходит; генератор сторожит хеш, транспорт — счёт событий.
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
Kafka: дошли ли байты до `ods.order_snapshot` — вопрос стенда и его
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
приёма заказов — число заведётся вместе с ней.
## 6. Мост к склейке: человек, `person_id`, `user_id`
Резолюция развилки [«Мост к склейке: user_id и двухкуковые
пары»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/73).
Причинная модель: план состава порождает **человека**; ему принадлежат одна
или две куки, каждая наблюдается в кликстриме как отдельный посетитель. Визит
может оформить заказ — тогда заказная сторона представляет того же человека
как **пользователя магазина**. Это не модель аккаунта: регистрации нет, люди
без визитов не порождаются, внутреннее знание наружу не выдаётся.
План владеет устойчивой личностью и отношением «кука принадлежит человеку».
Минимальная форма — непрозрачный внутренний `person_id`, выровненный по кукам:
у двух кук пары он одинаков. Заказ выводит то же значение под родным именем
`user_id` (`UInt64`); в кликстрим ни `person_id`, ни `user_id` не попадает —
анонимность формата держится формой, а не забывчивостью сериализатора.
`person_id` — последний обычный бросок потока случайности когорты, после уже
принятых свойств: добавление личности не сдвигает куки, пары, возвраты и
паспорта. Для второй куки повторяется ID её человека. Значения — ниже 2^53.
Раздельные прогоны ничего не хранят и не согласуют: генератор событий и
команда слепка заново спрашивают один план и получают тот же `person_id`.
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
отдельные прогоны одного мира дают то же соответствие; принятый мир
воспроизводит эффект склейки без случайных ложных объединений — конкретные
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
канонический мир по внешнему результату.
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь*
состояние между прогонами без нового внешнего эффекта; *первая кука как ID
человека* — магазин оказался бы замаскированным продолжением трекера;
*структурная координата, хеш или глобальная последовательность* — больше
механики при тех же данных; *полная модель аккаунтов* — менти её не наблюдает.
## 7. Заказы в стартовом мире
Резолюция развилки #74. Стартовый мир — не полный мир, а мир, остановленный на
границе суток 7|8. Генератор играет два источника с разными темпами — поток
Метрики и ночную выгрузку магазина; разные темпы дают всё остальное.
`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`; со
сдвигом отправки уезжают **слепки дней 0…6**. Слепок дня 7 снят на границе
суток и уедет первым же ходом мира. Особого режима у стартового мира нет:
правило отправки живёт в одном месте — в проигрывателе; генератору это решение
не стоит ничего.
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
**График выручки заваливается к правому краю** и дозаполняется, пока мир едет.
Это не издержка стенда, а главный наблюдаемый эффект второго источника: ночная
выгрузка отстаёт, свежие дни предварительны. На свежем стенде лаба сверки
видит целый день `awaiting_order` — норма, а не поломка; как это назвать
менти — за витринами этапа 4.
Цена принята с открытыми глазами: у 18 из 73 пар стартового мира поздний
назначенный заказ падает на день 7 (замерено на каноническом зерне), и до
первого хода мира этих пар в `dds.identity_map` нет. Опись пар не считает
(раздел 5), поэтому красной проверки из этого не выходит. Отклонено:
*дослать восьмой слепок* — исчезает `awaiting_order` на свежем стенде, в CLI
заводится рычаг, стартовый мир становится особым случаем ровно там, где #71
его убирал; *счётчик пар учится спрашивать про слепки* — число верно ровно до
первого хода мира; *отменить сдвиг отправки*`awaiting_order` пропал бы
навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой функцией
зерна.
Замеры канонического зерна (сняты при резолюции): пар в плане 73, в слепках
стартового мира доезжает 55; на эталонном мире — 192 и 172; дней с закрытым
окном — один на стартовом, семь на эталонном.
## 8. Запись на проводе
Резолюция развилки [«Форма записи слепка на
проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81);
основания и проверка разбора — в [исследовании
формата](../research/2026-08-16-order-snapshot-wire-format.md).
```json
{
"order_id": "20260603-0001",
"user_id": 42,
"status": "paid",
"created_at": "2026-06-03T14:21:07.123Z",
"updated_at": "2026-06-03T14:24:18.456Z",
"items_total": "1299.90",
"discount": "0.00",
"delivery": "199.00",
"total": "1498.90",
"items": [
{"sku": "sku-17", "qty": 1, "price": "1299.90"}
],
"snapshot_date": "2026-06-07"
}
```
- **Деньги — строки с ровно двумя знаками после точки**, включая
`items[].price`: сохраняется десятичная запись источника, сумма не проходит
через JSON-число/Float. Разница с кликстримом намеренна — там
`purchaseRevenue` приезжает JSON-числом в `Array(Float64)`, а бэкенд
передаёт деньги строками для точного `Decimal`: расхождение представлений
денег в двух источниках и есть урок.
- **`items` — обычный массив JSON**, не строка с JSON внутри. В ODS колонка
остаётся `String`: приём извлекает сырой фрагмент через `JSONExtractRaw`,
а разбирается массив один раз — на границе ODS → DDS.
- **`created_at` и `updated_at`** — аудит строки по часам базы источника, UTC
с настоящими миллисекундами, одна узкая форма `YYYY-MM-DDTHH:mm:ss.SSSZ`;
`.000` выводится честно. `updated_at` монотонен по версиям одного заказа и
служит версией `ReplacingMergeTree`. Бизнес-время покупки остаётся в
`purchase.UTCEventTime`; нового `ordered_at` не добавляется — потребителя
нет.
- **`snapshot_date`** — дата завершившегося модельного дня, снятая на
исходящей границе суток; одна на всю выгрузку, не глобальная константа и не
дата отправки. Константы мира — D0, пояс модельного календаря и окно K = 7.
- **Порядок ключей** — порядок полей контракта; у позиции — `sku`, `qty`,
`price`: воспроизводимые байты без сортировки ключей.
- **Сериализация** — явная функция рядом с `events(...)` в существующем
`serialize.py`: один словарь с вложенным списком и один `orjson.dumps` на
запись. Классы кодеков, реестр схем и отдельный `dumps` для `items` не
заводятся.
Отклонено: *JSON-числа для денег* — теряют десятичную форму и провоцируют путь
через Float; *строка с JSON в `items`* — двойная сериализация и второй разбор;
*секундная точность* — отбрасывает точность аудита источника; *глобальная
`SNAPSHOT_DATE`* — смешивает правило календаря с результатом его вычисления.
## 9. Приём: от топика до ODS
Резолюции развилок [«Приём заказов: нужен ли слепку слой
сырья»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/70)
и [«Брак в слепке: куда уходит и по каким
классам»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/80).
Источник истины здесь — [ADR 0008](../adr/0008-order-ingestion.md),
[ADR 0010](../adr/0010-order-versions-in-ods.md) и [спецификация
приёма](2026-08-16-order-ingestion.md); ниже — картина одним абзацем на слой.
Топик `orders` — одна партиция; чтец байтовый (`RawBLOB`), объявлен только на
`clickhouse-01` и без `ON CLUSTER`, матвью к нему не привязана. Сырьё забирает
пакетный шаг под управлением Airflow: одно прямое чтение Kafka вставляет
порцию в `stg.orders_raw` с `_load_id` = `run_id` запуска. Один следующий
`task_id` двумя взаимодополняющими `INSERT SELECT` читает неизменный срез STG:
годные строки — в `ods.order_snapshot`, брак — в `ods.order_snapshot_errors` с
классом `not_an_object` / `keyset_mismatch` / `field_invalid`. Строгий приём
проверяет только контракт провода — 11 верхних ключей и канонические формы;
позиции и бизнес-инварианты остаются ниже границы.
`ods.order_snapshot` принимает **версии заказа** на
`ReplacingMergeTree(updated_at)`: ключ — `order_id`, партиция — день
неизменного `created_at`, шардирование по `cityHash64(order_id)`.
`snapshot_date` — происхождение строки, а не ключ публикации: прямое чтение
Kafka не задаёт границы полного слепка, поэтому прежняя замена партиции
(ADR 0008) отменена ADR 0010. Точное текущее состояние читается через
`ods.order_v`; нужен ли при сборке DDS `FINAL`, `argMax` или инкрементальный
приём — решается вместе с DDS.
Стенд получает два режима приёма рядом — поток против слепка, push против
pull, — и это опорная точка раздела 12 мастер-спеки. Тот же даг проигрывает
модельный день и забирает слепок; со сдвигом отправки он переливает слепок
предыдущего дня — ровно тот, который сам и положил в топик.
## 10. Правила кода этапа 3
Хвосты резолюций, обязательные для реализации:
- **Броски заказной стороны — на полную длину дня**, а не на отобранных
заказах (правило формы, раздел 3); моментов всегда два.
- **Целочисленная случайность** наследуется правилом кода этапа 2: таблицы
целых весов, никаких плавающих распределений; деньги — в целых копейках.
- **Подпотоки — по позиции в дереве**: стороны судьбы ветвятся по дню рождения
заказа; в `COMMERCE` новых бросков нет; `person_id` — последний бросок
когорты.
- **Окно K = 7 — константа мира** в конфигурации мира, рядом с D0 и поясом
(исследование формата).
- **Один сериализатор**: запись слепка собирает явная функция `serialize.py`,
прямых `json.dumps` по коду нет.
- **Значения идентификаторов — ниже 2^53** (`user_id` наравне с прочими).
- Спорные API (DDL ClickHouse, операторы Airflow) перед кодом сверять через
MCP Context7 — правило AGENTS.md.
## 11. Расхождения с принятым
Внесены тем же коммитом, что и эта спека:
- **Мастер-спека, раздел 2**: цепочка статусов допускает прямой переход
`created → cancelled`; на выходе из окна заказ либо `paid`, либо
`cancelled` (#72).
- **Мастер-спека, раздел 4**: у класса C названа механика — вычеркнутая
позиция; ориентир «~5%» читается как доля отменённых вообще;
«`amount_delta` — необъяснённый остаток» читать как «не объяснённый
приведением к сравнимой базе» — сравнением позиций он объясняется, на этом
стоит урок класса C; счётчики описи — наблюдаемых классов, после приоритета,
в заказах (#72).
- **Мастер-спека, раздел 5**: «опись хранит число именно таких пар» снято —
число растёт, пока мир едет; ядро лабы — неравенство
`uniq(посетителей) > uniq(людей)` — верно в любой день (#74).
- **Мастер-спека, раздел 8**: опись получает строку на слепок с хешем байтов;
контрольные числа идентичности не заводятся; «заказы и выручка по дням»
читать как «по дням с закрытым окном» (#72, #74).
- **Спека генератора, раздел 2**: в перечислении компонентов дня расхождения
и опоздания уступают место заказной и событийной сторонам (#72).
- **ADR 0008**: фраза «переливается ровно то, что он положил в топик»
уточнена — со сдвигом отправки даг переливает слепок предыдущего дня (#71).
- **CONTEXT.md**: новый термин «судьба заказа» (#72); термины «человек» /
«посетитель» / «пользователь магазина», «дата слепка», «время аудита
источника» и поправки к «Слепку» и «Описи мира» внесены ранее, при закрытии
#72#81.
Ранее внесённые той же картой изменения (уже в main): раздел 12 мастер-спеки
переписан на «поток против слепка», сенсор дневного батча снят, первый даг
переехал на этап 3 (#70); разделы 2 и 7 мастер-спеки — формат провода и
версионный ODS (#81, #80).
## 12. Решается при нарезке и позже
- **Имена подпотоков сторон судьбы** — при реализации; из мёртвых имён никто
не бросает, переименование ничего не сдвигает (#72).
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
доли классов, стоимость доставки — калибровка при реализации; финальная
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
потребуют только числа, не устройство.
- **Проверки приёма** — разовая приёмка допущения «один запуск — одно чтение»
и опыты из «Рисков и проверки» спецификации приёма — тикет реализации
приёма.
- **`_load_id` выше ODS** — вместе с устройством `dds.order` (#85).
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
остаётся в тумане карты #69; естественное место разговора — этап 4.
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
механике заказов не привязан.