docs: hado-core design spec
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
400
docs/superpowers/specs/2026-09-03-hado-core-design.md
Normal file
400
docs/superpowers/specs/2026-09-03-hado-core-design.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# 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: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 | 10 мин |
|
||||
| `overdue` | одно в день: 9:00–22: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` и ручное откладывание.** Никто не будет вручную двигать даты;
|
||||
«Помню» делает это по правилу состояния.
|
||||
Reference in New Issue
Block a user