# Hado Core — дизайн ядра умных уведомлений Дата: 2026-09-03. Статус: согласован в брейншторме, ждёт ревью. ## 1. Что это и зачем Hado (波) — отдельный переиспользуемый сервис уведомлений для всех сервисов экосистемы (документы, будущие остальные). Клиенты присылают ядру **события с датой**, ядро владеет состоянием, само считает, когда и куда напоминать, и само доставляет по каналам, в которых пользователь сейчас доступен. Принципы, которые нельзя нарушать: - **Ядро не знает доменов.** Ни ДР, ни паспортов, ни седул. Только событие, дата, готовый к показу `payload`. Подключение нового клиента — токен в реестре, правок ядра ноль. - **Клиенты пушат, ядро хранит.** Как шина: клиент делает upsert, ничего у себя не хранит и не планирует. Ядро само разносит по каналам. - **Напоминание — когда ты доступен, не когда стрелка на девяти.** Иначе это ещё одни «Напоминания» Apple, которые стираются на автомате. - **«Помню» и «Сделано» — разные вещи.** Первое гасит текущее напоминание, второе закрывает событие. Бейдж падает только от второго. ## 2. Границы В этой спеке: бэкенд ядра (Laravel + Postgres), API для клиентов, API для пользователя, веб-инбокс, каналы `telegram` и `webhook`, доставка по присутствию. Вне спеки (отдельные проекты, путь открыт): - iOS-приложение: бейдж, виджет «Грядёт», Live Activity, канал `apns`. - Разбор PDF через LLM — это клиент документов, не ядро. - Голосовой ассистент — подключается как `webhook`-канал через Home Assistant, ядру про него знать не нужно. ## 3. Понятия - **Источник** (`source`) — сервис-клиент с токеном. `docs`, потом другие. - **Пользователь** (`user`) — логин из `X-Remote-User` хаба sekai. У него свой часовой пояс, тихие часы и каналы. - **Событие** (`event`) — то, что имеет срок. Идентичность: `(source, source_ref, topic)`. Ядро само собирает ключ из этих трёх полей, клиент ничего не считает. - **Срок** (`due`) — календарный день, опционально с моментом внутри дня. - **Старт** (`fire_on`) — день, с которого начинается подготовка. Если подготовки нет, клиент шлёт `fire_at = due_at`. - **Каскад** — точки подготовки между стартом и сроком, ядро строит их по формуле. - **Окно** — интервал, внутри которого ядро ищет момент доставить напоминание. - **Присутствие** — ответ канала на вопрос «пользователь сейчас тут?». ## 4. Модель данных ``` sources id, name UNIQUE, token_hash, created_at users id, login UNIQUE, tz (IANA, напр. America/Montevideo), quiet_start TIME (default 22:00), quiet_end TIME (default 09:00), created_at, updated_at events id source_id FK sources user_id FK users source_ref string -- "person:42" topic string -- "birthday:2026", "payment:2026-10"; по умолчанию "" due_mode enum(local, fixed) -- local: срок календарный, живёт в поясе пользователя (ДР, седула) -- fixed: срок — момент во времени, пояс зашит клиентом (вылет из Мадрида) due_date date -- при local: день срока due_time time -- при local: момент внутри дня, 24:00:00 = весь день due_instant timestamptz -- при fixed: момент срока -- tagged union: заполнены поля своего режима, код ветвится по due_mode fire_on date -- старт подготовки; = день срока, если подготовки нет after_due enum(keep, expire) payload jsonb -- title, subtitle, deep_link, done_label state enum(scheduled, preparing, today, overdue, done, expired, withdrawn) quiet_until timestamptz -- «молчать до»; по умолчанию в прошлом done_at timestamptz nullable created_at, updated_at UNIQUE (source_id, source_ref, topic) channels id, user_id FK, type enum(web, telegram, webhook), config jsonb, enabled bool, created_at, updated_at -- web.config: {} (создаётся автоматически, не удаляется) -- telegram.config: { chat_id } -- webhook.config: { deliver_url, presence_url } deliveries id, event_id FK, channel_id FK, window_start timestamptz -- начало окна, в которое доставлено action_token string UNIQUE -- для ссылок «Помню»/«Сделано» из канала result enum(pending, ok, failed) -- pending ставится в момент диспатча sent_at timestamptz nullable, error text nullable UNIQUE (event_id, channel_id, window_start) ``` Два режима срока. `local` — дата календарная, без пояса; пояс берётся у пользователя в момент вычисления. Переехал в другую страну, сменил `tz` — все окна сдвинулись сами, пересчитывать нечего. `fixed` — момент абсолютный; «день срока» и «момент внутри дня» для него вычисляются из `due_instant` в текущем `tz` пользователя каждый раз, когда нужны. Вылет из Мадрида в 08:15 при `tz = America/Montevideo` — это 03:15 по местному, и ядро считает именно так. Всюду ниже «день срока» и «момент срока» означают результат этого вычисления для обоих режимов. `fire_on` в обоих режимах календарный: подготовка начинается с утра этого дня, где бы пользователь ни был. Все `UNIQUE`: `events (source_id, source_ref, topic)`, `deliveries (event_id, channel_id, window_start)` — второй защищает от двойной доставки, см. §9. Кэш присутствия живёт в cache-store (ключ `presence:{channel_id}`, TTL 60 с), в таблицах его нет. ## 5. Контракт для клиентов Авторизация: `Authorization: Bearer `. Источник берётся из токена, в URL и теле его нет. ### Upsert пачкой ``` PUT /api/events { "events": [ { "user": "nikita", "source_ref": "person:42", "topic": "birthday:2026", "due_at": "2026-08-14", // или "2026-09-10T14:00" "fire_at": "2026-08-14", // = due_at, если подготовки нет "after_due": "expire", "payload": { "title": "Сегодня ДР — Вася", "subtitle": "исполняется 34", "deep_link": "/people/42", "done_label": "Поздравил" // опционально, по умолчанию «Сделано» } } ] } → 200 { "events": [ { "source_ref": "person:42", "topic": "birthday:2026", "id": 17, "state": "scheduled" } ] } ``` Правила разбора `due_at`, три формы: | форма | пример | режим | что хранится | |---|---|---|---| | дата | `2026-08-14` | `local` | `due_date`, `due_time = 24:00` (весь день) | | дата-время без смещения | `2026-09-10T14:00` | `local` | `due_date`, `due_time` | | дата-время со смещением | `2026-09-10T08:15+02:00` | `fixed` | `due_instant` | Правило для клиента: **смещение ставь только если событие привязано к месту** (вылет, встреча по чужому времени). ДР, сроки документов, оплаты — без смещения, они едут за пользователем. `fire_at` — только `YYYY-MM-DD`, не позже дня срока. Валидация: `fire_at <=` день `due_at`, `user` непустой, `payload.title` непустой, до 500 событий в пачке. Одна плохая запись → 422 на всю пачку с указанием индекса, ничего не применяется. Правила upsert по существующему событию (та же тройка идентичности): | что пришло | что делает ядро | |---|---| | `due_at` тот же | обновляет `fire_on`, `after_due`, `payload`, `user`. Если состояние терминальное (`done`/`expired`/`withdrawn`) — не трогает. Иначе пересчитывает состояние из дат (см. §6). | | `due_at` другой | это перенос (рейс перенесли, срок документа продлили): обновляет всё, сбрасывает `quiet_until`, пересчитывает состояние из дат **даже из терминального**. | Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с тем же `person:42` и новым `topic` — см. правило ниже. **Повторяющиеся события: каждое вхождение — отдельная строка.** Клиент кладёт период в `topic`: `birthday:2026`, `birthday:2027`, `payment:2026-09`, `payment:2026-10`. Тогда вхождения независимы: сентябрьская аренда висит в `overdue` и долбит, октябрьская спокойно создаётся рядом со своим каскадом, следующий ДР можно запушить хоть за год — он лежит в `scheduled` и молчит. Незакрытые `keep`-вхождения копятся, и это правильно: в этом смысл `keep`. Почему нельзя переиспользовать одну строку под все годы. Ядро не отличает «клиент перевёл ДР на следующий год» от «рейс перенесли в день вылета» — оба выглядят как новый `due_at` и оба сбрасывают состояние. В само утро ДР клиент прислал бы дату следующего года и стёр живое `today`. А для `keep` было бы ещё хуже: пока сентябрь не закрыт, октябрь запушить нельзя, иначе оборвётся просроченный долбёж, — и октябрьского напоминания не существует вообще. Правило «`due_at` другой → сбросить состояние» остаётся для настоящих переносов: рейс перенесли, срок документа продлили. Это то же событие с новой датой. Терминальные строки (`done`, `expired`, `withdrawn`) ядро удаляет через `HADO_RETENTION_DAYS` (по умолчанию 90) после перехода в терминальное состояние, чтобы вхождения не копились вечно. ### Снятие и чтение ``` DELETE /api/events?source_ref=person:42&topic=birthday:2026 → 204, state = withdrawn GET /api/events?source_ref=person:42&topic=birthday:2026 → 200 { id, state, due_at, ... } ``` `topic` в запросе можно опустить — тогда пустая строка. ## 6. Жизненный цикл события `state` — это и есть фаза. Хранится в базе, меняет крон (тик раз в минуту) по календарю пользователя в его `tz`. | state | смысл | напоминает | в бейдже | |---|---|---|---| | `scheduled` | день старта ещё не пришёл | нет | нет | | `preparing` | идёт подготовка (`fire_on <= сегодня < день срока`) | раз в день | нет | | `today` | день срока, момент срока ещё не наступил | каждый час | да | | `overdue` | момент срока прошёл, `after_due = keep` | раз в день | да | | `done` | нажал «Сделано» | нет | нет | | `expired` | момент срока прошёл, `after_due = expire` | нет | нет | | `withdrawn` | клиент прислал DELETE | нет | нет | Переходы: - `scheduled → preparing`: наступило 00:00 дня `fire_on`, и `fire_on` раньше дня срока. - `scheduled → today`, `preparing → today`: наступил **старт дня срока**. Обычно это 00:00 дня срока. Но если момент срока раньше начала активных часов (вылет в 07:00 при `quiet_end = 09:00`), первое окно дня уже опоздало бы, и событие ушло в `expired`, не пикнув ни разу. Поэтому для таких событий старт дня срока — **за два часа до тихих часов накануне**: `quiet_start − 2h` предыдущего дня (20:00 при `quiet_start = 22:00`), то есть два последних часовых окна вечера. Вечер накануне работает как `today`: часовые окна, бейдж, всё как в день. - `today → overdue`: наступил момент срока, `after_due = keep`. - `today → expired`: наступил момент срока, `after_due = expire`. - любое из `scheduled / preparing / today / overdue` → `done`: пользователь нажал «Сделано» (из `scheduled` — заранее, из инбокса). - любое → `withdrawn`: DELETE от клиента. Пересчёт состояния из дат (при upsert): берётся «сейчас» в `tz` пользователя и применяются те же условия, что и переходы. Событие, пришедшее уже в день срока, сразу становится `today`; пришедшее с `fire_on` в прошлом — `preparing`. Задним числом ничего не доставляется. Бейдж = число событий пользователя в `today` + `overdue`. ## 7. Каскад Точки подготовки нужны только для `preparing` и только чтобы знать, до какого дня молчать после «Помню». Не хранятся, считаются из `fire_on` и дня срока (`due_day`, вычисленного по режиму из §4): ``` d = due_day - fire_on // дней points = [] while d >= 1: points.append(due_day - d) d = d div 2 ``` | `fire_on` за | точки, дней до срока | |---|---| | 0 | нет точек, `preparing` не бывает | | 1 | 1 | | 7 | 7, 3, 1 | | 30 | 30, 15, 7, 3, 1 | | 60 | 60, 30, 15, 7, 3, 1 | | 180 | 180, 90, 45, 22, 11, 5, 2, 1 | ## 8. Окна, «Помню», «Сделано» Напоминание не шлётся в момент — ищется внутри **окна**. Окна лежат внутри активных часов пользователя `[quiet_end, quiet_start)`, по умолчанию 9:00–22:00 в его `tz`. | state | окна | fallback | |---|---|---| | `preparing` | одно в день: 9:00–22:00 | за 30 мин до конца окна | | `today` | каждый час: 9:00–10:00, 10:00–11:00, … 21:00–22:00 | только в трёх окнах дня: 9:00, 14:00, 20:00 (`HADO_FALLBACK_HOURS`), за 10 мин до их конца | | `overdue` | одно в день: 9:00–22:00 | за 30 мин до конца окна | Часовая частота в `today` — только для каналов, которые сказали `present`: ты рядом, тебя можно дёрнуть, ты ответишь за минуту. Если никто не `present`, стрелять наугад каждый час нельзя: это до 13 сообщений в день в Telegram, и такой бот замьютят через неделю — ровно та петля, от которой уходим. Поэтому fallback-доставка в `today` ограничена тремя окнами: утро, день, вечер. Если момент срока в `today` наступает посреди окна, окно обрезается по нему, и два следствия: fallback-запас считается **от обрезанного конца** (срок 9:30 → fallback в 9:20, а не в 9:50 за краем), а само обрезанное окно **всегда fallback-окно**, независимо от `HADO_FALLBACK_HOURS`: это последний шанс перед сроком, его нельзя пропустить из-за того, что час не попал в список. Событие получает **не больше одной доставки на окно** (`deliveries.window_start`), и только если `now >= quiet_until`. **«Помню»** (`ack`) — «молчи до следующей контрольной точки». Ставит `quiet_until`: | state | `quiet_until` | |---|---| | `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — старт дня срока (§6) | | `today` | начало окна через одно (следующий час пропускаем) | | `overdue` | 00:00 послезавтра (завтрашнее окно пропускаем) | Пример `preparing`: `fire_on` за 30 дней, напомнило на −30, −29, −28; «Помню» на −28 → тишина до −15, там снова каждый день до следующего «Помню» → до −7. Пример `today`: напомнило в 11:00, «Помню» → 12:00 молчит, 13:00 снова. **«Сделано»** (`done`) — событие в `done`, `done_at = now`, напоминания и бейдж снимаются. Работает в любой момент, хоть за два месяца до срока. Действия доступны тремя путями: кнопки в веб-инбоксе (`POST /me/events/{id}/ack|done`, под SSO), кнопки Telegram (callback `ack:{token}` / `done:{token}`) и подписанные ссылки для webhook (`POST /a/{token}/ack`, `POST /a/{token}/done`). `token` — это `deliveries.action_token`, живёт до терминального состояния события. ## 9. Доставка по присутствию Каждый канал реализует один контракт: ```php interface Channel { /** present | absent | unknown */ public function presence(User $user, array $config): Presence; public function deliver(Event $event, User $user, array $config, string $actionToken): DeliveryResult; } ``` Как каналы отвечают на `presence`: | канал | `present` | иначе | |---|---|---| | `webhook` (HA) | GET `presence_url` вернул `{"present": true}` | `absent` при `false`, `unknown` при ошибке/таймауте | | `telegram` | пользователь жал кнопку или писал боту за последние 10 мин | `unknown` (бот не видит онлайн-статус) | | `web` (инбокс) | последний `POST /me/heartbeat` был < 90 с назад | `absent` | Честно про реальность: присутствие по-настоящему работает у HA и веба. Telegram почти всегда `unknown` (боту никто не пишет), поэтому он получает в основном fallback-доставки. Это осознанно: Telegram — запасной канал, не основной. Heartbeat веба шлётся **только когда вкладка видима** (`visibilityState === 'visible'`) **и было движение мыши или клавиатуры за последние 3 минуты**. Забытая вкладка в фоне на десктопе в другой комнате не должна отвечать «пользователь тут», иначе она молча съест все реальные доставки в Telegram и HA. `web` — обычный канал в таблице `channels`, создаётся автоматически вместе с пользователем, удалить нельзя. Его `deliver` ничего не шлёт (инбокс и так показывает всё), только пишет запись в `deliveries`: окно закрыто, пользователь увидел в инбоксе. Зато присутствие он определяет полноценно: если открыта вкладка, доставлять в Telegram незачем. Алгоритм тика (раз в минуту) для каждого события в `preparing/today/overdue`, находящегося в открытом окне, без строки в `deliveries` за это окно и с `now >= quiet_until`: 1. Спросить `presence()` у всех включённых каналов пользователя и у `web`. Параллельно, таймаут 2 с, ответ кэшируется на 60 с. 2. Есть `present` → доставить только в них. Окно закрыто. 3. Никого `present`, окно не fallback-окно или до его конца больше запаса → ждать следующего тика. 4. Никого `present`, fallback-окно, до конца меньше запаса → доставить во **все** включённые каналы. Лучше один раз наугад, чем пропустить окно. **Защита от двойной доставки.** «Доставить» в тике означает: вставить строку `deliveries (event_id, channel_id, window_start, result = pending)` **в момент диспатча**, в той же транзакции, и только потом поставить job в очередь. Job делает HTTP-вызов и апдейтит `result` / `sent_at` / `error`. Следующий тик видит строку и событие в это окно больше не трогает, даже если HA отвечает две минуты. `UNIQUE (event_id, channel_id, window_start)` — страховка на случай двух параллельных тиков. Job с ретраями (3 попытки, экспоненциально), пока не исчерпал — `pending`. **Почему тик раз в минуту, хотя окна часовые.** Тик нужен не для окон, а для присутствия: окно 9:00–22:00 «выстреливает» в ту минуту, когда ты пришёл домой или открыл инбокс. При тике раз в 15 минут ты ждёшь до 15 минут после того, как уже сел за комп. Тик дешёвый: один запрос «события в открытых окнах без доставки», presence кэшируется на минуту, если таких событий нет — тик ничего не делает. Момент срока (`today → expired` в 14:00) тоже отслеживается с точностью до минуты. У пользователя без каналов доставок нет, события видны только в инбоксе. Это нормальное состояние, не ошибка. ### Что шлют каналы `telegram`: одно сообщение `title` + `subtitle` + inline-кнопки «Помню» и `done_label`. Повтор в следующем окне — новое сообщение. После `done` кнопки на старых сообщениях убираются (edit reply markup, best effort). `webhook`: `POST deliver_url` ```json { "event": { "id": 17, "state": "today", "due_at": "2026-08-14", "source": "docs", "title": "Сегодня ДР — Вася", "subtitle": "исполняется 34", "deep_link": "/people/42", "done_label": "Поздравил" }, "actions": { "ack": "https://hado.../a//ack", "done": "https://hado.../a//done" } } ``` ## 10. Пользователь: пояс, тихие часы, инбокс ``` GET /me → { login, tz, quiet_start, quiet_end, badge } PATCH /me { tz?, quiet_start?, quiet_end? } GET /me/events?state=... → список (по умолчанию все нетерминальные) POST /me/events/{id}/ack POST /me/events/{id}/done POST /me/heartbeat GET /me/channels POST /me/channels { type: webhook, config: {deliver_url, presence_url} } DELETE /me/channels/{id} POST /me/channels/telegram/link → { code, bot_url } // затем /start боту ``` Пользователь создаётся при первом запросе с новым `X-Remote-User`, `tz` из `HADO_DEFAULT_TZ`. Смена `tz` — руками через `PATCH /me` или из HA; позже iOS будет обновлять сама. Два дополнения по дизайн-хендоффу от 2026-09-04 (`docs/design/handoff/`): ``` POST /me/events { title, subtitle?, due_date, due_time? } → ручное событие. Источник `manual` зарезервирован, токена у него нет, через /api он недоступен. fire_at = срок − 30 дней, after_due = keep, done_label «Сделано». Так ручное событие само попадает в «Грядёт» за месяц, до этого лежит в «Запланировано». GET /me/archive → закрытые события (done, expired), новые сверху, до 100 штук, с подписью закрытия: done_label или «Истекло». withdrawn в архиве не показывается. Глубина архива = retention. ``` Веб-инбокс — одна Blade-страница под SSO по дизайну Sekai (dark only): список событий по группам `today`, `overdue`, `preparing`, `scheduled`, у каждого кнопки «Помню» / «Сделано», `deep_link` ведёт в клиент; форма «+ Добавить» для ручного события; сворачиваемый архив; блок настроек: пояс, тихие часы, каналы, привязка Telegram. Страница держит heartbeat. Никакого SPA. ## 11. Доступ Три входа, три механизма: | вход | маршруты | авторизация | |---|---|---| | клиенты | `/api/*` | Bearer-токен источника; `php artisan hado:source:create ` печатает токен один раз, в базе хэш | | пользователь | `/`, `/me/*` | sekai `forward_auth`, ядро доверяет `X-Remote-User` (сеть изолирована, как в спеке sekai) | | действия из каналов | `/a/{token}/*`, `/hooks/telegram` | случайный `action_token` доставки; Telegram дополнительно проверяет `X-Telegram-Bot-Api-Secret-Token` | `/api/*`, `/a/*` и `/hooks/*` в Caddy идут **мимо** `forward_auth`, всё остальное через него. `X-Remote-User` на этих маршрутах игнорируется. ## 12. Деплой Docker compose, как у sekai: - `app` — serversideup/php, HTTP. - `scheduler` — тот же образ, `php artisan schedule:work`, тик раз в минуту. - `worker` — тот же образ, `php artisan queue:work`, доставки и опросы присутствия. - `postgres`. - Cache/queue driver — `database` на старте (одна зависимость меньше); Redis, если упрёмся. Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `HADO_PRESENCE_TIMEOUT=2`, `HADO_FALLBACK_DAY=30`, `HADO_FALLBACK_HOUR=10`, `HADO_FALLBACK_HOURS=9,14,20`, `HADO_RETENTION_DAYS=90`. В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути (`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`. ## 13. Тесты Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы. Юнит: - Каскад: таблица из §7, включая `fire_on = день срока` → пусто. - Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом. - `quiet_until` по трём состояниям из §8. - Алгоритм окна: доставил только в `present`; ждал при отсутствии; fallback во все за N минут до конца; в `today` fallback только в трёх окнах; одно окно — одна доставка (строка `pending` ставится до job-а, повторный тик её видит); `quiet_until` блокирует; окно `today` обрезается моментом срока; смена `tz` сдвигает окна; тихие часы. - Режимы срока: `local` едет за `tz`, `fixed` не едет (вылет 08:15+02:00 при `tz = America/Montevideo` даёт момент 03:15 и старт `today` в 20:00 накануне). - Ранний срок: `due_time` раньше `quiet_end` → `today` начинается накануне в `quiet_start − 2h`, а не уходит в `expired` без единого окна. - Перенос: upsert с новым `due_at` сбрасывает состояние, в том числе из `today` и из терминального (это задокументированное поведение для переносов, тест фиксирует его, чтобы контракт §5 про отдельные строки на вхождения не забыли). - Вхождения: два события с одним `source_ref` и разными `topic` живут независимо, `overdue` сентября не мешает `scheduled` октября. - Обрезанное окно: срок 9:30 → fallback в 9:20; срок 11:15 → окно 11:00–11:15 становится fallback-окном, хотя 11 нет в `HADO_FALLBACK_HOURS`. - Retention: терминальные события старше `HADO_RETENTION_DAYS` удаляются, живые нет. Фичевые (HTTP): - Upsert пачкой: создание, повтор с тем же `due_at` (payload обновился, `done` остался), повтор с новым `due_at` (реанимация из `done`), 422 на плохую запись, 401 на чужой токен. - DELETE → `withdrawn`; GET состояния. - `/me/*` под `X-Remote-User`: автосоздание пользователя, `ack`/`done`, бейдж. - `/a/{token}/done` закрывает событие; неверный токен → 404. - Telegram: webhook с секретом, callback `ack:`/`done:`, привязка через код. Каналы с `Http::fake()`: доставка и presence у `webhook` (в т.ч. таймаут → `unknown`), Telegram `sendMessage` и `editMessageReplyMarkup`. ## 14. Отвергнутые варианты - **Ядро опрашивает клиентов (`derive`).** Заставляло бы каждый клиент хранить и планировать уведомления у себя. Шина работает наоборот: клиенты пушат. - **Тип события как enum `day-of / deadline`.** Склеивал два независимых свойства (есть ли подготовка, актуально ли после срока). Заменён на `fire_at = due_at` и `after_due`. - **Фиксированное расписание в ядре (−30, −15, −7).** Не подходит событиям с разной глубиной подготовки. Заменено каскадом половинок от `fire_on`. - **Пояс всегда из `due_at`.** Пользователь переезжает, для ДР и сроков документов пояс — его свойство, не события. Но для вылета пояс — свойство события, поэтому вместо «всегда» — два режима (§4): смещение в `due_at` есть → `fixed`, нет → `local`. - **Telegram как канал с присутствием.** Бот не видит онлайн-статус, presence почти всегда `unknown`. Признано: Telegram — fallback-канал, часовая частота `today` для него не работает, иначе 13 сообщений в день. - **Таблица шагов (`nudges`).** Одно поле `quiet_until` плюс лог доставок покрывают то же самое без второй сущности. - **`snoozed_until` и ручное откладывание.** Никто не будет вручную двигать даты; «Помню» делает это по правилу состояния.