Files
specs/openspec/changes/archive/2026-09-01-harden-tbank-connection-cache-boundary/design.md
T

242 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Frontend использует один `QueryClient`, а `openapi-react-query` формирует ключи
GET-запросов как кортежи с HTTP method, canonical API path и, при наличии,
params. Все пользовательские финансовые выборки текущего T-Bank connection
находятся в `/api/v1/brokerage-accounts` subtree. Сейчас successful PUT и DELETE
connection обновляют status cache, но сбрасывают только список Brokerage
Accounts; сохранённые и выполняющиеся query остальных выборок могут пережить
смену connection.
Backend также владеет connection-dependent cache. Account Ledger хранит страницы
по User, Brokerage Account и query без идентичности текущего connection; Income
Summary и Income Analytics используют те же страницы. Portfolio очищается при
PUT и DELETE, но старый in-flight request может завершиться после очистки и
снова записать snapshot. Browser `AbortSignal` прекращает потребление HTTP
response, но не является гарантией отмены уже запущенного backend T-Bank RPC.
Поэтому один frontend reset не может обеспечить запрет на данные предыдущей
границы.
Каждый successful PUT сохраняет заново зашифрованный token с новым AES-GCM
nonce, включая замену token тем же plaintext value. Persisted encrypted payload
тем самым уже является сменяемой идентичностью connection; DELETE удаляет её.
Это позволяет изолировать server-side cache без нового поля БД, transport DTO
или API version. Обычная ошибка frontend background refetch по-прежнему
сохраняет Client Cached Snapshot, а logout или потеря auth session очищает весь
`QueryClient` независимо от connection boundary.
## Goals / Non-Goals
**Goals:**
- сделать successful create, replace и delete общей границей frontend и backend
cache reuse для connection-dependent финансовых данных;
- исключить чтение сохранённого либо поздно завершившегося результата
предыдущего connection независимо от порядка in-flight completions;
- оставить failed mutation в текущем generation и сохранить Client Cached
Snapshot только для текущего generation;
- сохранить status-first frontend commit, refetch активных consumers и
независимую полную session cleanup при logout;
- удержать cache isolation внутри существующих QueryClient, connection access и
cache-owning backend layers без изменения публичного API и storage schema.
**Non-Goals:**
- менять observable Requirements, backend endpoints, transport DTO или OpenAPI
contract;
- добавлять persisted connection version, data migration, persisted financial
cache или новый глобальный frontend state container;
- привязывать к User connection generation общие reference caches инструментов,
логотипов и корпоративных событий, которые не содержат account snapshot;
- менять stale times, retry policies или UI-семантику обычного background
refetch;
- сужать logout cleanup до T-Bank data, менять auth boundary либо сериализовать
конкурентные replacements между распределёнными backend instances.
## Decisions
### Одна connection boundary координирует два cache plane
Successful mutation создаёт одну логическую границу с двумя владельцами.
Frontend feature `tbank-connection` коммитит подтверждённый status и сбрасывает
все financial query текущего `QueryClient`. Backend connection access сообщает
cache owners opaque identity текущего persisted connection, а каждый
connection-dependent cache разрешает reuse только внутри этой identity.
Обе защиты обязательны. Frontend reset немедленно удаляет данные из UI и
останавливает reuse browser responses; backend isolation не позволяет новому
frontend fetch получить server response, вычисленный до границы. Failed mutation
не меняет persisted connection identity и не запускает frontend commit, поэтому
данные текущей границы сохраняются.
### Feature-owned frontend commit использует native reset brokerage subtree
Модель `tbank-connection` остаётся единственным владельцем frontend commit после
successful PUT или DELETE. PUT использует status из response, DELETE — известный
disconnected status. Оба paths вызывают одну операцию, которая сначала записывает
status в существующий status query, затем применяет `QueryClient.resetQueries` с
feature-owned predicate ко всем generated GET keys, чей canonical path равен
`/api/v1/brokerage-accounts` или начинается с
`/api/v1/brokerage-accounts/`. Connection status и несвязанные query в эту family
не входят.
Native reset отменяет browser requests через существующий `AbortSignal`,
возвращает matching query в initial state без предыдущих данных и запускает
refetch active observers. Inactive query получает данные при следующем
потреблении. Refetch не удерживает mutation UX в pending: обязательный status и
cache commit завершается синхронно, а получение нового response продолжается
независимо.
`invalidateQueries` отклонён, потому что оставляет предыдущие данные читаемыми до
successful refetch. `removeQueries` отклонён для active observers: reset
напрямую переводит их в состояние без старых данных и использует штатный
TanStack Query lifecycle.
### Backend connection access выдаёт opaque cache generation
Ответственный connection access layer вместе с расшифрованным token получает
persisted encrypted payload и вычисляет из него фиксированный non-reversible
digest средствами Node runtime. Digest используется только как internal cache
generation: он не сохраняется отдельно, не возвращается через API и не попадает
в logs. Новый random nonce при каждом успешном PUT создаёт новое generation даже
для того же plaintext token; DELETE оставляет последующие requests без
generation до нового успешного PUT.
Generation определяется из persisted record при обслуживании request, поэтому
все backend instances, читающие одну запись, получают одну identity, а
process-local invalidation не является условием корректности. `connectedAt`
отклонён как единственный generation key: timestamp имеет пользовательскую
семантику и конечную точность. Отдельный UUID column также подходит, но добавляет
schema migration и lifecycle нового persisted field без необходимого
compatibility gap.
### Connection-dependent backend cache принимает только своё generation
Account Ledger page key расширяется backend generation. Проверка connection и
доступа к Brokerage Account остаётся до cache reuse; Income Summary и Income
Analytics автоматически получают ту же изоляцию через общий collection path.
Старая page, записанная после boundary поздним RPC, остаётся помеченной старым
generation и недоступна новому request; существующие TTL и LRU ограничивают её
жизнь и память.
Portfolio разрешает cache hit только после разрешения текущего persisted
connection и только когда snapshot принадлежит его generation. Cached fallback
при временной upstream error также ограничен тем же generation. Snapshot старого
in-flight request может завершиться после PUT или DELETE, но его generation не
совпадёт с новым connection, а при отсутствии connection cache вообще не
проверяется. Existing explicit invalidation может оставаться best-effort memory
eviction, но не служит correctness boundary.
Brokerage Accounts не хранит полный server-side account snapshot. Events каждый
раз начинает с current connection и current account positions; его общие
instrument/dividend reference caches остаются connection-independent, поскольку
не содержат пользовательскую account selection или financial snapshot. Любой
будущий backend cache готового response из brokerage subtree обязан при
добавлении классифицироваться как connection-dependent либо получить конкретное
обоснование независимости.
### Connection cleanup и auth cleanup остаются раздельными
Connection boundary выполняется только после successful PUT или DELETE. Failed
mutation не меняет status, generation и financial cache. Обычный background
refetch использует существующие query policies и может сохранить последний
successful Client Cached Snapshot текущего generation. Logout и auth failure
продолжают вызывать полный `QueryClient.clear()`, включая connection status,
financial и любые другие session-scoped query.
Regression coverage проверяет observable boundary и race ordering: successful
create/replace/delete сбрасывает весь parameterized brokerage subtree;
отменённый frontend response не возвращается; cached и late backend results
предыдущего generation не обслуживают новый request; DELETE не допускает cache
hit без connection; failed mutation, current-generation background snapshot и
logout сохраняют свои независимые outcomes.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | `tbank-connection` централизует successful status/cache commit и через native QueryClient lifecycle сбрасывает весь brokerage query subtree, сохраняя failure, background snapshot и logout semantics. |
| backend | `affected` | Connection access выдаёт opaque generation текущей persisted connection; Ledger/Income и Portfolio допускают cache reuse и fallback только внутри неё, поэтому local invalidation и browser cancellation не являются correctness boundary. |
| specs | `affected` | Canonical change фиксирует пересмотренный двухсторонний Design, зависимые Tasks и lifecycle artifacts без изменения утверждённого observable Requirement. |
| docs | `affected` | После поставки as-built connection, Portfolio и Account Ledger cache descriptions отражают generation isolation, frontend reset и отличие от current-generation Client Cached Snapshot. |
### Contract Gate Decision
| Gate | Status | Reason |
| --- | --- | --- |
| runtime-openapi | `N/A` | Frontend и backend меняют только internal cache orchestration; endpoints, request/response schemas и generated OpenAPI contract не меняются ни на одной стороне. |
## Solution Assessment
Change существенно меняет caching и concurrency boundary, поэтому рассмотрены
native и custom alternatives.
| Option | Fit | Maturity and security | Operating constraints | Migration cost |
| --- | --- | --- | --- | --- |
| TanStack Query reset плюс backend generation из digest persisted encrypted payload | Закрывает browser cache, server cache и late in-flight responses; один persisted connection определяет generation во всех processes | Использует установленный TanStack Query lifecycle, Node crypto и существующее AES-GCM storage; digest не раскрывает token и не создаёт новый public surface | Portfolio выполняет lightweight persisted connection lookup до cache reuse; cache owners обязаны различать connection-dependent и shared reference data | Низкая: без schema/API migration, локальные изменения ответственных cache layers |
| Только frontend `resetQueries` | Закрывает browser state, но новый fetch может получить старый backend response | Native и безопасен на frontend | Browser abort не отменяет уже выполняющийся backend RPC | Низкая, но нарушает Requirement |
| Fan-out `clear()`/`invalidate()` из connection mutation во все backend caches | Удаляет уже сохранённые local entries | Простая process-local операция | Late request может повторно записать старые данные после clear; другой instance не получает invalidation | Низкая, но не является correctness solution |
| Explicit persisted UUID generation column | Даёт ту же строгую generation isolation и ясную storage identity | Зрелый стандартный механизм, без token-derived coupling | Требует schema ownership, migration существующих records и rollback data contract | Средняя и непропорциональная при уже сменяемом encrypted payload |
| Отключить connection-dependent backend caches | Строго исключает server reuse предыдущей границы | Наименьшая cache logic surface | Увеличивает T-Bank RPC load, latency и rate-limit pressure для Portfolio, Ledger и Income | Низкая code migration, высокая постоянная operating cost |
Выбран первый вариант. Он использует существующую persisted connection identity
и native cache lifecycle, а custom logic ограничивает одной доменной операцией:
сравнением opaque generation перед reuse. Это устраняет compatibility gap,
который нельзя закрыть одним frontend reset или invalidation fan-out, без нового
storage field и без отказа от полезного backend caching.
## Risks / Trade-offs
- Generation зависит от свойства AES-GCM storage создавать новый encrypted
payload при каждом successful PUT. Если token storage перестанет
re-encrypt-иться, перед таким изменением потребуется явная generation identity;
regression boundary должна сделать нарушение наблюдаемым.
- Opaque digest является security-sensitive internal identifier: его нельзя
возвращать, логировать или включать в errors, хотя он не позволяет восстановить
token.
- Persisted connection lookup перед Portfolio cache reuse добавляет локальную DB
работу, но сохраняет экономию дорогих T-Bank Portfolio/Positions calls и
обеспечивает DELETE/replace isolation между processes.
- Old-generation Ledger pages остаются в bounded LRU до TTL, а поздний Portfolio
completion может временно вытеснить current entry. Generation check сохраняет
корректность, но редкая гонка способна вызвать дополнительный refetch.
- Active frontend view после смены connection временно переходит в initial
loading/error state. Это намеренный trade-off: snapshot предыдущей границы не
показывается, пока current-generation response ещё не получен.
- Если current-generation refetch завершается ошибкой сразу после successful
mutation, новый status уже виден, а данные предыдущего generation не
восстанавливаются. Recovery использует обычный error path без ложного
snapshot.
- Path-subtree classification включает будущие brokerage subresources на
frontend, но новый backend response cache всё равно требует явного решения о
generation ownership.
## Migration Plan
1. Сначала в backward-compatible backend delivery добавить opaque generation в
connection access context и ограничить ею Portfolio, Ledger и производные
Income cache paths; existing endpoints и responses оставить без изменений.
2. Перевести frontend successful PUT и DELETE на единый status/cache commit с
reset всего brokerage query subtree.
3. Зафиксировать boundary-level regression coverage для cached и in-flight
ordering на обеих сторонах, failed mutation, current-generation background
snapshot и независимого logout cleanup.
4. После подтверждённой реализации обновить as-built connection, Portfolio и
Account Ledger cache documentation.
Backend поставляется первым, потому что он совместим с текущим frontend и сразу
делает новые reads generation-safe. Frontend delivery затем включает немедленную
UI boundary. Data migration, compatibility window и OpenAPI regeneration не
требуются.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `required` |
| Reason | Change укрепляет isolation пользовательских финансовых данных после replace/delete; rollback не должен возвращать cross-generation cache reuse или cache hit без current connection. |
| Trigger | Generation resolution ошибочно блокирует current connection либо вызывает системную деградацию доступности cache-backed финансовых выборок после delivery. |
| Actions | Сохранить frontend reset и current connection checks; временно отключить reuse затронутого backend cache или перейти на uncached current-connection reads, затем исправить generation logic. Не откатывать систему к cache keys без generation. |
| Data safety | Persisted token и финансовые данные не мигрируют; затронуты только in-memory entries, которые можно безопасно отбросить. |
| Verification | Current connection обслуживает Brokerage Accounts, Portfolio, Operations, Events и Income; create/replace/delete не используют previous-generation entries, а failed mutation сохраняет current-generation data. |