Files
hado/docs/superpowers/specs/2026-09-03-hado-core-design.md
2026-09-03 23:21:59 -03:00

401 lines
24 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", по умолчанию ""
due_date date
due_time time -- момент срока внутри дня; 24:00:00 = весь день
fire_on date -- старт подготовки; = due_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 -- для ссылок «Помню»/«Сделано» из канала
sent_at, result enum(ok, failed), error text nullable
```
Даты события хранятся **как календарные, без пояса**. Пояс берётся у пользователя
в момент вычисления. Переехал в другую страну, сменил `tz` — все окна сдвинулись
сами, пересчитывать нечего.
Кэш присутствия живёт в 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",
"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", "id": 17, "state": "scheduled" } ] }
```
Правила разбора `due_at`: строка `YYYY-MM-DD` → `due_date`, `due_time = 24:00`
(весь день). Строка `YYYY-MM-DDTHH:MM` → `due_date` + `due_time`. Смещение в
строке игнорируется, дата календарная. `fire_at` — только `YYYY-MM-DD`, не
позже `due_date`. Валидация: `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` + `birthday` и новым `due_at`.
### Снятие и чтение
```
DELETE /api/events?source_ref=person:42&topic=birthday → 204, state = withdrawn
GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, due_at, ... }
```
`topic` в запросе можно опустить — тогда пустая строка.
## 6. Жизненный цикл события
`state` — это и есть фаза. Хранится в базе, меняет крон (тик раз в минуту) по
календарю пользователя в его `tz`.
| state | смысл | напоминает | в бейдже |
|---|---|---|---|
| `scheduled` | день старта ещё не пришёл | нет | нет |
| `preparing` | идёт подготовка (`fire_on <= сегодня < due_date`) | раз в день | нет |
| `today` | день срока, момент срока ещё не наступил | каждый час | да |
| `overdue` | момент срока прошёл, `after_due = keep` | раз в день | да |
| `done` | нажал «Сделано» | нет | нет |
| `expired` | момент срока прошёл, `after_due = expire` | нет | нет |
| `withdrawn` | клиент прислал DELETE | нет | нет |
Переходы:
- `scheduled → preparing`: наступило 00:00 дня `fire_on`, и `fire_on < due_date`.
- `scheduled → today`, `preparing → today`: наступило 00:00 дня `due_date`.
- `today → overdue`: наступил момент `due_date + due_time`, `after_due = keep`.
- `today → expired`: наступил момент `due_date + due_time`, `after_due = expire`.
- любое из `scheduled / preparing / today / overdue` → `done`: пользователь нажал
«Сделано» (из `scheduled` — заранее, из инбокса).
- любое → `withdrawn`: DELETE от клиента.
Пересчёт состояния из дат (при upsert): берётся «сейчас» в `tz` пользователя и
применяются те же условия, что и переходы. Событие, пришедшее уже в день срока,
сразу становится `today`; пришедшее с `fire_on` в прошлом — `preparing`. Задним
числом ничего не доставляется.
Бейдж = число событий пользователя в `today` + `overdue`.
## 7. Каскад
Точки подготовки нужны только для `preparing` и только чтобы знать, до какого
дня молчать после «Помню». Не хранятся, считаются из `fire_on` и `due_date`:
```
d = due_date - fire_on // дней
points = []
while d >= 1:
points.append(due_date - 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 | 10 мин |
| `overdue` | одно в день: 9:0022:00 | 30 мин |
Если момент срока в `today` наступает посреди окна, окно обрезается по нему.
Событие получает **не больше одной доставки на окно** (`deliveries.window_start`),
и только если `now >= quiet_until`.
**«Помню»** (`ack`) — «молчи до следующей контрольной точки». Ставит `quiet_until`:
| state | `quiet_until` |
|---|---|
| `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — 00:00 `due_date` |
| `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` раз в 30 с, последний был < 90 с назад | `absent` |
`web` — обычный канал в таблице `channels`, создаётся автоматически вместе с
пользователем, удалить нельзя. Его `deliver` ничего не шлёт (инбокс и так
показывает всё), только пишет запись в `deliveries`: окно закрыто, пользователь
увидел в инбоксе. Зато присутствие он определяет полноценно: если открыта
вкладка, доставлять в Telegram незачем.
Алгоритм тика (раз в минуту) для каждого события в `preparing/today/overdue`,
находящегося в открытом окне, без доставки в этом окне и с `now >= quiet_until`:
1. Спросить `presence()` у всех включённых каналов пользователя и у `web`.
Параллельно, таймаут 2 с, ответ кэшируется на 60 с.
2. Есть `present` → доставить только в них. Окно закрыто.
3. Никого `present`, до конца окна больше fallback-запаса → ждать следующего тика.
4. Никого `present`, до конца окна меньше fallback-запаса → доставить во **все**
включённые каналы. Лучше один раз наугад, чем пропустить окно.
Сама доставка — job в очереди с ретраями (3 попытки, экспоненциально), чтобы
медленный HA не тормозил тик. Результат каждой попытки в `deliveries`.
У пользователя без каналов доставок нет, события видны только в инбоксе. Это
нормальное состояние, не ошибка.
### Что шлют каналы
`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`.
В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
## 13. Тесты
Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.
Юнит:
- Каскад: таблица из §7, включая `fire_on = due_date` → пусто.
- Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом.
- `quiet_until` по трём состояниям из §8.
- Алгоритм окна: доставил только в `present`; ждал при отсутствии; fallback во все
за N минут до конца; одно окно — одна доставка; `quiet_until` блокирует; окно
`today` обрезается моментом срока; смена `tz` сдвигает окна; тихие часы.
Фичевые (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`.** Пользователь переезжает, пояс — его свойство, не события.
- **Таблица шагов (`nudges`).** Одно поле `quiet_until` плюс лог доставок
покрывают то же самое без второй сущности.
- **`snoozed_until` и ручное откладывание.** Никто не будет вручную двигать даты;
«Помню» делает это по правилу состояния.