diff --git a/openspec/changes/harden-hourly-sync-lifecycle/.openspec.yaml b/openspec/changes/harden-hourly-sync-lifecycle/.openspec.yaml new file mode 100644 index 0000000..d2578e7 --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: wealth-ledger-sdd-v2 +created: 2026-10-12 diff --git a/openspec/changes/harden-hourly-sync-lifecycle/design.md b/openspec/changes/harden-hourly-sync-lifecycle/design.md new file mode 100644 index 0000000..a4795af --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/design.md @@ -0,0 +1,220 @@ +# Engineering Design + +## Context and Constraints + +- Один backend-процесс; durable fence — reservation попытки в SQLite; таймер + процесса только предлагает работу (существующий инвариант, комментарии в + `tbank-hourly.scheduler.ts`). +- Итог попытки пишется CAS-обновлением только из состояния `running`: + устаревшая публикация после появления преемника уже отклоняется существующим + fence (`settleHourly` where `state: 'running'`; `read()` при несовпадении + `progress.attempts` помечает шаг `cancelled`). +- Сегодня итог вычисляется по in-memory списку ошибок walk (пуст при + исключении — ложный `succeeded`), ошибка `settleHourly` подавляется + `.catch(() => undefined)`, `settledWorks` поллит без deadline, а id строк + шагов не персистятся на hourly-попытке (`progress: { sections: [] }`). +- Финализаторы enrichment/operations могут очистить память без durable finish — + шаг остаётся `running`. +- Топология не меняется: без lease и мультреплик; CAS-ограничения + `attempt`/`revision`/`source` не ослабляются. + +## Technical Goals / Non-Goals + +**Goals** + +- Итог попытки выводится из durable-состояния шагов; ложный `succeeded` + невозможен ни на одном error-пути. +- Каждая попытка достигает терминального итога в ограниченное время + (ограничение ожидания на каждый шаг). +- Попытка с утраченным выполнением закрывается диагностируемо (`INTERRUPTED`) + и не блокирует следующий обход дольше обычного часового срока. +- Поздняя публикация шага текущей попытки сохраняется до фиксации итога; + после появления преемника отклоняется существующим fence. + +**Non-Goals** + +- Lease, очередь, вторая реплика, параллельный scheduler. +- Отмена уже начатых обращений к T-Bank. +- Новый публичный статус попытки и изменения HTTP contract. +- Graceful drain — только если тест контролируемой остановки докажет + необходимость. + +## Implementation Impact Map + +### main + +`N/A` — workspace/dev/release orchestration не меняется. + +### frontend + +`N/A` — публичный словарь состояний и форма `meta` не меняются. + +### backend + +`affected` — координатор `tbank-hourly.scheduler.ts` (walk/settle/ +settledWorks/launch), `tbank-sync.repository.ts` (`reserveHourly`/ +`settleHourly`), персист id шагов на попытке, durable finish в финализаторах +`catalog-enrichment.service.ts` и `brokerage-operations-sync.service.ts`, +изолированные SQLite-тесты reservation/coordinator. + +### qa + +`N/A` — QA repository не существует; системная верификация остаётся ручной по +QA Analysis. + +## Decisions + +### backend + +1. **Итог выводится из durable-состояния шагов (корневой фикс).** + `settleHourly` в одной транзакции читает строки шагов попытки и вычисляет + итог: `succeeded` — только когда каждый обязательный шаг успешен и закрытие + инициировано нормальным завершением walk; `partial` — успешны некоторые; + `failed` — ни один. Вызывающие больше не передают результат и список + ошибок; walk передаёт максимум `cause: 'INTERRUPTED'` (закрытие до + нормального завершения), который капит итог до `partial`. Исключение — + обязательные секции, чей dispatch не создал durable-строку шага: walk + передаёт их как дополнительные problem-записи (наблюдаемый факт + диспатча, не результат), иначе попытка могла бы получить ложный успех. + Альтернатива + «прокидывать настоящий failures-map в catch» отклонена: не закрывает + lost-settle и краш-пути — каждый error-путь требовал бы своего guard. + Отдельный janitor-сервис отклонён: issue требует чинить существующих + владельцев, не вводить параллельный scheduler. + +2. **Id шагов персистятся на попытке в момент dispatch.** `progress` + hourly-попытки переиспользует существующую форму `children: string[]` + + `attempts: { [id]: attemptId }` (паттерн `ScreenProgress`/`progress()`). + Walk записывает id каждой строки шага сразу после dispatch, до ожидания — + иначе recovery после краша не знает множество шагов попытки, а шаг, упавший + в dispatch, исчезает из множества. + +3. **Ограниченное ожидание на каждый шаг.** `settledWorks` получает per-id + deadline: константа `WORK_WAIT_BOUND_MS = 15 * 60 * 1000`; истёкший id + возвращается как проблемный шаг с причиной `DEADLINE`. Тем же bound покрыты + и ожидания walk до появления durable-строк шагов (review-замечание, + follow-up WealthLedger/backend#78): `membership.request`/`member.done`, + `access.local` и dispatch `portfolio`/`operations`/`events.refresh` гоняются + с `WORK_WAIT_BOUND_MS` через race; истечение трактуется как проблемная + секция (как failed dispatch), для `access.local` — как закрытие + `INTERRUPTED`. Вызов не отменяется; поздняя durable-строка fenced по + `attemptId`, как любая поздняя публикация. Управляемые часы в + тестах — jest fake timers, инъекция clock в production-код не вводится. + RPC не отменяется; поздний результат шага сохраняется его собственной + строкой — учтён ли он в итоге, решает fence и момент фиксации. + +4. **In-memory активные обходы + recovery на существующем tick.** + `TbankHourlyScheduler` держит `active: Set` (attemptId): добавление + в начале `walk`, удаление в `finally`. `launch()` перед `reserveHourly` + вызывает `recoverAbandoned(userId)`: если строка hourly `running`, + `processRunId` свой и attemptId не в `active` — попытка потеряла settle и + закрывается через `settleHourly` с `cause: 'INTERRUPTED'` (итог из детей, + максимум `partial`). Это же закрывает застрявшие legacy-строки `running`. + Новых таймеров и санитаров нет — восстановление едет на существующем + минутном tick. + +5. **Restart-ветка `reserveHourly` закрывает попытку итогом.** Существующая + ветка «running + чужой `processRunId`» вместо голой записи + `state: 'cancelled'` выполняет тот же derived settle с + `cause: 'INTERRUPTED'` внутри транзакции reservation; далее прежний + `due`-гейт: новый обход — не ранее следующего часового срока от начала + попытки. `cancelled` для hourly-строк перестаёт писаться; существующие + маппинги `cancelled` в `hourly()`/`hourlyAttempt()` остаются толерантным + чтением и не удаляются. + +6. **Причина прерывания — в существующем поле `errorCode`.** `INTERRUPTED` — + уровень попытки (закрытие до нормального завершения); `DEADLINE` — уровень + шага (истечение ожидания), попадает в `errorCode` попытки как первая ошибка + секции по существующему precedence. Коды ошибок шагов остаются в + `progress.sections[].errorCode`. Поле — открытое строковое: форма ответа и + enum `TbankErrorCode` не расширяются (маркеры жизненного цикла — не ошибки + T-Bank). + +7. **Durable finish в дочерних финализаторах.** Внешний catch финализаторов + (enrichment, operations drain/step) обязан перед возвратом или пробросом + записать терминальный неуспешный результат своей строки шага существующим + путём записи ошибки, а не только очистить память. Иначе шаг остаётся + `running`, и попытка не проходит terminal barrier. + +## Cross-cutting Implementation + +- Подавление ошибки `settleHourly` (`.catch(() => undefined)`) допустимо только + как пара к решению 4: `walk` удаляет attemptId из `active` в `finally`, и + следующий tick закрывает строку recovery. Ошибка фиксируется существующим + структурным логом. +- Новых фоновых таймеров, очередей и таблиц нет; concurrency не меняется + (один walk на подключение). + +## Data / Storage Changes + +- Миграции нет: `children`/`attempts` живут в существующем JSON `progress` + строки `tbankSyncWork`; поле `errorCode` уже в схеме + (`20260924170000_persist_tbank_data/migration.sql:177`). +- Legacy-застрявшие `running` строки закрываются первым recovery-проходом + (итог из детей + `INTERRUPTED`); чтение уже закрытых строк не меняется. + +## Contract Implementation + +- HTTP contract не меняется: форма `meta` и словарь состояний + (`running`/`succeeded`/`partial`/`failed`) прежние; `cancelled` остаётся в + enum DTO как толерантное чтение. +- До закрытия change: backend-generated OpenAPI проверяется на семантическую + совместимость с текущим контрактом — изменения формы не ожидаются; новые + значения существуют только внутри открытого строкового поля диагностики и + в hourly-read наружу не попадают. + +## Developer Verification Strategy + +- Изолированные SQLite-тесты reservation/coordinator (не stub + `upcoming-events`): ветки `first`, `not-due`, `running`, `restart`, + `partial`; итог проверяется после terminal barrier. +- Инъекция ошибок отдельно в дочерние шаги и в родительский settle; + восстановление после ошибки сохранения; защита от overlap и от поздней + публикации; controlled stop/restart — нет бесконечного drain, после + остановки новые reservations не создаются. +- Управляемые часы — jest fake timers. +- Сохранить существующие наборы auth/money/publication; полный + `backend verify` на финальном diff. +- Graceful drain не добавляется, пока тест остановки не покажет + необходимость. + +## UI/UX Package + +`N/A` — UI не меняется; новый mockup не требуется. + +## Risks / Trade-offs + +- Derivation зависит от полноты `children`: пропуск записи id при dispatch + делает шаг невидимым для итога. Митигируется решением 2 (запись до await) и + тестом инъекции ошибки в dispatch. +- Per-step deadline может закрыть попытку как `partial` при медленном, но + живом шаге; поздний результат сохраняется строкой шага, данные не теряются. +- In-memory `active` не переживает рестарт — этот случай закрывает + restart-ветка reservation (решение 5). +- Константа 15 минут без конфигурации: потолок опыта одного процесса + (`ponytail:` вынести в конфиг, если медленные шаги станут регулярными). + +## Migration / Rollout / Rollback + +- Rollout: один PR в backend; миграций и флагов нет; следующий tick после + деплоя закрывает застрявшие строки. +- Rollback contract: `N/A` — нет миграции, security или public contract + изменения; откат — revert кода, уже закрытые recovery строки остаются в + корректном терминальном виде. + +## Solution Assessment + +Обязателен (concurrency/recovery). Варианты: + +- (a) Root-cause: settle выводит итог из durable-детей; recovery на + существующем tick/reservation; один guard в общей точке. +- (b) Минимальный патч: настоящий failures-map в catch плюс guard в каждом + вызывающем. +- (c) Отдельный janitor/очередь с lease. + +Выбор — (a): закрывает все error-пути одной точкой (exception, lost settle, +краш, deadline), переиспользует существующие fence и `progress`-паттерн, без +новых компонентов. (b) оставляет открытыми lost-settle и краш — каждый новый +error-путь снова требует своего guard, что и есть текущий баг. (c) отклонён: +issue явно запрещает параллельный scheduler, lease не даёт выгоды в +одно-процессной топологии, а стоимость нового компонента выше фикса. diff --git a/openspec/changes/harden-hourly-sync-lifecycle/docs-impact.md b/openspec/changes/harden-hourly-sync-lifecycle/docs-impact.md new file mode 100644 index 0000000..e526f8d --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/docs-impact.md @@ -0,0 +1,43 @@ +# Documentation Impact + +## Summary + +Durable docs update нужен ограниченно: наблюдаемая семантика итога часовой +попытки уже описана в AS-IS, но правила вывода итога, ограниченного ожидания и +восстановления после сбоя сохранения/перезапуска в docs отсутствуют. Переносится +только это знание; migration history остаётся в archived change. + +## Documentation Changes + +| Action | Target | Category | Resulting AS-IS | Source | Verification | +|---|---|---|---|---|---| +| update | `docs/architecture/cross-cutting/data-freshness.md` | cross-cutting | Рядом со словарём `lastAttempt` (`none`, `running`, `succeeded`, `partial`, `failed`): итог попытки выводится из фактических результатов её шагов; попытка, не завершившая обязательный шаг, не обозначается полным успехом; ожидание шага ограничено, зависший шаг учитывается как проблемный; попытка, потерявшая выполнение (сбой сохранения, остановка процесса), закрывается частичным успехом либо неуспехом и не блокирует следующий обход дольше обычного срока | System Analysis — Data and State Semantics, Error and Recovery Semantics; Design 1–5 | Страница не противоречит canonical spec `hourly-investment-sync` и фактическому поведению `meta` | +| update | `docs/integrations/tbank.md` | integration | В разделе «Состояние и ошибки»: диагностические причины жизненного цикла попытки (`INTERRUPTED` — прервана до нормального завершения, `DEADLINE` — истечение ожидания шага) — внутренняя диагностика, не ошибки T-Bank; в hourly-read наружу не попадают, начатые обращения к T-Bank не отменяются | System Analysis — Observability Semantics; Design 3, 6 | Раздел различает ошибки подключения/external-коды и внутренние причины жизненного цикла | + +PR реализации: WealthLedger/docs#37 (open) — обе страницы обновлены, diff +соответствует таблице выше (задачи 6.1 и 6.2 верифицируемы по нему). + +## Diagram Promotion + +Не переносить. Sequence-диаграммы (`hourly-attempt-terminal-barrier.mmd`, +`hourly-bounded-wait.mmd`, `hourly-recovery.mmd`) — planning evidence этого +change; AS-IS достаточно текстового описания семантики в `data-freshness.md`. + +## ADR Decision + +`N/A` — архитектурно значимого решения с новыми alternatives/consequences нет: +чинятся существующие владельцы строк в прежней одно-процессной топологии, +без новых компонентов и контрактов (Solution Assessment остаётся в archived +change). + +## Glossary / Navigation Impact + +Нет: новые термины (`terminal barrier`, преемник попытки) — внутренние; +публичный словарь состояний не расширяется, navigation не меняется. + +## No-Change Rationale + +`docs/architecture/containers.md` не меняется: раздел «Сохранение и +восстановление данных» описывает backup/restore уровня базы, а не восстановление +попытки приложением. Остальные AS-IS страницы не затрагиваются: HTTP contract +и словарь состояний не изменились. diff --git a/openspec/changes/harden-hourly-sync-lifecycle/proposal.md b/openspec/changes/harden-hourly-sync-lifecycle/proposal.md new file mode 100644 index 0000000..497a4c5 --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/proposal.md @@ -0,0 +1,114 @@ +# Proposal + +## Why + +Почасовая синхронизация T-Bank может завершиться в неконсистентном состоянии +после ошибки сохранения: незавершённая попытка записывается как полный успех, +ошибка финализации либо подавленная ошибка закрытия оставляет попытку в +состоянии выполнения навсегда, а локальная reservation не позволяет запустить +следующий обход в том же процессе. Пользователь видит ложный успех и перестаёт +получать обновления до перезапуска backend, а неудавшееся ожидание дочерних +шагов может ждать без ограничения. Механизм подтверждён статически (BE-002, +BE-015, RISK-001, RISK-002, TQ-003); частота проявления в production пока не +установлена. + +## What Changes + +- Итог попытки определяется фактическим результатом каждого обязательного шага: + невыполненный шаг даёт частичный успех либо неуспех, но не полный успех, а + уже полученные успехи отдельных счетов и разделов сохраняются. +- Прерванная попытка (ошибка, сбой сохранения, остановка процесса, истечение + ожидания) обозначается частичным успехом либо неуспехом и не получает нового + публичного состояния; причина прерывания фиксируется существующим + диагностическим полём. +- Попытка достигает итога в ограниченное время: ожидание дочернего шага + ограничено, а зависшая попытка не остаётся в выполнении бесконечно. +- Состояние в SQLite восстанавливается после сбоя сохранения: брошенная попытка + закрывается диагностируемым итогом, ожидание завершается, следующая + допустимая попытка может стартовать без перезапуска процесса. +- Восстановление не создаёт перекрытия обходов и не принимает устаревшие + результаты: поздняя публикация относительно актуального преемника + отклоняется. + +## Scope + +### In Scope + +- Итоговый статус попытки часового обхода и обработка ошибок в дочерних + финализаторах. +- Ограниченное ожидание дочерних шагов и восстановление после ошибок + завершения шага, публикации результата и закрытия попытки. +- Защита от перекрытия обходов и от поздней публикации устаревших данных при + восстановлении. +- Контролируемая остановка и перезапуск процесса и их влияние на незавершённую + попытку, подтверждённые тестами. +- Наблюдаемый результат попытки: время начала, состояние, безопасный перечень + проблемных счетов и разделов. + +### Out of Scope / Non-Goals + +- Распределённая очередь, lease, поддержка нескольких реплик и изменение + топологии deployment. +- Удаление данных. +- Ослабление существующих ограничений CAS для попытки, ревизии подключения и + источника данных. +- Новый публичный статус попытки и изменения HTTP contract. +- Новый параллельный scheduler или замена владельцев строк синхронизации. +- Проверка часовой cadence и cleanup workflow — независимые задачи. + +## Capabilities + +### New Capabilities + +- Нет. + +### Modified Capabilities + +- `hourly-investment-sync`: итог попытки из фактических результатов шагов, + ограниченное ожидание шага, диагностируемое закрытие брошенной попытки, + отсутствие перекрытий и устаревшей публикации. + +## Impact + +- **Пользователь**: достоверный статус последнего обхода вместо ложного успеха + и бесконечного «выполняется» после сбоя сохранения; обновления продолжаются + без перезапуска процесса. +- **Backend**: координатор часового обхода, дочерние финализаторы обновления + данных и reservation попытки в SQLite. +- **Frontend**: не затрагивается — наблюдаемый словарь состояний и форма + ответов не меняются. +- **Внешняя система T-Bank**: contract не меняется; начатые запросы к T-Bank + могут завершаться поздно и должны корректно отклоняться как устаревшие. +- **Процессы**: диагностика причин прерывания попыток и восстановление после + ошибок сохранения. + +## Success Criteria + +- Попытка с невыполненным обязательным шагом никогда не наблюдается как полный + успех; частичные успехи счетов и разделов остаются видимыми. +- После однократной ошибки завершения шага, публикации результата либо закрытия + попытки и последующего восстановления ожидание завершается, а следующая + допустимая попытка стартует. +- Устаревшая попытка наблюдается относительно актуального преемника; поздняя + публикация старого процесса отклоняется и не меняет данные. +- Координатор различает состояния `first`, `not-due`, `running`, `restart` и + `partial`; итог проверяется после terminal barrier. +- Сценарий остановки и перезапуска доказан тестами: нет бесконечного ожидания, + после остановки новые попытки не создаются. +- Сохранены существующие наборы проверок auth/money/publication, полный + `backend verify` на финальном изменении зелёный. + +## Assumptions / Dependencies + +- Топология остаётся одно-процессной; межпроцессный lease и мультреплики не + требуются. +- Истечение ожидания не отменяет уже начатый запрос к T-Bank; защита + опирается на существующие ограничения CAS и правило срока следующего обхода. +- Причина прерывания фиксируется существующим диагностическим полём, поэтому + HTTP contract и публичный словарь состояний не меняются; упомянутое в задаче + состояние `interrupted` реализуется как частичный успех либо неуспех. +- Graceful drain добавляется только если тест контролируемой остановки докажет + такую необходимость. +- Правило устаревшей публикации относится к итогу попытки и данным её шагов; + существующие требования `tbank-data-sync` уже запрещают выдавать неполный или + продолжающийся результат за полный, поэтому их изменение не планируется. diff --git a/openspec/changes/harden-hourly-sync-lifecycle/qa-analysis.md b/openspec/changes/harden-hourly-sync-lifecycle/qa-analysis.md new file mode 100644 index 0000000..d556bb7 --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/qa-analysis.md @@ -0,0 +1,90 @@ +# QA Analysis + +## Verification Scope + +### In Scope + +- Дельта `hourly-investment-sync`: «Последний результат обхода доступен только + для наблюдения» (MODIFIED) и ADDED «Каждая попытка обхода достигает итога в + ограниченное время», «Восстановление после сбоя сохранения не блокирует + последующие обходы», «Устаревшая публикация не влияет на актуальную попытку». +- Системные проверки через resource GET `meta` и durable-состояние попытки + после контролируемых действий backend (инъекции сбоев, остановка/запуск + процесса). + +### Out of Scope + +- Внутренние unit/component тесты — developer-owned, определены Design. +- Неизменяемые поведения `tbank-data-sync`/`tbank-connection` — только + regression. +- Проверка часовой cadence (T03) и cleanup workflow — независимые задачи. + +## Coverage Matrix + +| Requirement / Scenario | Системная проверка | +| --- | --- | +| Последний результат… — Сбой после частичного обновления | Инъекция сбоя шага после успешного обновления Portfolio одного счёта: GET обозначает частичный успех с проблемным шагом, данные первого счёта остаются обновлёнными | +| Последний результат… — Сбой до первого обновления | Инъекция сбоя до завершения любого шага: GET обозначает неуспех, не полный успех | +| Последний результат… — Частичный успех / Наблюдение не запускает обход / Старая граница подключения | Regression существующего поведения `meta` | +| Каждая попытка… — Шаг не завершается | Зависший шаг с управляемыми часами: попытка достигает терминального итога в ограниченное время, шаг учтён как проблемный | +| Каждая попытка… — Наблюдение не ждёт без ограничения | Наблюдение результата получает итоговое состояние, а не бесконечное «выполняется» | +| Восстановление… — Потерянное закрытие попытки | Ошибка закрытия при живом процессе: следующий проход закрывает попытку диагностируемо, следующая допустимая попытка стартует | +| Восстановление… — Перезапуск процесса | Остановка процесса во время обхода и повторный запуск: брошенная попытка закрыта, новый обход не ранее следующего часового срока, перекрытия нет | +| Устаревшая публикация… — После появления преемника | Позднее завершение шага старой попытки после запуска следующей: данные не изменились, итог актуальной попытки не изменился | +| Устаревшая публикация… — До фиксации итога | Позднее завершение шага до фиксации итога попытки: результат сохранён и учтён в итоге | + +## Cross-system Verification + +- T-Bank upstream не отменяется при истечении ожидания; поздний ответ после + появления преемника не изменяет данные (проверяется на симулированном + upstream в контролируемой среде backend-тестов). +- Внешние ошибки шагов отображаются только безопасным перечнем проблемных + счетов/разделов; причины `INTERRUPTED`/`DEADLINE` не раскрывают наружу + ничего сверх существующей формы `meta`. + +## Risk-based Priorities + +- **Critical**: ложный полный успех (целостность данных), бесконечное + «выполняется», перекрытие обходов, порча данных устаревшей публикацией. +- **Compatibility**: публичный словарь состояний `meta` неизменен; + `cancelled` в enum остаётся толерантным значением. +- **Money/data integrity**: успешные результаты шагов не теряются при сбоях + остальных. + +## Test Data and Environment Needs + +- Изолированный backend на SQLite с управляемыми часами и симулированным + upstream T-Bank (контролируемая тестовая среда; внутренние mocks — на + усмотрение разработчиков по Design). +- Подключение с не менее чем двумя доступными счетами для сценариев частичного + успеха. + +## Manual / Exploratory Scope + +- Ручная проверка отображения результата последнего обхода на инвестиционных + экранах после симулированного сбоя сохранения. +- Exploratory: повторные остановки процесса в разных фазах обхода; наблюдение + результата сразу после старта процесса. + +## Automation Candidates + +- Black-box API-кандидаты (GET `meta` после инъекций сбоев, контролируемая + остановка) — `deferred until QA repo exists`. + +## Observability Verification + +- Причины `INTERRUPTED`/`DEADLINE` наблюдаются в durable-диагностике попытки, + а не в публичном API; подавленная ошибка закрытия фиксируется структурным + логом backend. + +## Regression Impact + +- Существующие наборы auth/money/publication и полный `backend verify`. +- Happy path часового обхода, первый обход после подключения, скрытие + результата прежней границы подключения. + +## Gaps / Blockers + +- Gaps нет. Graceful drain остаётся условным: добавляется только если + controlled-stop тест докажет необходимость (routing: пересмотр Design/QA + после теста). diff --git a/openspec/changes/harden-hourly-sync-lifecycle/specs/hourly-investment-sync/spec.md b/openspec/changes/harden-hourly-sync-lifecycle/specs/hourly-investment-sync/spec.md new file mode 100644 index 0000000..f24c79d --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/specs/hourly-investment-sync/spec.md @@ -0,0 +1,59 @@ +## MODIFIED Requirements + +### Requirement: Последний результат обхода доступен только для наблюдения +Система SHALL предоставлять авторизованному User результат последнего обхода только в границе его текущего подключения: время начала, состояние выполнения, полного успеха, частичного успеха либо неуспеха и безопасный перечень проблемных счетов или разделов. Это состояние MUST быть доступно в `meta` каждого затронутого resource read вместе с freshness, полнотой и доступностью его `data`. Успех одного счёта или раздела MUST сохраняться независимо от ошибки другого; ошибка MUST NOT заменять его последнее успешное состояние или время. Итог попытки MUST определяться фактическим результатом каждого обязательного шага обхода: попытка, не завершившая обязательный шаг, MUST обозначаться частичным успехом либо неуспехом и MUST NOT обозначаться полным успехом. Отсутствие состоявшейся попытки MUST отличаться от успешной попытки. Наблюдение результата MUST NOT предоставлять способ начать личную синхронизацию. + +#### Scenario: Частичный успех +- **WHEN** Portfolio одного доступного счёта обновился, а другого не обновился +- **THEN** новый Portfolio первого сохраняется, прежний успешный Portfolio второго остаётся доступным, а результат обхода обозначает частичный успех и проблемный счёт + +#### Scenario: Наблюдение не запускает обход +- **WHEN** User запрашивает результат последнего обхода +- **THEN** система возвращает доступный сохранённый результат без нового обращения к T-Bank + +#### Scenario: Старая граница подключения +- **WHEN** User заменил либо удалил подключение и запрашивает результат прежнего обхода +- **THEN** система не раскрывает результат прежней границы подключения + +#### Scenario: Сбой после частичного обновления +- **WHEN** попытка прервана ошибкой либо сбоем сохранения после обновления Portfolio одного доступного счёта +- **THEN** результат попытки обозначает частичный успех с проблемными шагами, а не полный успех, а обновлённый Portfolio сохраняется + +#### Scenario: Сбой до первого обновления +- **WHEN** попытка прервана до обновления любого обязательного шага +- **THEN** результат попытки обозначает неуспех, а не полный успех + +## ADDED Requirements + +### Requirement: Каждая попытка обхода достигает итога в ограниченное время +Каждая начатая попытка часового обхода MUST достигать итогового состояния в ограниченное время. Ожидание завершения каждого обязательного шага MUST быть ограничено; шаг, не завершившийся в это ограничение, MUST быть учтён как проблемный, а попытка MUST быть закрыта частичным успехом либо неуспехом после того, как каждый обязательный шаг достиг терминального результата либо был учтён как проблемный. Истечение ограничения MUST NOT отменять уже начатые шаги. + +#### Scenario: Шаг не завершается +- **WHEN** обязательный шаг не покидает состояние выполнения дольше ограничения ожидания +- **THEN** шаг учитывается как проблемный, попытка закрывается частичным успехом либо неуспехом, а её состояние перестаёт быть выполняющимся + +#### Scenario: Наблюдение не ждёт без ограничения +- **WHEN** попытка не может завершиться из-за незавершённых шагов +- **THEN** наблюдение результата получает итоговое состояние в ограниченное время и не ожидает завершения бесконечно + +### Requirement: Восстановление после сбоя сохранения не блокирует последующие обходы +Если попытка осталась в состоянии выполнения после сбоя завершения шага, публикации результата либо закрытия попытки, система MUST привести её к диагностируемому итоговому состоянию: частичный успех либо неуспех с сохранёнными результатами шагов, а не полный успех. Последующий обход MUST становиться допустимым по обычному правилу срока, отсчитанному от начала этой попытки. Восстановление MUST NOT создавать перекрывающийся обход и MUST NOT повторять уже полученные результаты шагов. + +#### Scenario: Потерянное закрытие попытки +- **WHEN** закрытие попытки не сохранилось, а процесс продолжает работать +- **THEN** система закрывает попытку итогом из фактических результатов шагов, наблюдение получает итоговое состояние, а следующая допустимая попытка может стартовать + +#### Scenario: Перезапуск процесса +- **WHEN** процесс остановился после начала попытки и запускается снова +- **THEN** брошенная попытка обозначается частичным успехом либо неуспехом с сохранёнными результатами шагов, а новый обход начинается не ранее следующего часового срока от её начала + +### Requirement: Устаревшая публикация не влияет на актуальную попытку +Результат шага попытки, у которой появился преемник, MUST отклоняться как устаревший: он MUST NOT обновлять данные и MUST NOT учитываться в итоге актуальной попытки. Шаг устаревшей попытки MUST наблюдаться как устаревший относительно актуального преемника. Результат шага текущей попытки, полученный до фиксации её итога, MUST сохраняться и учитываться в этом итоге независимо от того, наступил ли он до или после истечения ограничения ожидания. + +#### Scenario: Поздняя публикация после появления преемника +- **WHEN** шаг старой попытки завершается после запуска следующей попытки +- **THEN** его результат отклоняется как устаревший, данные не обновляются, а итог актуальной попытки не меняется + +#### Scenario: Поздняя публикация до фиксации итога +- **WHEN** шаг завершился после истечения ограничения ожидания, но итог его попытки ещё не зафиксирован +- **THEN** его результат сохраняется и учитывается при выводе итога этой попытки diff --git a/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/analysis.md b/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/analysis.md new file mode 100644 index 0000000..ae62614 --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/analysis.md @@ -0,0 +1,140 @@ +# System Analysis + +## Requirement Coverage + +| Requirement / Scenario | Flow | Contract / Semantics | +| --- | --- | --- | +| Последний результат обхода… — Сбой после частичного обновления | `hourly-attempt-terminal-barrier.mmd` | Outcome derivation, terminal barrier | +| Последний результат обхода… — Сбой до первого обновления | `hourly-attempt-terminal-barrier.mmd` | Outcome derivation | +| Последний результат обхода… — Частичный успех | `hourly-attempt-terminal-barrier.mmd` | Step results preserved | +| Каждая попытка обхода достигает итога в ограниченное время — Шаг не завершается | `hourly-bounded-wait.mmd` | Bounded wait per required step | +| Каждая попытка обхода достигает итога в ограниченное время — Наблюдение не ждёт без ограничения | `hourly-bounded-wait.mmd` | Bounded terminal outcome | +| Восстановление после сбоя сохранения… — Потерянное закрытие попытки | `hourly-recovery.mmd` | Recovery of lost settle | +| Восстановление после сбоя сохранения… — Перезапуск процесса | `hourly-recovery.mmd` | Recovery of abandoned attempt | +| Устаревшая публикация… — Поздняя публикация после появления преемника | `hourly-recovery.mmd` | Successor fencing | +| Устаревшая публикация… — Поздняя публикация до фиксации итога | `hourly-bounded-wait.mmd` | Late publication counts before outcome is recorded | + +## Systems and Responsibilities + +- **Hourly walk coordinator** (backend): резервирует попытку часового обхода для + текущего подключения, запускает обязательные шаги, ждёт их терминального + результата с ограничением, выводит итог попытки и закрывает её. +- **Durable attempt status** (SQLite): единственная durable-запись попытки + (состояние, диагностическая причина, результаты шагов) и источник итога для + наблюдения и восстановления. +- **Child step owners**: шаги membership, Portfolio, операций и будущих событий + публикуют свой полный результат в durable-хранилище и обозначают свой + терминальный результат; финализатор шага обязан фиксировать результат даже + при ошибке. +- **T-Bank и публичные источники**: внешние данные; начатые обращения не + отменяются по внутреннему ограничению ожидания. +- **Resource GET observer**: читает сохранённые данные и `meta` с состоянием + последней попытки; наблюдение не запускает обход. + +## Interaction Flows + +- `sequences/hourly-attempt-terminal-barrier.mmd` — попытка, шаги, terminal + barrier и вывод итога из фактических результатов шагов. +- `sequences/hourly-bounded-wait.mmd` — ограничение ожидания шага, закрытие + попытки по его истечении и учёт позднего результата до появления преемника. +- `sequences/hourly-recovery.mmd` — восстановление после сбоя сохранения и + после перезапуска процесса, отклонение устаревшей публикации. + +## Contracts + +- Новых и изменённых межсистемных HTTP/event contracts нет. Публичный словарь + состояний последней попытки (`выполнение`, `полный успех`, `частичный успех`, + `неуспех`) и форма `{ data, meta }` не меняются. +- Машинно-читаемый target contract в `system-analysis/contracts/` не создаётся: + изменения ограничены уточнением значений внутри существующих полей. +- Диагностическое поле причины остаётся открытым строковым полем; значения + причин прерывания (`INTERRUPTED` — ошибка, сбой сохранения, остановка + процесса; `DEADLINE` — истечение ограничения ожидания шага) дополняют + существующие коды ошибок шагов и не требуют изменения формы ответа. +- Compatibility check до закрытия change: backend-generated OpenAPI остаётся + семантически совместимым с текущим контрактом (ожидаются только дополнительные + значения открытого строкового поля диагностики). + +## Data and State Semantics + +- **Попытка**: `running` → терминальный итог (`succeeded` | `partial` | `failed`). + Попытка, не завершившая обязательный шаг, не может получить `succeeded`. +- **Terminal barrier**: итог попытки выводится только после того, как каждый + обязательный шаг достиг терминального результата либо учтён как истёкший по + ограничению ожидания. Итог не вычисляется по неполному списку шагов. +- **Вывод итога**: `succeeded` — только когда все обязательные шаги успешны и + координатор сам зафиксировал закрытие попытки; `partial` — успешны некоторые + шаги; `failed` — не успешен ни один. Успешные результаты шагов сохраняются + независимо от итога. +- **Причина прерывания**: попытка, закрытая до нормального завершения всех + шагов, сохраняет причину прерывания в диагностическом поле и не превышает + `partial`. +- **Internal coordinator decisions** `first`, `not-due`, `running`, `restart`, + `partial`: внутренние метки решения о запуске и проверки итога после terminal + barrier; в HTTP contract не входят и публично не наблюдаемы. +- **Шаги**: каждый шаг владеет своей durable-строкой результата; строка шага + устаревшей попытки помечается устаревшей относительно преемника. + +## Error and Recovery Semantics + +- **Сбой шага** (внешняя ошибка T-Bank): шаг фиксирует терминальный неуспех со + своим кодом ошибки; остальные шаги продолжаются; итог — `partial` или `failed`. +- **Сбой сохранения** (завершение шага, публикация результата, закрытие + попытки): ошибка не подавляется молча в durable-состоянии; попытка, оставшаяся + `running`, закрывается восстановлением по фактическим результатам шагов. +- **Остановка процесса**: начатая попытка остаётся `running` в SQLite; + следующий запуск процесса закрывает её как прерванную (`partial` или `failed` + с сохранёнными результатами шагов) и не создаёт перекрывающийся обход. +- **Ограничение ожидания**: истечение ограничивает только ожидание шага; уже + начатое обращение к T-Bank не отменяется. Его поздний результат сохраняется и + учитывается при выводе итога своей попытки, если итог ещё не зафиксирован, и + отклоняется как устаревший после появления преемника. +- **Повтор**: неуспешная попытка не повторяется ранее следующего часового срока, + отсчитанного от начала этой попытки; восстановление не сокращает и не + продлевает этот срок. +- **Overlapping**: следующая попытка не резервируется, пока предыдущая не + терминальна; восстановление обязано сначала закрыть предыдущую. + +## Observability Semantics + +- Наблюдаемый итог попытки и безопасный перечень проблемных счетов/разделов — + существующее обязательство; значения итога не расширяются. +- Семантические коды причин: `INTERRUPTED` — попытка прервана до нормального + завершения (ошибка, сбой сохранения, остановка процесса); `DEADLINE` — шаг не + завершился в ограничение ожидания. Коды сохраняются в диагностическом поле + попытки и/или шага и дополняют коды ошибок шагов без изменения формы `meta`. +- SDK/exporter/logger — вне границ этого изменения; используется существующий + структурный лог backend. + +## Security and NFR at System Boundaries + +- Граница наблюдения попытки остаётся границей текущего подключения: результат + прежней границы не раскрывается (существующее обязательство). +- Ограниченное время достижения итога попытки — наблюдаемое обязательство + надёжности: ожидание шага и ожидание наблюдателя ограничены. +- Новых trust boundaries, authn/authz требований нет. + +## Compatibility / Migration + +- Схема данных и HTTP contract не меняются; миграция не требуется. +- Уже застрявшие строки попыток в состоянии выполнения закрываются первым + проходом восстановления по правилам этого изменения: итог из фактических + результатов шагов, причина прерывания, без полного успеха. Это ожидаемое + разовое приведение legacy-состояния к диагностируемому виду. +- Совместимость с одно-процессной топологией сохраняется; межпроцессный lease + не требуется. + +## Durable Documentation Candidates + +- `docs/integrations/tbank.md` (раздел «Состояние и ошибки»): итог часовой + попытки, причины прерывания и правило ограниченного ожидания как часть + наблюдаемого состояния интеграции. +- `docs/architecture/containers.md` (раздел «Сохранение и восстановление + данных»): восстановление попытки после сбоя сохранения и после перезапуска + процесса как durable-свойство системы. +- Итоговое решение о переносе принимает `docs-impact`. + +## Open Questions + +- Нет блокирующих вопросов. Частота проявления механизма в production остаётся + не установленной и не влияет на contract. diff --git a/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/sequences/hourly-attempt-terminal-barrier.mmd b/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/sequences/hourly-attempt-terminal-barrier.mmd new file mode 100644 index 0000000..55a8412 --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/sequences/hourly-attempt-terminal-barrier.mmd @@ -0,0 +1,23 @@ +sequenceDiagram + participant Coordinator + participant DB as Durable attempt status + participant Steps as Child step owners + participant TB as T-Bank and public sources + participant API as Resource GET + + Coordinator->>DB: Reserve attempt for current connection + DB-->>Coordinator: Attempt with connection revision + Coordinator->>Steps: Run required steps (membership, portfolios, operations, events) + Steps->>TB: Fetch missing data + Steps->>DB: Publish complete successful step result + Steps->>DB: Record terminal step result (success or failure with code) + Coordinator->>DB: Read step results after terminal barrier + alt All required steps succeeded + Coordinator->>DB: Settle succeeded + else Some required steps succeeded + Coordinator->>DB: Settle partial with safe problem sections + else No required step succeeded + Coordinator->>DB: Settle failed with safe problem sections + end + API->>DB: Read last attempt result + DB-->>API: Time, outcome, safe problem list diff --git a/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/sequences/hourly-bounded-wait.mmd b/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/sequences/hourly-bounded-wait.mmd new file mode 100644 index 0000000..d92ef62 --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/sequences/hourly-bounded-wait.mmd @@ -0,0 +1,24 @@ +sequenceDiagram + participant Coordinator + participant DB as Durable attempt status + participant Steps as Child step owners + participant TB as T-Bank and public sources + + Coordinator->>Steps: Run required steps + Steps->>TB: Start upstream call + Coordinator->>Coordinator: Wait for step result within bound + alt Step reaches terminal result in time + Steps->>DB: Publish complete result + Coordinator->>DB: Settle attempt after terminal barrier + else Wait bound expires + Note over Coordinator,TB: Upstream call is not cancelled + Coordinator->>DB: Record step problem cause DEADLINE + Coordinator->>DB: Settle partial or failed after terminal barrier + Steps->>TB: Upstream call completes later + Steps->>DB: Publish late result for the same attempt + alt Outcome not recorded yet and no successor + DB-->>Steps: Late result is preserved and counts at outcome derivation + else Successor attempt already started + DB-->>Steps: Publication rejected as stale + end + end diff --git a/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/sequences/hourly-recovery.mmd b/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/sequences/hourly-recovery.mmd new file mode 100644 index 0000000..4da444f --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/system-analysis/sequences/hourly-recovery.mmd @@ -0,0 +1,23 @@ +sequenceDiagram + participant Coordinator + participant DB as Durable attempt status + participant Steps as Child step owners + + Note over Coordinator,DB: Attempt is still running but its walk no longer executes + Coordinator->>DB: Detect running attempt without active walk + alt Process still alive after lost settle + Coordinator->>DB: Read actual step results + Coordinator->>DB: Close attempt as partial or failed with cause INTERRUPTED + else Process restarted after stop + Coordinator->>DB: Read actual step results + Coordinator->>DB: Close abandoned attempt as partial or failed with cause INTERRUPTED + end + DB-->>Coordinator: Attempt is terminal, no overlap possible + Coordinator->>DB: Reserve next attempt at next hourly term from attempt start + alt Next term reached + DB-->>Coordinator: Successor attempt starts + Steps->>DB: Late publication from previous attempt + DB-->>Steps: Publication rejected as stale relative to successor + else Next term not reached + DB-->>Coordinator: No work (not-due) + end diff --git a/openspec/changes/harden-hourly-sync-lifecycle/tasks.md b/openspec/changes/harden-hourly-sync-lifecycle/tasks.md new file mode 100644 index 0000000..88686ca --- /dev/null +++ b/openspec/changes/harden-hourly-sync-lifecycle/tasks.md @@ -0,0 +1,111 @@ +# Tasks + +## 1. Данные попытки и вывод итога + +- [x] 1.1 [backend] `settleHourly` выводит итог попытки из durable-строк шагов + в одной транзакции; `cause: 'INTERRUPTED'` капит итог до `partial`; + вызывающие не передают результат и список ошибок + - **Covers:** `hourly-investment-sync` — «Последний результат обхода доступен только для наблюдения» + - **Source:** Design — Decisions 1, 6; System Analysis — Data and State Semantics + - **Verification:** изолированный SQLite-тест: сбой после частичного обновления даёт `partial` с сохранённым успехом; сбой до первого шага даёт `failed`; ложный `succeeded` невозможен ни на одном error-пути + - **Depends on:** none +- [x] 1.2 [backend] id строк шагов персистятся в `progress` hourly-попытки + (`children` + `attempts`) в момент dispatch, до ожидания шага + - **Covers:** `hourly-investment-sync` — «Восстановление после сбоя сохранения не блокирует последующие обходы», «Устаревшая публикация не влияет на актуальную попытку» + - **Source:** Design — Decisions 2, 5 + - **Verification:** SQLite-тест: после потери выполнения попытки множество шагов восстанавливается из `progress`, включая шаг, упавший в dispatch + - **Depends on:** 1.1 + +## 2. Ограниченное ожидание и восстановление + +- [x] 2.1 [backend] ограничение ожидания на каждый шаг в `settledWorks` + (константа ~15 минут, per-id deadline); истёкший шаг учитывается проблемным + с причиной `DEADLINE`; начатые обращения к T-Bank не отменяются; часы в + тестах — jest fake timers без инъекции clock в production-код + - **Covers:** `hourly-investment-sync` — «Каждая попытка обхода достигает итога в ограниченное время» + - **Source:** Design — Decision 3; System Analysis — Error and Recovery Semantics + - **Verification:** SQLite-тест с зависшим шагом на fake timers: попытка терминальна в ограниченное время, наблюдение не ждёт бесконечно + - **Depends on:** 1.1 +- [x] 2.2 [backend] in-memory `active`-реестр обходов + `recoverAbandoned` + перед reservation: running-попытка своего `processRunId` без активного walk + закрывается derived settle с `INTERRUPTED`; застрявшие legacy-строки + закрываются первым проходом + - **Covers:** `hourly-investment-sync` — «Восстановление после сбоя сохранения не блокирует последующие обходы» (Потерянное закрытие попытки) + - **Source:** Design — Decisions 4, 6; System Analysis — Error and Recovery Semantics + - **Verification:** SQLite-тест: подавленная ошибка закрытия при живом процессе — следующий проход закрывает попытку, следующая допустимая попытка стартует + - **Depends on:** 1.1, 1.2 +- [x] 2.3 [backend] restart-ветка `reserveHourly` закрывает чужую running-попытку + derived settle с `INTERRUPTED` вместо голого `cancelled`; `due`-гейт и + защита от overlap прежние + - **Covers:** `hourly-investment-sync` — «Восстановление после сбоя сохранения не блокирует последующие обходы» (Перезапуск процесса) + - **Source:** Design — Decision 5 + - **Verification:** SQLite-тест перезапуска процесса: брошенная попытка закрыта `partial`/`failed` с сохранёнными результатами шагов, новый обход не ранее следующего часового срока, перекрытия нет + - **Depends on:** 1.1, 1.2 + +- [x] 2.4 [backend] ожидания walk до durable-строк шагов + (`membership.request`/`member.done`, `access.local`, dispatch + `portfolio`/`operations`/`events.refresh`, post-wait `events.get`) + ограничены тем же `WORK_WAIT_BOUND_MS` через race; истечение — проблемная + секция либо `INTERRUPTED`-закрытие без отмены вызова + (WealthLedger/backend#78) + - **Covers:** `hourly-investment-sync` — «Каждая попытка обхода достигает итога в ограниченное время» + - **Source:** Design — Decision 3; review specs#33 (CRITICAL) + - **Verification:** SQLite-тесты с никогда не завершающимися dispatch/membership/`done`/`get`: попытка терминальна в ограниченное время, поздние вызовы не отменяются + - **Depends on:** 2.1 + +## 3. Финализаторы шагов + +- [x] 3.1 [backend] внешний catch финализаторов шагов (enrichment, operations + drain/step) записывает терминальный неуспешный результат своей строки шага + существующим путём записи ошибки перед возвратом или пробросом + - **Covers:** `hourly-investment-sync` — «Каждая попытка обхода достигает итога в ограниченное время» (шаг покидает running) + - **Source:** Design — Decision 7 + - **Verification:** SQLite-тест с инъекцией ошибки финализатора: строка шага терминальна, попытка проходит terminal barrier + - **Depends on:** none + +## 4. Поздняя публикация + +- [x] 4.1 [backend] подтверждено fence-поведение публикации шага: результат до + фиксации итога попытки сохраняется и учитывается в итоге; после появления + преемника — отклоняется без изменения данных и итога актуальной попытки + - **Covers:** `hourly-investment-sync` — «Устаревшая публикация не влияет на актуальную попытку» + - **Source:** Design — Decisions 1–3; System Analysis — Error and Recovery Semantics + - **Verification:** SQLite-тесты поздней публикации: до фиксации итога — учтён; после successor — данные и итог актуальной попытки не изменились + - **Depends on:** 1.1, 1.2, 2.1 + +## 5. Системная верификация backend + +- [x] 5.1 [backend] полный набор изолированных SQLite-тестов coordinator: + ветки `first`, `not-due`, `running`, `restart`, `partial`, итог после + terminal barrier; инъекции ошибок отдельно в дочерние шаги и родительский + coordinator; controlled stop/restart без бесконечного drain и без новых + reservations после остановки; scheduler stub `upcoming-events` не является + единственным доказательством + - **Covers:** `hourly-investment-sync` — все Requirements дельты + - **Source:** Design — Developer Verification Strategy; QA Analysis — Coverage Matrix, Risk-based Priorities + - **Verification:** полный `backend verify` зелёный на финальном diff; наборы auth/money/publication сохранены + - **Depends on:** 1.1, 1.2, 2.1, 2.2, 2.3, 3.1, 4.1 +- [x] 5.2 [backend] backend-generated OpenAPI семантически совместим с + текущим контрактом: форма `meta` и словарь состояний не изменились, новые + значения только внутри открытого строкового поля диагностики + - **Covers:** `N/A` — защита контракта (изменений контракта нет) + - **Source:** Design — Contract Implementation; System Analysis — Contracts + - **Verification:** diff сгенерированного OpenAPI до/после изменения не содержит shape-изменений + - **Depends on:** 1.1, 1.2, 2.1, 2.2, 2.3, 3.1, 4.1 + +## 6. Canonical documentation + +- [x] 6.1 [docs] обновить `data-freshness.md`: вывод итога из фактических + результатов шагов, ограниченное ожидание шага, закрытие брошенной попытки + без блокировки расписания — рядом со словарём `lastAttempt` + - **Covers:** `N/A` — docs promotion + - **Source:** docs-impact — Documentation Changes, строка 1 + - **Verification:** страница не противоречит canonical spec `hourly-investment-sync` и фактическому поведению `meta` + - **Depends on:** 5.1 +- [x] 6.2 [docs] обновить раздел «Состояние и ошибки» `tbank.md`: причины + жизненного цикла `INTERRUPTED`/`DEADLINE` — внутренняя диагностика, не + ошибки T-Bank; начатые обращения не отменяются + - **Covers:** `N/A` — docs promotion + - **Source:** docs-impact — Documentation Changes, строка 2 + - **Verification:** раздел различает ошибки подключения/external-коды и внутренние причины жизненного цикла + - **Depends on:** 5.1