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

32 KiB
Raw Blame History

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_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",
      "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, три формы:

форма пример режим что хранится
дата 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 + birthday и новым due_at.

Обязательное правило для повторяющихся событий. Ядро не отличает «клиент перевёл ДР на следующий год» от «рейс перенесли в день вылета» — оба легитимны, оба выглядят как новый due_at. Поэтому клиент не переключает событие на следующее вхождение, пока текущее не стало вчерашним: следующий ДР считается как ближайший с датой >= сегодня, а не > сегодня. Иначе в само утро ДР клиент пришлёт дату следующего года, ядро сбросит живое today в scheduled, и напоминание исчезнет ровно в тот день, ради которого всё делалось. Для событий с after_due: keep клиент переключает вхождение только когда текущее в done (проверяется GET /api/events), иначе он сам оборвёт просроченный долбёж.

Снятие и чтение

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 <= сегодня < день срока) раз в день нет
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 / overduedone: пользователь нажал «Сделано» (из 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 наступает посреди окна, окно обрезается по нему.

Событие получает не больше одной доставки на окно (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. Доставка по присутствию

Каждый канал реализует один контракт:

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

{
  "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.

В 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_endtoday начинается накануне в quiet_start 2h, а не уходит в expired без единого окна.
  • Годовой ре-пуш: upsert с новым due_at в день today сбрасывает событие (это задокументированное поведение, тест фиксирует его, чтобы контракт §5 не забыли).

Фичевые (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 и ручное откладывание. Никто не будет вручную двигать даты; «Помню» делает это по правилу состояния.