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 user_id FK users
source_ref string -- "person:42" source_ref string -- "person:42"
topic string -- "birthday", по умолчанию "" topic string -- "birthday", по умолчанию ""
due_date date due_mode enum(local, fixed)
due_time time -- момент срока внутри дня; 24:00:00 = весь день -- local: срок календарный, живёт в поясе пользователя (ДР, седула)
fire_on date -- старт подготовки; = due_date, если подготовки нет -- 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) after_due enum(keep, expire)
payload jsonb -- title, subtitle, deep_link, done_label payload jsonb -- title, subtitle, deep_link, done_label
state enum(scheduled, preparing, today, overdue, done, expired, withdrawn) state enum(scheduled, preparing, today, overdue, done, expired, withdrawn)
@@ -86,12 +91,26 @@ deliveries
id, event_id FK, channel_id FK, id, event_id FK, channel_id FK,
window_start timestamptz -- начало окна, в которое доставлено window_start timestamptz -- начало окна, в которое доставлено
action_token string UNIQUE -- для ссылок «Помню»/«Сделано» из канала 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)
``` ```
Даты события хранятся **как календарные, без пояса**. Пояс берётся у пользователя Два режима срока. `local` — дата календарная, без пояса; пояс берётся у
в момент вычисления. Переехал в другую страну, сменил `tz` — все окна сдвинулись пользователя в момент вычисления. Переехал в другую страну, сменил `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 с), в Кэш присутствия живёт в 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" } ] } → 200 { "events": [ { "source_ref": "person:42", "topic": "birthday", "id": 17, "state": "scheduled" } ] }
``` ```
Правила разбора `due_at`: строка `YYYY-MM-DD` → `due_date`, `due_time = 24:00` Правила разбора `due_at`, три формы:
(весь день). Строка `YYYY-MM-DDTHH:MM` → `due_date` + `due_time`. Смещение в
строке игнорируется, дата календарная. `fire_at` — только `YYYY-MM-DD`, не | форма | пример | режим | что хранится |
позже `due_date`. Валидация: `fire_at <= due_at`, `user` непустой, `payload.title` |---|---|---|---|
непустой, до 500 событий в пачке. Одна плохая запись → 422 на всю пачку с | дата | `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 по существующему событию (та же тройка идентичности): Правила upsert по существующему событию (та же тройка идентичности):
@@ -143,6 +171,16 @@ PUT /api/events
Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с
тем же `person:42` + `birthday` и новым `due_at`. тем же `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 | смысл | напоминает | в бейдже | | state | смысл | напоминает | в бейдже |
|---|---|---|---| |---|---|---|---|
| `scheduled` | день старта ещё не пришёл | нет | нет | | `scheduled` | день старта ещё не пришёл | нет | нет |
| `preparing` | идёт подготовка (`fire_on <= сегодня < due_date`) | раз в день | нет | | `preparing` | идёт подготовка (`fire_on <= сегодня < день срока`) | раз в день | нет |
| `today` | день срока, момент срока ещё не наступил | каждый час | да | | `today` | день срока, момент срока ещё не наступил | каждый час | да |
| `overdue` | момент срока прошёл, `after_due = keep` | раз в день | да | | `overdue` | момент срока прошёл, `after_due = keep` | раз в день | да |
| `done` | нажал «Сделано» | нет | нет | | `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 → preparing`: наступило 00:00 дня `fire_on`, и `fire_on` раньше дня срока.
- `scheduled → today`, `preparing → today`: наступило 00:00 дня `due_date`. - `scheduled → today`, `preparing → today`: наступил **старт дня срока**. Обычно
- `today → overdue`: наступил момент `due_date + due_time`, `after_due = keep`. это 00:00 дня срока. Но если момент срока раньше начала активных часов
- `today → expired`: наступил момент `due_date + due_time`, `after_due = expire`. (вылет в 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 / preparing / today / overdue` → `done`: пользователь нажал
«Сделано» (из `scheduled` — заранее, из инбокса). «Сделано» (из `scheduled` — заранее, из инбокса).
- любое → `withdrawn`: DELETE от клиента. - любое → `withdrawn`: DELETE от клиента.
@@ -187,13 +231,13 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
## 7. Каскад ## 7. Каскад
Точки подготовки нужны только для `preparing` и только чтобы знать, до какого Точки подготовки нужны только для `preparing` и только чтобы знать, до какого
дня молчать после «Помню». Не хранятся, считаются из `fire_on` и `due_date`: дня молчать после «Помню». Не хранятся, считаются из `fire_on` и дня срока (`due_day`, вычисленного по режиму из §4):
``` ```
d = due_date - fire_on // дней d = due_day - fire_on // дней
points = [] points = []
while d >= 1: while d >= 1:
points.append(due_date - d) points.append(due_day - d)
d = d div 2 d = d div 2
``` ```
@@ -212,11 +256,17 @@ while d >= 1:
активных часов пользователя `[quiet_end, quiet_start)`, по умолчанию 9:0022:00 активных часов пользователя `[quiet_end, quiet_start)`, по умолчанию 9:0022:00
в его `tz`. в его `tz`.
| state | окна | fallback-запас | | state | окна | fallback |
|---|---|---| |---|---|---|
| `preparing` | одно в день: 9:0022:00 | 30 мин | | `preparing` | одно в день: 9:0022:00 | за 30 мин до конца окна |
| `today` | каждый час: 9:0010:00, 10:0011:00, … 21:0022:00 | 10 мин | | `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 мин | | `overdue` | одно в день: 9:0022:00 | за 30 мин до конца окна |
Часовая частота в `today` — только для каналов, которые сказали `present`: ты
рядом, тебя можно дёрнуть, ты ответишь за минуту. Если никто не `present`,
стрелять наугад каждый час нельзя: это до 13 сообщений в день в Telegram, и
такой бот замьютят через неделю — ровно та петля, от которой уходим. Поэтому
fallback-доставка в `today` ограничена тремя окнами: утро, день, вечер.
Если момент срока в `today` наступает посреди окна, окно обрезается по нему. Если момент срока в `today` наступает посреди окна, окно обрезается по нему.
@@ -227,7 +277,7 @@ while d >= 1:
| state | `quiet_until` | | state | `quiet_until` |
|---|---| |---|---|
| `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — 00:00 `due_date` | | `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — старт дня срока (§6) |
| `today` | начало окна через одно (следующий час пропускаем) | | `today` | начало окна через одно (следующий час пропускаем) |
| `overdue` | 00:00 послезавтра (завтрашнее окно пропускаем) | | `overdue` | 00:00 послезавтра (завтрашнее окно пропускаем) |
@@ -263,8 +313,17 @@ interface Channel
| канал | `present` | иначе | | канал | `present` | иначе |
|---|---|---| |---|---|---|
| `webhook` (HA) | GET `presence_url` вернул `{"present": true}` | `absent` при `false`, `unknown` при ошибке/таймауте | | `webhook` (HA) | GET `presence_url` вернул `{"present": true}` | `absent` при `false`, `unknown` при ошибке/таймауте |
| `telegram` | пользователь писал боту или жал кнопку за последние 10 мин | `unknown` (бот не видит онлайн-статус) | | `telegram` | пользователь жал кнопку или писал боту за последние 10 мин | `unknown` (бот не видит онлайн-статус) |
| `web` (инбокс) | вкладка инбокса шлёт `POST /me/heartbeat` раз в 30 с, последний был < 90 с назад | `absent` | | `web` (инбокс) | последний `POST /me/heartbeat` был < 90 с назад | `absent` |
Честно про реальность: присутствие по-настоящему работает у HA и веба. Telegram
почти всегда `unknown` (боту никто не пишет), поэтому он получает в основном
fallback-доставки. Это осознанно: Telegram — запасной канал, не основной.
Heartbeat веба шлётся **только когда вкладка видима** (`visibilityState ===
'visible'`) **и было движение мыши или клавиатуры за последние 3 минуты**.
Забытая вкладка в фоне на десктопе в другой комнате не должна отвечать
«пользователь тут», иначе она молча съест все реальные доставки в Telegram и HA.
`web` — обычный канал в таблице `channels`, создаётся автоматически вместе с `web` — обычный канал в таблице `channels`, создаётся автоматически вместе с
пользователем, удалить нельзя. Его `deliver` ничего не шлёт (инбокс и так пользователем, удалить нельзя. Его `deliver` ничего не шлёт (инбокс и так
@@ -273,17 +332,32 @@ interface Channel
вкладка, доставлять в Telegram незачем. вкладка, доставлять в Telegram незачем.
Алгоритм тика (раз в минуту) для каждого события в `preparing/today/overdue`, Алгоритм тика (раз в минуту) для каждого события в `preparing/today/overdue`,
находящегося в открытом окне, без доставки в этом окне и с `now >= quiet_until`: находящегося в открытом окне, без строки в `deliveries` за это окно и с
`now >= quiet_until`:
1. Спросить `presence()` у всех включённых каналов пользователя и у `web`. 1. Спросить `presence()` у всех включённых каналов пользователя и у `web`.
Параллельно, таймаут 2 с, ответ кэшируется на 60 с. Параллельно, таймаут 2 с, ответ кэшируется на 60 с.
2. Есть `present` → доставить только в них. Окно закрыто. 2. Есть `present` → доставить только в них. Окно закрыто.
3. Никого `present`, до конца окна больше fallback-запаса → ждать следующего тика. 3. Никого `present`, окно не fallback-окно или до его конца больше запаса →
4. Никого `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`, Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
`TELEGRAM_WEBHOOK_SECRET`, `HADO_PRESENCE_TIMEOUT=2`, `HADO_FALLBACK_DAY=30`, `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.<домен>` с двумя матчерами: публичные пути В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`. (`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
@@ -365,12 +439,20 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы. Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.
Юнит: Юнит:
- Каскад: таблица из §7, включая `fire_on = due_date` → пусто. - Каскад: таблица из §7, включая `fire_on = день срока` → пусто.
- Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом. - Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом.
- `quiet_until` по трём состояниям из §8. - `quiet_until` по трём состояниям из §8.
- Алгоритм окна: доставил только в `present`; ждал при отсутствии; fallback во все - Алгоритм окна: доставил только в `present`; ждал при отсутствии; fallback во все
за N минут до конца; одно окно — одна доставка; `quiet_until` блокирует; окно за N минут до конца; в `today` fallback только в трёх окнах; одно окно — одна
`today` обрезается моментом срока; смена `tz` сдвигает окна; тихие часы. доставка (строка `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): Фичевые (HTTP):
- Upsert пачкой: создание, повтор с тем же `due_at` (payload обновился, `done` - Upsert пачкой: создание, повтор с тем же `due_at` (payload обновился, `done`
@@ -393,7 +475,13 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
и `after_due`. и `after_due`.
- **Фиксированное расписание в ядре (30, 15, 7).** Не подходит событиям с - **Фиксированное расписание в ядре (30, 15, 7).** Не подходит событиям с
разной глубиной подготовки. Заменено каскадом половинок от `fire_on`. разной глубиной подготовки. Заменено каскадом половинок от `fire_on`.
- **Пояс из `due_at`.** Пользователь переезжает, пояс — его свойство, не события. - **Пояс всегда из `due_at`.** Пользователь переезжает, для ДР и сроков документов
пояс — его свойство, не события. Но для вылета пояс — свойство события, поэтому
вместо «всегда» — два режима (§4): смещение в `due_at` есть → `fixed`, нет →
`local`.
- **Telegram как канал с присутствием.** Бот не видит онлайн-статус, presence
почти всегда `unknown`. Признано: Telegram — fallback-канал, часовая частота
`today` для него не работает, иначе 13 сообщений в день.
- **Таблица шагов (`nudges`).** Одно поле `quiet_until` плюс лог доставок - **Таблица шагов (`nudges`).** Одно поле `quiet_until` плюс лог доставок
покрывают то же самое без второй сущности. покрывают то же самое без второй сущности.
- **`snoozed_until` и ручное откладывание.** Никто не будет вручную двигать даты; - **`snoozed_until` и ручное откладывание.** Никто не будет вручную двигать даты;