Files
specs/openspec/changes/archive/2026-10-09-reconcile-hourly-investment-sync/design.md
T
ksv741 329d66e767
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
spec: finalize wealth-ledger SDD migration
2026-10-09 21:14:22 +03:00

21 KiB

Engineering Design

Context and Constraints

The deployed backend is a single NestJS process with SQLite/Prisma. Its durable hourly walk, connection revision publication fence, saved investment resources, and frontend 30-minute rereads already provide most of the approved model. The remaining mismatch is at the resource boundary: backend InvestmentResourceMetaDto and investmentResourceMeta expose legacy missing|partial|complete availability and idle|running|failed work state, while the frontend projects that legacy metadata through useResourceQuery and uses sync=observe as a transport mode. The generated backend openapi.json and frontend snapshot agree with each other, but they describe this AS-IS contract rather than the approved target.

The backend-generated OpenAPI remains the executable contract. The change-local system-analysis/contracts/investment-resource-envelope.schema.json is a semantic target and must not become a second OpenAPI source, generated client, or runtime validator. Route identities, authentication/access boundaries, Problem Details codes, and resource payload DTOs remain established unless an approved target requirement explicitly changes them.

No normal personal resource GET may wait for or call T-Bank. The only acquisition exceptions remain the first unconfirmed ledger interval and first unconfirmed candle range. The design preserves the single-process boundary; it does not add distributed scheduling, a job service, polling endpoint, manual sync command, public value-history read, or a new UI package dependency.

Technical Goals / Non-Goals

Goals

  • Publish one generated HTTP envelope for Accounts, Portfolio, operations, and events: { data, meta }, with Accounts-only top-level sync.
  • Make resource-result state and hourly-attempt state independently truthful: confirmed data stays available after a failed/partial attempt, while an initial result remains null until complete.
  • Preserve local-read, connection-revision, and first-load invariants while migrating Swagger DTOs, mapper inputs, runtime responses, and frontend generated types together.
  • Keep the existing successful TanStack Query snapshot during read retries and retain the existing 30-minute open-screen reread cadence as a local HTTP reread, not an import trigger.

Non-Goals

  • Rebuild source owners, hourly scheduling, portfolio-value storage, or the legacy status endpoint beyond removing their use as a source-work command from these screens.
  • Introduce a compatibility envelope, client-side DTO normalizer, duplicate cache, new route/version, manual refresh/import action, or a copy of OpenAPI in specs.
  • Plan documentation edits, implementation tasks, or product behavior beyond the approved proposal, delta specs, and System Analysis.

Implementation Impact Map

frontend

affected - generated transport types, shared/api/resource-query.ts, the existing brokerage-account/portfolio/operations/events query owners, and their page VMs/views consume the target metadata. useScreenSync remains the common 30-minute open-screen reread owner, but its source-error derivation must read meta.lastAttempt rather than legacy meta.sync; retry stays HTTP-only.

backend

affected - investment resource DTOs, mapper, endpoint response DTOs, resource readers, connection/hourly-attempt projections, Swagger annotations, and generated openapi.json implement the semantic envelope. Existing durable attempt, revision, membership, snapshot, forecast, coverage, and history owners remain the data owners.

specs

affected - this Design makes the approved deltas and System Analysis implementable without duplicating the executable contract.

docs

affected - durable AS-IS references are limited to ADR 019, ADR 017, architecture/cross-cutting/data-freshness.md, domains/portfolio/flows/portfolio-loading.md, and domains/income/upcoming-events.md. Their impact is recorded here only; documentation sequencing is not designed in this artifact.

qa

N/A - no separate QA/E2E repository exists.

Decisions

Backend: one resource-meta projection and one connection-walk projection

Replace the legacy shared metadata vocabulary at the existing InvestmentResourceMetaDto/investmentResourceMeta seam. Every affected resource envelope has required meta.freshness, meta.completeness, meta.lastSuccessfulAt, meta.lastAttempt, and meta.availability. lastAttempt is a resource-scoped projection with state none|running|succeeded|partial|failed, nullable start/end times, and only safe failed section/account/error-code entries. none is a concrete status, not an omitted field or success.

The mapper derives freshness and successful time only from the returned confirmed resource result. A later running, partial, or failed walk neither changes that time nor makes data fresh. data: null pairs with unknown/ incomplete and either initial-loading, temporarily-unavailable, or connection-unavailable; a confirmed empty collection pairs with available and complete. The ledger's permitted first-load rows retain incomplete coverage and are never represented as a confirmed complete result.

Accounts additionally projects the connection-wide walk as sync: null when no attempt exists, otherwise the same complete safe attempt shape. Other resource envelopes do not gain top-level sync. This preserves the single shared metadata owner while avoiding the current ambiguity between per-resource meta.sync and Accounts-level walk status.

The existing resourceKey, dataRevision, coverage, and dependency details remain internal projection inputs or endpoint-specific metadata only where their approved behavior still requires publication; they must not substitute for any of the five required target meanings. No client must infer target state from their presence.

Backend: retain storage owners and publication fences

The durable hourly attempt remains the sole normal publisher. Resource readers read the saved current connection revision and project its persisted result; they do not reserve work or contact upstream. Each successful section can persist independently, preserving the previous confirmed resource result and its success time when a later section fails. Forecast publication remains complete-source-only, and exact per-currency value-history writes remain in the same successful Portfolio publication transaction.

Connection PUT establishes a new revision before its first walk; DELETE and replacement prevent old personal snapshots and old attempt status from being read or published. Invalid-token knowledge blocks disclosure rather than being masked by saved data. Data and storage changes must be additive when a new persisted distinction is necessary; existing snapshots, coverage, forecasts, attempts, revisions, and retained value history are not reset or reclassified by a destructive migration.

Contract: Swagger is produced at the DTO boundary

Swagger DTO annotations and endpoint response classes are the only place the wire schema is authored. The backend regenerates openapi.json from the implemented DTOs, then the frontend receives that published snapshot and runs its existing openapi-typescript generation. Hand-editing frontend schema.d.ts, adding handwritten parallel interfaces, or retaining a compatibility mapper would hide drift and is excluded.

The resulting generated contract must express nullable data, all required meta fields, nullable attempt timestamps, nullable problem fields, Accounts top-level nullable sync, and absence of top-level sync on the other resource responses. It must retain sync=observe only as a read-observation compatibility parameter while it exists in the generated route contract; it cannot cause source work and is not used to represent the new hourly state. The generated contract reconciles field spelling with the target schema before release, not through a second contract artifact.

Frontend: one query cache and metadata-aware screen state

useResourceQuery remains the sole adapter that projects resource envelopes into data, meta, and Accounts sync, preserving its existing query key, authenticated transport, access-group invalidation, and Client Cached Snapshot behavior. It must project the generated target types directly. Resource query owners remain at their existing entity slices; page VMs, not presentation views, choose initial loading, unavailable/error, empty, and loaded states.

useScreenSync remains the single 30-minute timer for open investment screens. It calls ordinary local refetch() and its retry path continues to use the existing observation read without changing cache identity. It derives server attempt warning state from meta.lastAttempt, keeps transport failures separate, and never starts personal synchronization. Existing Accounts 30-minute staleTime remains aligned with this cadence; Portfolio and other screen resources keep their current short cache freshness where already established while the open-screen timer supplies the approved reread rhythm.

All affected account screens render the five metadata meanings in text or an element with an accessible name, not color alone. A complete empty resource is normal empty content. data: null renders initial-loading or unavailable states without invented values. A failed/partial hourly attempt is an independent nonblocking warning beside confirmed data and its original time. Portfolio.isStale remains snapshot-specific and is shown separately from hourly-attempt and client HTTP-error warnings. A client snapshot stays visible during and after a failed background HTTP reread; connection replacement or deletion still invalidates personal queries.

Cross-cutting Implementation

The canonical state split is:

Concern Owner Observable representation
Returned confirmed resource result Saved resource owner data, freshness, completeness, lastSuccessfulAt, availability
Resource-relevant hourly outcome Durable walk and resource projection meta.lastAttempt
Connection-wide hourly outcome Durable walk Accounts sync only
In-flight/failed browser reread TanStack Query Existing query lifecycle and local retry warning
Snapshot-specific Portfolio source age Portfolio owner Portfolio.isStale

This split prevents a source failure from becoming a synchronous GET failure, prevents the UI from treating its cache as server fallback, and preserves safe access invalidation. Safe attempt problems contain only the approved section, optional account ID, and safe error code: no token, upstream payload, cursor, connection revision, or raw exception text is serialized or logged as status metadata.

Events retains its existing account and all scopes and its saved forecast window/filter semantics. Its metadata independently reports the latest events attempt, so a saved forecast can remain available when a later refresh fails. Operations retains its coverage semantics; only its explicitly approved missing historical first-load acquisition may work outside the hourly walk.

Data / Storage Changes

No new storage architecture is needed. Existing durable tables/records for the current connection revision, hourly attempts, snapshots, operation coverage, forecast source windows, and retained portfolio-value points are authoritative. If implementation needs persisted terminal-state or problem-detail values not already durable, it extends the existing attempt record/schema additively and backfills conservatively: absent historical attempt information is projected as none, never as succeeded.

Migrations must preserve all current user, connection, personal-resource, coverage, forecast, and history rows. They must not reset SQLite, rewrite an old partial result as complete, expose retained history across a new connection boundary, or delete old data merely to fit the new vocabulary. Compatibility is achieved by deploying the backend DTO/runtime and its generated OpenAPI as one server contract, followed by the frontend snapshot/types and rendering change; there is no claim that an old frontend understands the target envelope.

Contract Implementation

The implementation contract is the generated result of the following mapping:

Target concept Backend source HTTP location
Last confirmed result or no complete result Existing local resource reader data
Result freshness and completeness Resource mapper from confirmed result/coverage meta.freshness, meta.completeness
Original confirmed-result time Saved source timestamp meta.lastSuccessfulAt
Relevant hourly result Durable attempt plus resource section projection meta.lastAttempt
Whether complete data can be used Connection/access/read result meta.availability
Complete current-connection walk Durable connection-wide attempt Accounts sync

The target schema's problemSection shape is used for both scopes. For a connection-wide Accounts walk, its list may include all affected sections; for a resource-level attempt, it is restricted to failures relevant to that resource. A running attempt has finishedAt: null; terminal attempts have a completion time; none has null timestamps and no problems. These invariants are validated at runtime tests and in the generated OpenAPI, rather than by shipping the JSON Schema to application consumers.

Developer Verification Strategy

Backend verification proves the contract at DTO, mapper, controller, and SQLite boundaries: successful empty data; first load with data: null; stale fallback after a failed walk; partial walk with preserved data; resource versus Accounts attempt scoping; events forecast-window filtering; invalid-token and connection-revision disclosure fences; no upstream personal call on ordinary GET; and both first-load exceptions staying narrow. It regenerates OpenAPI and runs the existing OpenAPI contract, type, lint, and relevant E2E suites.

Frontend verification updates the supplied snapshot, runs openapi:check, and uses MSW/page-VM tests to prove generated metadata projection, no automatic retry, HTTP-only retry, 30-minute open-screen reread, cache retention after a background error, connection invalidation, and distinct server stale/hourly attempt/client transport states. UI checks cover keyboard access, accessible status text, loading/error/empty/loaded states, and narrow viewport wrapping.

Before release, verify the backend-generated OpenAPI and frontend snapshot are the same published artifact, then run each repository's configured verification commands. In this specs store, run both v2 schema validation and strict change validation; structural success is supplemented by a semantic comparison against the approved deltas and System Analysis.

UI/UX Package

Applicability and scope: new/changed. This is an inline package for the existing Accounts, account Portfolio, operations, and events screens. It adds the approved status vocabulary and preserves 30-minute local rereads; it does not introduce new composition, graphics, navigation, or controls.

Package revision: 1 (this Design). No visual files, external references,

Screens, states and interactions:

Screen/state Presentation Interaction
No complete result, initial attempt running Existing structural loading state, labelled as loading No manual sync action
No complete result, attempt failed/unavailable Safe unavailable/error state with target status text Retry rereads HTTP only when recovery allows
Complete empty result Normal domain empty state and target status text Existing navigation/actions only
Complete data with running/succeeded/partial/failed attempt Content and original success time remain visible; attempt appears as separate accessible status No source-work action
Client Cached Snapshot during/after HTTP failure Existing content remains visible; transport warning remains distinct from server state Existing retry rereads HTTP only

Responsive behavior: reuse existing Mantine/theme components and page layouts. Status text wraps without hiding account content or money values on narrow screens; color supplements an accessible label. Existing skeletons remain noninteractive and unfocusable. Existing cards, buttons, and icons are used through their public package exports; no wrapper, SVG asset, token, or component package revision is introduced.

Reused source and differences: N/A for approved visual reuse. This is a new inline status package revision 1, not an immutable reuse of a prior approved visual package. Existing cards, layouts, hooks, and UI packages are implementation inputs only; they are not the approved visual source. The package adds semantic presentation of the five target metadata fields and separate hourly-attempt/client-transport warnings. No local visual export is required.

Risks / Trade-offs

  • Envelope evolution is a coordinated backend/frontend release risk. Generated artifacts prevent hidden type drift but cannot make an older client interpret new enum meanings.
  • Mapping resource relevance from connection-wide attempts can over-report or under-report a failure if it is not constrained by resource/section scope; mapper tests must cover each affected resource.
  • A durable none projection for old rows is intentionally less informative than inventing a historic success; it preserves truthfulness.
  • The 30-minute client timer can issue local reads while an hourly walk runs; this is acceptable because reads do not contend for upstream work and retain the last confirmed result.
  • Single-process scheduling remains unsuitable for multi-process deployment; adding leases, a worker, or distributed coordination requires a separate decision.
  • Documentation currently contains legacy request-driven/manual-refresh descriptions. Until the identified durable references are updated, they must not be used as implementation authority over the approved behavior.

Migration / Rollout / Rollback

Rollout is backend-first at the contract source: apply only additive persistence changes if required, implement and test runtime projection/fences, regenerate and publish backend OpenAPI, then update the frontend snapshot/generated types and renderers. Do not enable the new projection alongside a second personal write owner. A frontend built from the old snapshot is not the compatibility target; rollout uses the matched generated artifact pair.

Field Decision
Status required
Trigger Personal-data disclosure across connection revisions, false complete/fresh state, malformed generated contract, failure to preserve confirmed fallback, or a frontend unable to read the published contract.
Actions Stop the affected rollout, preserve a SQLite copy, return to the last compatible backend/frontend pair, and fix forward. Do not restore screen-triggered/manual synchronization while the hourly owner remains enabled.
Data safety Never reset production SQLite, delete additive attempt metadata, delete snapshots/coverage/forecasts/value history, or use a destructive downgrade. Old rows without target details safely project none.
Verification On a representative copy, verify migration integrity, revision/access fences, ordinary local GET behavior, preserved fallback timestamps, generated OpenAPI/snapshot identity, and frontend rendering before traffic resumes.

Solution Assessment

Option Assessment
Evolve the existing shared DTO/mapper and generated OpenAPI Chosen. It is the narrowest path: one resource-meta projection already owns all affected envelope metadata, Swagger already produces the contract, and frontend types already derive from the supplied snapshot.
Add a target adapter/parallel endpoint or versioned envelope Rejected. It duplicates mapping and cache paths, delays removal of ambiguous states, and creates an unnecessary second compatibility surface.
Handwrite frontend target types or runtime validation Rejected. Generated paths/schemas already own transport typing; manual copies would drift and a validator adds no trust boundary.
Add a scheduler/queue/cache service to express status Rejected. Durable single-process attempts and SQLite records already express the required state. A new service cannot improve this contract without changing the explicitly retained deployment model.
Add a new design-system package/component family Rejected. Existing Mantine and @wealthledger/ui states cover the textual, accessible status presentation; a new package would be abstraction without a new visual primitive.

The chosen solution is intentionally a contract-and-projection reconciliation, not a new synchronization system. It minimizes code and migration surface while making the approved observable model expressible through the existing owners.