docs: spec review round 2 — per-occurrence rows for recurring events, truncated-window fallback, eve-rule wording

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:53:10 -03:00
parent a845d9d4ac
commit 669c6bca55

View File

@@ -63,7 +63,7 @@ events
source_id FK sources
user_id FK users
source_ref string -- "person:42"
topic string -- "birthday", по умолчанию ""
topic string -- "birthday:2026", "payment:2026-10"; по умолчанию ""
due_mode enum(local, fixed)
-- local: срок календарный, живёт в поясе пользователя (ДР, седула)
-- fixed: срок — момент во времени, пояс зашит клиентом (вылет из Мадрида)
@@ -129,7 +129,7 @@ PUT /api/events
{
"user": "nikita",
"source_ref": "person:42",
"topic": "birthday",
"topic": "birthday:2026",
"due_at": "2026-08-14", // или "2026-09-10T14:00"
"fire_at": "2026-08-14", // = due_at, если подготовки нет
"after_due": "expire",
@@ -142,7 +142,7 @@ PUT /api/events
}
]
}
→ 200 { "events": [ { "source_ref": "person:42", "topic": "birthday", "id": 17, "state": "scheduled" } ] }
→ 200 { "events": [ { "source_ref": "person:42", "topic": "birthday:2026", "id": 17, "state": "scheduled" } ] }
```
Правила разбора `due_at`, три формы:
@@ -169,23 +169,34 @@ PUT /api/events
| `due_at` другой | это новый заход (ДР следующего года, перенесённый рейс): обновляет всё, сбрасывает `quiet_until`, пересчитывает состояние из дат **даже из терминального**. |
Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с
тем же `person:42` + `birthday` и новым `due_at`.
тем же `person:42` и новым `topic` — см. правило ниже.
**Обязательное правило для повторяющихся событий.** Ядро не отличает «клиент
перевёл ДР на следующий год» от «рейс перенесли в день вылета» — оба легитимны,
оба выглядят как новый `due_at`. Поэтому клиент **не переключает событие на
следующее вхождение, пока текущее не стало вчерашним**: следующий ДР считается
как ближайший с датой `>= сегодня`, а не `> сегодня`. Иначе в само утро ДР клиент
пришлёт дату следующего года, ядро сбросит живое `today` в `scheduled`, и
напоминание исчезнет ровно в тот день, ради которого всё делалось. Для событий с
`after_due: keep` клиент переключает вхождение только когда текущее в `done`
(проверяется `GET /api/events`), иначе он сам оборвёт просроченный долбёж.
**Повторяющиеся события: каждое вхождение — отдельная строка.** Клиент кладёт
период в `topic`: `birthday:2026`, `birthday:2027`, `payment:2026-09`,
`payment:2026-10`. Тогда вхождения независимы: сентябрьская аренда висит в
`overdue` и долбит, октябрьская спокойно создаётся рядом со своим каскадом,
следующий ДР можно запушить хоть за год — он лежит в `scheduled` и молчит.
Незакрытые `keep`-вхождения копятся, и это правильно: в этом смысл `keep`.
Почему нельзя переиспользовать одну строку под все годы. Ядро не отличает
«клиент перевёл ДР на следующий год» от «рейс перенесли в день вылета» — оба
выглядят как новый `due_at` и оба сбрасывают состояние. В само утро ДР клиент
прислал бы дату следующего года и стёр живое `today`. А для `keep` было бы ещё
хуже: пока сентябрь не закрыт, октябрь запушить нельзя, иначе оборвётся
просроченный долбёж, — и октябрьского напоминания не существует вообще.
Правило «`due_at` другой → сбросить состояние» остаётся для настоящих переносов:
рейс перенесли, срок документа продлили. Это то же событие с новой датой.
Терминальные строки (`done`, `expired`, `withdrawn`) ядро удаляет через
`HADO_RETENTION_DAYS` (по умолчанию 90) после перехода в терминальное состояние,
чтобы вхождения не копились вечно.
### Снятие и чтение
```
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, ... }
DELETE /api/events?source_ref=person:42&topic=birthday:2026 → 204, state = withdrawn
GET /api/events?source_ref=person:42&topic=birthday:2026 → 200 { id, state, due_at, ... }
```
`topic` в запросе можно опустить — тогда пустая строка.
@@ -212,9 +223,10 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
это 00:00 дня срока. Но если момент срока раньше начала активных часов
(вылет в 07:00 при `quiet_end = 09:00`), первое окно дня уже опоздало бы, и
событие ушло в `expired`, не пикнув ни разу. Поэтому для таких событий старт дня
срока — начало последнего часового окна **накануне**: `quiet_start 2h`
предыдущего дня (20:00 при `quiet_start = 22:00`). Вечер накануне работает как
`today`: часовые окна, бейдж, всё как в день.
срока — **за два часа до тихих часов накануне**: `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`: пользователь нажал
@@ -268,7 +280,11 @@ while d >= 1:
такой бот замьютят через неделю — ровно та петля, от которой уходим. Поэтому
fallback-доставка в `today` ограничена тремя окнами: утро, день, вечер.
Если момент срока в `today` наступает посреди окна, окно обрезается по нему.
Если момент срока в `today` наступает посреди окна, окно обрезается по нему, и
два следствия: fallback-запас считается **от обрезанного конца** (срок 9:30 →
fallback в 9:20, а не в 9:50 за краем), а само обрезанное окно **всегда
fallback-окно**, независимо от `HADO_FALLBACK_HOURS`: это последний шанс перед
сроком, его нельзя пропустить из-за того, что час не попал в список.
Событие получает **не больше одной доставки на окно** (`deliveries.window_start`),
и только если `now >= quiet_until`.
@@ -429,7 +445,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_HOURS=9,14,20`.
`HADO_FALLBACK_HOUR=10`, `HADO_FALLBACK_HOURS=9,14,20`, `HADO_RETENTION_DAYS=90`.
В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
@@ -451,8 +467,14 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
`tz = America/Montevideo` даёт момент 03:15 и старт `today` в 20:00 накануне).
- Ранний срок: `due_time` раньше `quiet_end` → `today` начинается накануне в
`quiet_start 2h`, а не уходит в `expired` без единого окна.
- Годовой ре-пуш: upsert с новым `due_at` в день `today` сбрасывает событие (это
задокументированное поведение, тест фиксирует его, чтобы контракт §5 не забыли).
- Перенос: upsert с новым `due_at` сбрасывает состояние, в том числе из `today` и
из терминального (это задокументированное поведение для переносов, тест
фиксирует его, чтобы контракт §5 про отдельные строки на вхождения не забыли).
- Вхождения: два события с одним `source_ref` и разными `topic` живут независимо,
`overdue` сентября не мешает `scheduled` октября.
- Обрезанное окно: срок 9:30 → fallback в 9:20; срок 11:15 → окно 11:0011:15
становится fallback-окном, хотя 11 нет в `HADO_FALLBACK_HOURS`.
- Retention: терминальные события старше `HADO_RETENTION_DAYS` удаляются, живые нет.
Фичевые (HTTP):
- Upsert пачкой: создание, повтор с тем же `due_at` (payload обновился, `done`