# Заказы бэкенда (этап 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) — разделы 2–5 и 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, к механике заказов не привязан.