docs: spec review fixes — recurrence rule, fallback caps, web presence, fixed-mode due, delivery race, early due_time

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
2026-09-03 23:39:39 -03:00
parent e08808bb77
commit a845d9d4ac

View File

@@ -64,9 +64,14 @@ events
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, если подготовки нет
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)
@@ -86,12 +91,26 @@ deliveries
id, event_id FK, channel_id FK,
window_start timestamptz -- начало окна, в которое доставлено
action_token string UNIQUE -- для ссылок «Помню»/«Сделано» из канала
sent_at, result enum(ok, failed), error text nullable
result enum(pending, ok, failed) -- pending ставится в момент диспатча
sent_at timestamptz nullable, error text nullable
UNIQUE (event_id, channel_id, window_start)
```
Даты события хранятся **как календарные, без пояса**. Пояс берётся у пользователя
в момент вычисления. Переехал в другую страну, сменил `tz` — все окна сдвинулись
сами, пересчитывать нечего.
Два режима срока. `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 с), в
таблицах его нет.
@@ -126,12 +145,21 @@ PUT /api/events
→ 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 на всю пачку с
указанием индекса, ничего не применяется.
Правила разбора `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 по существующему событию (та же тройка идентичности):
@@ -143,6 +171,16 @@ PUT /api/events
Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с
тем же `person:42` + `birthday` и новым `due_at`.
**Обязательное правило для повторяющихся событий.** Ядро не отличает «клиент
перевёл ДР на следующий год» от «рейс перенесли в день вылета» — оба легитимны,
оба выглядят как новый `due_at`. Поэтому клиент **не переключает событие на
следующее вхождение, пока текущее не стало вчерашним**: следующий ДР считается
как ближайший с датой `>= сегодня`, а не `> сегодня`. Иначе в само утро ДР клиент
пришлёт дату следующего года, ядро сбросит живое `today` в `scheduled`, и
напоминание исчезнет ровно в тот день, ради которого всё делалось. Для событий с
`after_due: keep` клиент переключает вхождение только когда текущее в `done`
(проверяется `GET /api/events`), иначе он сам оборвёт просроченный долбёж.
### Снятие и чтение
```
@@ -160,7 +198,7 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
| state | смысл | напоминает | в бейдже |
|---|---|---|---|
| `scheduled` | день старта ещё не пришёл | нет | нет |
| `preparing` | идёт подготовка (`fire_on <= сегодня < due_date`) | раз в день | нет |
| `preparing` | идёт подготовка (`fire_on <= сегодня < день срока`) | раз в день | нет |
| `today` | день срока, момент срока ещё не наступил | каждый час | да |
| `overdue` | момент срока прошёл, `after_due = keep` | раз в день | да |
| `done` | нажал «Сделано» | нет | нет |
@@ -169,10 +207,16 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
Переходы:
- `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`: наступило 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 от клиента.
@@ -187,13 +231,13 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
## 7. Каскад
Точки подготовки нужны только для `preparing` и только чтобы знать, до какого
дня молчать после «Помню». Не хранятся, считаются из `fire_on` и `due_date`:
дня молчать после «Помню». Не хранятся, считаются из `fire_on` и дня срока (`due_day`, вычисленного по режиму из §4):
```
d = due_date - fire_on // дней
d = due_day - fire_on // дней
points = []
while d >= 1:
points.append(due_date - d)
points.append(due_day - d)
d = d div 2
```
@@ -212,11 +256,17 @@ while d >= 1:
активных часов пользователя `[quiet_end, quiet_start)`, по умолчанию 9:0022:00
в его `tz`.
| state | окна | fallback-запас |
| state | окна | fallback |
|---|---|---|
| `preparing` | одно в день: 9:0022:00 | 30 мин |
| `today` | каждый час: 9:0010:00, 10:0011:00, … 21:0022:00 | 10 мин |
| `overdue` | одно в день: 9:0022:00 | 30 мин |
| `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` наступает посреди окна, окно обрезается по нему.
@@ -227,7 +277,7 @@ while d >= 1:
| state | `quiet_until` |
|---|---|
| `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — 00:00 `due_date` |
| `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — старт дня срока (§6) |
| `today` | начало окна через одно (следующий час пропускаем) |
| `overdue` | 00:00 послезавтра (завтрашнее окно пропускаем) |
@@ -263,8 +313,17 @@ interface Channel
| канал | `present` | иначе |
|---|---|---|
| `webhook` (HA) | GET `presence_url` вернул `{"present": true}` | `absent` при `false`, `unknown` при ошибке/таймауте |
| `telegram` | пользователь писал боту или жал кнопку за последние 10 мин | `unknown` (бот не видит онлайн-статус) |
| `web` (инбокс) | вкладка инбокса шлёт `POST /me/heartbeat` раз в 30 с, последний был < 90 с назад | `absent` |
| `telegram` | пользователь жал кнопку или писал боту за последние 10 мин | `unknown` (бот не видит онлайн-статус) |
| `web` (инбокс) | последний `POST /me/heartbeat` был < 90 с назад | `absent` |
Честно про реальность: присутствие по-настоящему работает у HA и веба. Telegram
почти всегда `unknown` (боту никто не пишет), поэтому он получает в основном
fallback-доставки. Это осознанно: Telegram — запасной канал, не основной.
Heartbeat веба шлётся **только когда вкладка видима** (`visibilityState ===
'visible'`) **и было движение мыши или клавиатуры за последние 3 минуты**.
Забытая вкладка в фоне на десктопе в другой комнате не должна отвечать
«пользователь тут», иначе она молча съест все реальные доставки в Telegram и HA.
`web` — обычный канал в таблице `channels`, создаётся автоматически вместе с
пользователем, удалить нельзя. Его `deliver` ничего не шлёт (инбокс и так
@@ -273,17 +332,32 @@ interface Channel
вкладка, доставлять в Telegram незачем.
Алгоритм тика (раз в минуту) для каждого события в `preparing/today/overdue`,
находящегося в открытом окне, без доставки в этом окне и с `now >= quiet_until`:
находящегося в открытом окне, без строки в `deliveries` за это окно и с
`now >= quiet_until`:
1. Спросить `presence()` у всех включённых каналов пользователя и у `web`.
Параллельно, таймаут 2 с, ответ кэшируется на 60 с.
2. Есть `present` → доставить только в них. Окно закрыто.
3. Никого `present`, до конца окна больше fallback-запаса → ждать следующего тика.
4. Никого `present`, до конца окна меньше fallback-запаса → доставить во **все**
3. Никого `present`, окно не fallback-окно или до его конца больше запаса →
ждать следующего тика.
4. Никого `present`, fallback-окно, до конца меньше запаса → доставить во **все**
включённые каналы. Лучше один раз наугад, чем пропустить окно.
Сама доставка — job в очереди с ретраями (3 попытки, экспоненциально), чтобы
медленный HA не тормозил тик. Результат каждой попытки в `deliveries`.
**Защита от двойной доставки.** «Доставить» в тике означает: вставить строку
`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) тоже отслеживается с точностью до минуты.
У пользователя без каналов доставок нет, события видны только в инбоксе. Это
нормальное состояние, не ошибка.
@@ -355,7 +429,7 @@ Docker compose, как у sekai:
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_HOUR=10`, `HADO_FALLBACK_HOURS=9,14,20`.
В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
@@ -365,12 +439,20 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.
Юнит:
- Каскад: таблица из §7, включая `fire_on = due_date` → пусто.
- Каскад: таблица из §7, включая `fire_on = день срока` → пусто.
- Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом.
- `quiet_until` по трём состояниям из §8.
- Алгоритм окна: доставил только в `present`; ждал при отсутствии; fallback во все
за N минут до конца; одно окно — одна доставка; `quiet_until` блокирует; окно
`today` обрезается моментом срока; смена `tz` сдвигает окна; тихие часы.
за 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 не забыли).
Фичевые (HTTP):
- Upsert пачкой: создание, повтор с тем же `due_at` (payload обновился, `done`
@@ -393,7 +475,13 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
и `after_due`.
- **Фиксированное расписание в ядре (30, 15, 7).** Не подходит событиям с
разной глубиной подготовки. Заменено каскадом половинок от `fire_on`.
- **Пояс из `due_at`.** Пользователь переезжает, пояс — его свойство, не события.
- **Пояс всегда из `due_at`.** Пользователь переезжает, для ДР и сроков документов
пояс — его свойство, не события. Но для вылета пояс — свойство события, поэтому
вместо «всегда» — два режима (§4): смещение в `due_at` есть → `fixed`, нет →
`local`.
- **Telegram как канал с присутствием.** Бот не видит онлайн-статус, presence
почти всегда `unknown`. Признано: Telegram — fallback-канал, часовая частота
`today` для него не работает, иначе 13 сообщений в день.
- **Таблица шагов (`nudges`).** Одно поле `quiet_until` плюс лог доставок
покрывают то же самое без второй сущности.
- **`snoozed_until` и ручное откладывание.** Никто не будет вручную двигать даты;