diff --git a/docs/superpowers/specs/2026-09-03-hado-core-design.md b/docs/superpowers/specs/2026-09-03-hado-core-design.md index f5d2c03..6bc7a48 100644 --- a/docs/superpowers/specs/2026-09-03-hado-core-design.md +++ b/docs/superpowers/specs/2026-09-03-hado-core-design.md @@ -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:00–11:15 + становится fallback-окном, хотя 11 нет в `HADO_FALLBACK_HOURS`. +- Retention: терминальные события старше `HADO_RETENTION_DAYS` удаляются, живые нет. Фичевые (HTTP): - Upsert пачкой: создание, повтор с тем же `due_at` (payload обновился, `done`