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

21 KiB
Raw Blame History

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.