Files
hado/docs/superpowers/specs/2026-09-03-hado-core-design.md

511 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <token источника>`. Источник берётся из
токена, в 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:0022:00
в его `tz`.
| state | окна | fallback |
|---|---|---|
| `preparing` | одно в день: 9:0022:00 | за 30 мин до конца окна |
| `today` | каждый час: 9:0010:00, 10:0011:00, … 21:0022:00 | только в трёх окнах дня: 9:00, 14:00, 20:00 (`HADO_FALLBACK_HOURS`), за 10 мин до их конца |
| `overdue` | одно в день: 9:0022: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:0022: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/<token>/ack", "done": "https://hado.../a/<token>/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 <code> боту
```
Пользователь создаётся при первом запросе с новым `X-Remote-User`, `tz` из
`HADO_DEFAULT_TZ`. Смена `tz` — руками через `PATCH /me` или из HA; позже iOS будет
обновлять сама.
Веб-инбокс — одна Blade-страница под SSO: список событий по группам `today`,
`overdue`, `preparing`, `scheduled`, у каждого кнопки «Помню» / «Сделано»,
`deep_link` ведёт в клиент. Блок настроек: пояс, тихие часы, каналы, привязка
Telegram. Страница держит heartbeat. Никакого SPA.
## 11. Доступ
Три входа, три механизма:
| вход | маршруты | авторизация |
|---|---|---|
| клиенты | `/api/*` | Bearer-токен источника; `php artisan hado:source:create <name>` печатает токен один раз, в базе хэш |
| пользователь | `/`, `/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:0011: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` и ручное откладывание.** Никто не будет вручную двигать даты;
«Помню» делает это по правилу состояния.