Repository navigation
Restore main to the merged branch state + backend/frontend fixes - #18
Merged
Merged
Conversation
Design for closing the six mocked frontend domains, the missing S8 admin/lots/ export routes, the unbuilt ADR 20 real-time push fan-out, and real Stripe Elements billing capture — plus exhaustive Vitest/RTL coverage per frontend/CLAUDE.md's one-test-per-component rule.
…backend - portfoliosApi.js now calls GET/POST /portfolios/models and /assignment instead of mockClient; drops the fabricated securities/price table. - useSecurities derives the orderable universe from the real models response (deduped target_weights) instead of a mock quote endpoint. - OrderForm collects the reference price from the customer directly, since no quote endpoint exists. - ModelCard/PortfolioPage updated to the real field names (weight_pct, model_portfolio_id) and a static per-model description (no description field in the real response). - chatApi/useChatStream replaced the fake word-splitting reveal with a real fetch + ReadableStream consumer of the existing chat SSE endpoint.
- feesApi.js now calls GET /fees and POST /payment-methods instead of mockClient; drops the invented month_to_date_gain and payment_method fields (neither exists on the real response). - PaymentMethodForm sends one of Stripe's fixed test-mode payment method tokens (pm_card_visa, etc.) to the real endpoint rather than a brand/last-4 string pair — a genuinely live sandbox call once STRIPE_SECRET_KEY (test mode) is configured, no Elements UI needed. - useCustomerFees/CustomerFeesPanel (admin) now call feesApi.getForCustomer instead of the adminCustomersApi mock, which drops getFees entirely. - Fixes two regressions this surfaced: HoldingsTable and DashboardPage still read the old target_weight/model_id field names the mock used; LotTable depended on useSecurities' now-removed reference_price, so lotsApi's own mock seed now carries its own current_price per lot.
New SimulatedBadge (design-system.md §8.3's neutral provenance-tag pattern, generalized past its one named case) placed on the customer directory, customer detail's profile/KYC-override sections, tax lots, and the statement export button — the three domains this pass didn't wire to a backend (no route exists yet: S8's /lots, /admin/customers*, /admin/kyc-overrides, /statements/<period>/export). Honest real-vs-simulated labelling, not new hardening.
nginx.conf hardcoded proxy_pass to http://backend:8000, which only
resolves inside docker-compose's own network. Renamed to
nginx.conf.template and switched to \${BACKEND_URL}, rendered by
nginx's own built-in template mechanism
(/docker-entrypoint.d/20-envsubst-on-templates.sh) at container start
- Dockerfile sets a default of http://backend:8000 so the local
docker-compose flow is unaffected; a deployed environment (Azure
Container Apps) overrides it to the backend's actual reachable FQDN.
Verified locally: built the image, confirmed the template renders with
a custom BACKEND_URL and nginx starts and serves the SPA correctly.
Three real bugs found deploying to Azure Container Apps, all reproducible and fixed independently of the platform quirk that surfaced them: - Missing proxy_http_version 1.1 + an unforwarded Connection header caused nginx's own proxy module to return 426 Upgrade Required whenever the ingress-to-container hop set Connection/Upgrade headers nginx then blindly forwarded to a plain HTTP/1.1 upstream that never completed the requested upgrade. - proxy_buffering was never disabled for /api/, so the chat SSE endpoint would have buffered its entire response instead of streaming tokens incrementally. - BACKEND_URL should reference the backend Container App's cluster-internal short name (http://backend), not its public FQDN -- routing east-west traffic back out through the environment's shared external ingress gateway was consistently unreliable (intermittent TLS resets), while the internal service name is stable. Verified against a live deployment: 10/10 consecutive requests succeeded, plus a full register -> login -> portfolios -> fees -> chat SSE smoke test through the public frontend URL.
…token Stripe's fixed test-mode tokens (pm_card_visa etc.) each materialize a new PaymentMethod object on .attach() -- the real id differs from the token passed in. StripeBillingAdapter reused the input id for the follow-up Customer.modify() call, which fails Stripe's own "must be attached to the customer" check whenever a fixed test token is used (as opposed to a genuine pre-existing pm_... id). Found live: deploying the MVP with a real Stripe test-mode key and exercising POST /payment-methods end-to-end surfaced a real 500 that no prior test caught, because the contract test's own assertion (stripe_payment_method_id == the input token) only held for the fake adapter and silently encoded the same wrong assumption. Loosened that assertion to what the port actually guarantees; the real-adapter contract test (requires_credentials) now passes against the live sandbox.
README gets a Deployment section with the live Azure Container Apps URLs and an explicit list of what's deferred; CHANGELOG and DECISION-LOG record the MVP-first pivot, the nginx and Stripe fixes found only by actually deploying, and why Key Vault was set up for real rather than deferred (FLASK_ENV=production refuses to boot without it, and dropping to development mode would have disabled HSTS/forced-HTTPS/the session cookie's Secure flag).
Phase 1 of closing the gap audit: root CLAUDE.md requires lint, unit + integration + API tests, coverage thresholds, dependency + secret scanning, SAST, Docker build + image scan, and an Alembic migration check on every PR, plus CD to Azure Container Apps gated on green CI and manual approval -- none of this existed. - .github/workflows/ci.yml: backend (ruff, mypy --strict, lint-imports, full pytest suite with the 77% coverage floor against real Postgres/Redis service containers, pip-audit, bandit, Alembic migration-drift check) and frontend (oxlint, build, npm audit) jobs, plus Gitleaks secret scanning and Trivy image scans on both Docker builds. - .github/workflows/cd.yml: on green CI on main, build + push commit-SHA-tagged images (never :latest -- Container Apps does not reliably re-pull a moved tag), run migrations via the existing backend-migrate Container Apps Job, then update both Container Apps. Gated on the `production` GitHub Environment's manual approval. - .github/dependabot.yml: uv, npm, GitHub Actions, and both Dockerfiles. Fixed while proving the backend job actually goes green: - test_cash_lock_serialization.py's concurrency test compared which of two independent Postgres connections' commit/unblock notifications a Python thread noticed first -- a genuine two-connection race, not a thread-scheduling one, so no amount of thread synchronization fixed it (confirmed by trying). Rewrote the assertion to measure the withdrawal thread's own blocking duration against its own monotonic clock instead; verified deterministic across 20 consecutive runs. - bandit flagged two pre-existing, reviewed B608 false positives (static owner-authored DDL and an already-validated dynamic-SQL wrapper). Suppressed precisely at both lines with justification -- the DDL one needed splitting its assignment from its .format() call first, since the flagged line was mid-string and a same-line comment there would have corrupted the generated SQL; verified the generated SQL is byte-identical and the chat security-perimeter tests still pass. Verified: 687 backend tests green (0 failed), coverage 82.82% against the 77% floor, ruff/mypy/import-linter clean, bandit clean, migration drift check clean, frontend lint/build/audit clean, both Docker images build on linux/amd64. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
The CI workflow from the previous commit had never actually run against a truly clean checkout. It surfaced five genuine, pre-existing issues -- none introduced by this change, all previously masked by every local dev machine having a backend/.env: - ci.yml: mypy/ruff steps ran without `working-directory: backend`, silently dropping backend/pyproject.toml's [tool.mypy] overrides (the pydantic plugin, the flask_*/alpaca/plaid ignore-missing-imports list) -- 34 spurious errors. Fixed by adding the working directory consistently across all backend-quality steps. - ci.yml: the drift-check step's `git status --porcelain` reports paths relative to the repo root even when invoked from a `working-directory: backend` step -- stripped the prefix back off so the following grep/rm resolve the same file. - ci.yml: `npm audit` was missing `working-directory: frontend`, so it ran at the repo root with no lockfile (ENOLOCK). - ci.yml: aquasecurity/trivy-action@0.24.0 doesn't exist (tags are v-prefixed) -- pinned to the real v0.36.0. - ci.yml: the migration-check job passed only DATABASE_URL_OWNER, but `app.config.Settings` validates all 7 required fields eagerly at construction even though alembic/env.py only reads sqlalchemy_url_owner -- added the full (non-secret, ephemeral-DB) env block. - The deepest one: ~20 call sites across every webhook, funding, fees, chat, identity, orders, and admin/rebalance controller plus every scheduled job call the module-level get_settings() singleton directly instead of the app's injected test_settings, so its first construction still needs real env vars -- with no .env present, 22 tests across 5 files failed with "7 validation errors for Settings" the moment CI actually ran clean. Fixed centrally with an autouse `_default_settings_env` fixture in tests/conftest.py (monkeypatch. setenv, reverted per test -- not a session-wide or import-time os.environ mutation, which was tried first and broke test_config.py's own deliberately env-isolated "missing setting" tests). Those tests were in turn hardened to explicitly delenv every COMPLETE key so they no longer depend on ambient env being clean -- matching what their own docstring already claimed. Verified: 687 backend tests green across 3 consecutive runs with a fully clean environment (env -i, no .env, no shell exports) and separately with .env present; ruff/mypy/lint-imports clean. Confirmed live against the actual GitHub Actions run (PR #7), not just locally: this fixes the failures that run surfaced in lint/types, tests, migration-check, dependency-scan, and docker-build-and-scan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…ing results Docker build + Trivy scan (PR #7) failed once real vulnerability data was involved: nginx:1.27-alpine's libssl3/libcrypto3 carried CVE-2026-31789 (CRITICAL, fixed upstream in 3.3.7-r0, not yet in that tag's build). Bumped to nginx:1.29-alpine and confirmed via `apk info` it ships 3.5.6-r0 -- but a second local scan still found 34 HIGH findings across libssl3/libuuid/libxml2/nghttp2-libs, all with fixes already released in Alpine's package repo, just not yet folded into Docker Hub's rebuild of that tag. Added `apk update && apk upgrade --no-cache` in the runtime stage so the image tracks Alpine's own (faster) patch cadence instead of waiting on the upstream image rebuild; verified 0 findings with `trivy image` locally, `nginx -t` still validates the rendered config, and the backend image (Debian slim, apt-get) was independently confirmed already clean. Also added `fail-fast: false` to the backend/frontend build matrix -- the frontend leg's failure had silently cancelled the backend leg mid-scan ("The operation was canceled"), hiding its actual result. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
… fetch failure BalanceCard/ReturnCard only branched on status 'loading'/'idle' before falling through to the value-rendering branch -- any other status, including 'error', called formatMoney(null)/formatPercent(null), which construct a Decimal from null and throw (verified directly: `new Decimal(null)` -> "DecimalError: Invalid argument: null"). With no error boundary anywhere in the app, a single failed GET /valuation/balance or /valuation/returns took down the whole /dashboard route. Added the explicit status === 'error' branch both components were missing, rendering the same ErrorState-with-retry pattern already used elsewhere (LotTable.jsx's precedent), and threaded error/refetch from useBalance/useReturns through DashboardPage into both cards. Verified: lint and build clean; the DecimalError is unreachable now that 'error' status never reaches formatMoney/formatPercent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…vent dispatcher
An independent multi-agent audit (backend-audit) found three blocker-
class gaps: written, unit-tested code that nothing in the running
application ever actually invokes. Verified each by hand before
fixing, per this project's own established discipline.
- register_cli(app) existed with the exact right implementation and
docstring ("Called by the application factory when CLI wiring is
enabled") but create_app() never called it -- `flask jobs <name>`,
the entrypoint Makefile's `job` target and every deployed Container
Apps Job use, had no commands registered at all. One-line fix;
verified `flask jobs --help` now lists all 8 commands.
- No `outbox-worker` CLI command existed. Without it, nothing ever
drains `job_outbox` -- an order placed via POST /orders enqueues
`submit_order_to_broker` and simply sits there forever; same for
`charge_fee`. Added the command, routing each of the three task
types this codebase's services actually enqueue
(submit_order_to_broker, charge_fee, process_inbound_event) through
a new OutboxTaskRouter (app/services/ops/outbox_task_router.py,
unit-tested in isolation -- pure routing logic, no DB/provider
needed, unlike the credential-gated CLI wiring around it).
- `InboundEventDispatcher.register(...)` was never called anywhere --
its own module docstring already said so ("not yet wired end-to-
end... tracked as owed operational work"). This is the one gap that
silently breaks money-moving correctness even with the outbox worker
running: Alpaca fills arrive over a websocket (ADR 22), not a
request/response webhook, so TradeUpdatesConsumer.handle_message
only records the InboundEvent -- applying the fill to the order/
ledger/lot-consumption has no other path. Registered
AlpacaTradeUpdateHandler for InboundEventSource.ALPACA in the new
outbox-worker command. STRIPE/PLAID already apply their effect
synchronously inside their webhook controllers, so their own
process_inbound_event row is a durability record, not a required
second application -- registered as explicit no-ops so the
dispatcher's "unregistered source is a wiring gap" check doesn't
retry-then-dead-letter every Stripe/Plaid webhook's outbox row.
Two more real bugs surfaced only by actually constructing these
objects together for the first time:
- OutboxRepository/OutboxUnitOfWork's Protocols (app/workers/outbox.py)
didn't structurally match the concrete OpsUnitOfWork/JobOutboxRepository
they were written to accept -- outbox_id: object vs the real
uuid.UUID, a bare `outbox: OutboxRepository` attribute vs a real
@cached_property, and a loose `__exit__(*args: object)` vs
UnitOfWork's real 3-argument signature. Fixed to match the exact,
already-proven pattern event_intake.py's equivalent Protocol uses.
Never caught before because nothing had ever type-checked a real
UnitOfWork against these Protocols.
Added `outbox_max_attempts` (Settings, default 5) since OutboxWorker
needs one and no generic setting existed. Added a `worker` service to
docker-compose.yml (same image as `backend`, `flask jobs outbox-worker`
as its command) so local dev actually has a running worker, matching
Makefile's `worker` target which previously had no command to invoke.
Verified: 691 backend tests green (4 new), ruff/mypy --strict/
lint-imports clean. Live smoke test of `flask jobs outbox-worker`
against real local Alpaca paper + Stripe test credentials: clean
startup, correctly constructs real adapters, blocks on LISTEN with no
errors. Repeated with credentials unset: falls back to FakeBrokerAdapter
with a loud warning rather than crashing or silently looking identical
to production. Also verified running as the actual `worker` container
via `docker compose --profile app up worker` -- same clean result.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
S12 §3's own stated rule: every FK gets an explicit supporting index (Postgres does not create one automatically) and every named WHERE/ORDER BY access pattern gets a composite index added at the time the table is created -- never bolted on ad hoc later. An independent audit found 8 of the 11 named indexes missing entirely (verified: none of the seven affected model classes declared them, so this needed adding to the ORM models before autogenerating, not just a hand- written migration -- otherwise the next autogenerate would see them in the DB but not the model metadata and want to drop them again). - account(customer_id, role) -- every cash-policy/balance query, S1 §5 - posting(account_id) -- S1's balance-summing queries - journal_entry(effective_date), journal_entry(recorded_at) -- S1/ADR 1's bitemporal range queries - order(customer_id, status) -- S3 §6's order list/status queries - tax_lot(customer_id, security_id, acquired_at) -- S5 §4's FIFO ordering - reconciliation_break(status, opened_at) -- S7 §7's aged-break list - chat_message(session_id, created_at) -- S11 §6's message history daily_close, order_event, and fee_accrual were already covered by existing UNIQUE-backed indexes; admin_audit_log already had its own. Verified: migration applies clean against real Postgres, all 8 indexes confirmed present via pg_indexes, a second autogenerate against the now-migrated database produces an empty diff (no drift), 691 tests green, ruff/mypy --strict clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Non-negotiable #5 requires "a working MCP surface, not a design for one" -- minimum three read tools and one write tool landing in a human approval queue, plus a written list of operations never handed to an autonomous agent. Nothing satisfies this today; S11's chat assistant is an HTTP+SSE endpoint, not an MCP server, and has no write path. ADR 24 records the decision and its rationale: read tools reuse ADR 19's proven curated-view/least-privilege-role boundary directly rather than re-deriving it; all three write tools (resolve a reconciliation break, override a KYC verdict, propose a rebalance) only ever insert a pending agent_action_request row -- approval, not the tool call, is what executes the underlying already-tested service method. Includes the required written list of operations never handed to an agent. S13 is the implementation-level spec: the agent_action_request and staff_api_token schemas, the six tool contracts, the four approval routes and their execution mapping back to existing services, edge cases (concurrent approval, revoked token mid-session, a resolved-out- from-under-you break), and the testing strategy including a real MCP client hitting a running server end-to-end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- TaxLotRepository.list_for_customer / LotConsumptionRepository.list_for_lots back the new read, matching the unpaginated per-customer list shape valuation history already uses. - Response always reads TaxLot.adjusted_basis / LotConsumption.realized_gain_loss (S5's mutate-in-place fields), never original_cost_basis for gain math, so a wash-sale-adjusted lot can never surface its pre-adjustment figure. - is_provisional aggregates true if any of the lot's own consumptions are still provisional (S5 §7 edge case 1). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- CSV of lot_consumption rows realized in the as-published period, always the lot's own wash-sale-adjusted basis and each consumption's own is_provisional flag -- never a raw pre-adjustment figure. - An unpublished period returns 404 statement_not_published rather than falling back to a live-derived export (S8 §6 case 1). - LotConsumptionRepository.list_realized_in_period, TaxLotRepository.list_by_ids back the new read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- GET /api/v1/admin/customers: case-insensitive email search, cursor-paginated via app/core/pagination.py (Customer carries no name field yet, so email is the only match target). - GET /api/v1/admin/customers/<id>: aggregated view calling the same ValuationService.value_book path GET /valuation/balance uses, and surfacing any open reconciliation break prominently (S8 §6 case 2). - GET /api/v1/admin/customers/<id>/fees: thin re-export of the same FeeSummaryService.summarize GET /fees now calls, extracted from fees.py so both routes can never independently drift on the same customer's figures (S8 §6 case 4's no-drift requirement, applied to fees). - New AdminUnitOfWork (app/services/admin/uow.py) composes FeesUnitOfWork with S7's reconciliation model mixin for this cross-aggregate read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- KycService.start_verification now blocks with KycLockedError once kyc_status is rejected AND the latest attempt has exhausted KYC_MAX_ATTEMPTS (S2 §9's own deferred gate) -- a single canceled attempt well below the cap still allows a normal retry (S8 §6 case 3). - POST /identity/kyc-sessions surfaces the block as 422 kyc_locked. - New POST /api/v1/admin/kyc-overrides/<customer_id> (adviser/admin, @Audited) resets customer.kyc_status to pending without touching the append-only kyc_session table, per S2 §9's own text on how reopening works. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…scan Per explicit direction: drop lint (ruff), typecheck (mypy --strict), import-layering (lint-imports), and dependency audits (pip-audit, npm audit) as CI gates -- they stay available locally (make lint/typecheck/imports/audit, npm run lint) but no longer block a merge. Removed .github/dependabot.yml with them, since its only purpose was feeding the now-removed dependency-audit jobs. Kept exactly five jobs, each independently verified to catch a real, distinct defect class: tests + coverage floor, Alembic migration drift check, SAST (Bandit + Semgrep), secret scanning (Gitleaks), and Docker build + Trivy image scan -- kept specifically because it is a genuine scanner, not a rubber stamp: it already caught a real CRITICAL CVE in nginx:1.27-alpine earlier this session (fixed in a prior commit). cd.yml is untouched -- it triggers on the CI workflow's overall success, not on individual job names, so it needed no change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…in-breaks comments to 1-2 lines Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…ation comments to 1-2 lines Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…comments to 1-2 lines Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
az containerapp create's --args parser drops any value starting with "-" (confirmed locally: az/argparse discards it silently instead of erroring cleanly), so "-m" "app.mcp.entrypoint" never reached the container -- verified against the real az CLI (2.90.0) that both the space-separated and repeated --args="-m" forms fail or silently lose the flag. Running the module as a plain file path sidesteps the dash parsing bug entirely; imports still resolve since app/ is installed into the venv regardless of invocation style. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…o 1-2 lines Trims module/function docstrings and standalone comments across app/controllers, app/jobs, app/mcp, app/workers, config.py, app/__init__.py, extensions.py, and wsgi.py to 1-2 lines. Suppression directives (noqa, type: ignore, pragma: no cover) kept verbatim. Comment text only, no functional changes.
Comment/docstring text only; suppression directives and functional code untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Covers app/models/reconciliation, app/services/orders, app/services/rebalance, app/services/reconciliation, and app/services/restatement. Comment/docstring text only; fixed a few new E501s the shortened one-liners introduced by wrapping to two lines. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
No test logic changed -- comment/docstring text only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
agent_action_request (two-hop pending/approved/rejected -> executed/ execution_failed transition graph, enforced by a BEFORE UPDATE trigger) and staff_api_token (SHA-256-hashed MCP bearer credential), plus their repositories, views, and unit/integration/contract tests. Backs the MCP write-tool approval queue: a write tool never acts directly, it only ever creates a pending request here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
A local machine-specific workaround (port 5000 was occupied by another process during dev) leaked into an earlier bulk commit. Backend serves on 5000 everywhere else -- restore that as the proxy target. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Bandit's own suppression syntax is `# nosec <CODE>`, not ruff's `# noqa` -- bandit doesn't recognize noqa, so B104 (medium) was flagging the already-reviewed 0.0.0.0 bind on `mcp_server_host` (the MCP server's only network interface inside its container). Add `# nosec B104` alongside the existing `# noqa: S104`, matching the established precedent at app/services/chat/read_only_sql_executor.py:65. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
SQLAlchemy's `Table.drop(bind=owner_engine, checkfirst=True)` fails hard with `DependentObjectsStillExist` under the full suite whenever another test file's still-live fixture holds an FK into the table being dropped (e.g. `customer`) -- reproducible only as part of the full run, confirmed by the two teardown errors on tests/contract/test_mcp_agent_surface.py. Sweep the remaining 26 occurrences across 20 files to the raw `DROP TABLE IF EXISTS ... CASCADE` idiom already established in tests/integration/conftest.py's `ledger_tables` fixture. Teardown mechanics only -- no fixture creates different tables, no assertion changed. Full suite run twice back to back: 779 passed, 7 skipped, 2 xpassed, 0 errors, 83.07% coverage both times. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- New pure frontend/src/features/auth/validation.js: email format check (looser than the backend's EmailStr so the client never rejects what the server would accept), a length-or-class-mix password strength rule layered on top of the backend's 8-char floor, and a confirm-password matcher. - RegisterForm now validates on blur and submit via Input's existing error prop, with a live 2-item password strength checklist (updates per keystroke, icon + text, never color alone) instead of a static hint. The shared banner is reserved for the server/network error. - Failed submit still leaves all input filled. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- Email: inline format validation via Input's error prop, on blur and submit (was `required` only). - Password stays presence-only by design — a login form must never reject an older, already-provisioned password because a newer strength policy exists; commented in place so this isn't "fixed" into a regression later. - MFA step: format-checks a 6-digit numeric code (verified against pyotp's TOTP default, which is what /auth/mfa/verify actually checks against) before submitting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Replaces the Ledger palette (near-black ink, cool paper, verdigris accent) with Vault (deep navy ink, warm ivory paper, brass accent) -- the direction chosen after reviewing four options against the real signup form and dashboard layout. Token values and every contrast ratio recomputed with the actual WCAG relative-luminance formula, not eyeballed; text-muted darkened from the artifact's draft value to clear full AA at caption size (13px, not "large text"). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Covers the two FAIL-level findings from the compliance audit: the correction test (PostingService.correct() has no real caller, the one test exercising it doesn't reverse properly, and nothing republishes a statement snapshot after a restatement) and automatic-fail #3 (six tables beyond the core ledger permit UPDATE on money/unit columns). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…r poll The onboarding "Identify" flow got stuck at pending indefinitely: the only thing that ever advanced kyc_status was the Stripe Identity webhook, and Stripe can't reach localhost to deliver it in local dev (and any missed/delayed webhook would have the same effect in production). GET /identity/status/<id> now polls Stripe directly for the live verification-session status when kyc_status is still pending, reusing the existing verdict-mapping/resolve logic unchanged -- so it self-heals with zero frontend changes, since "Refresh status" already calls this endpoint. Verified against the real Stripe sandbox (not just the fake adapter): created a live verification session, confirmed the new poll path executes cleanly end-to-end with no errors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Foundation for the chat/nav/tables redesign work: a 16-glyph Icon component (currentColor-driven, token-sized) and --duration-*/--ease-* tokens with a single reduced-motion rule that zeroes every duration at once. Scoped tight to glyphs with a real replacement site already identified (retiring the ▼▲↕/✕ Unicode glyphs currently standing in for icons), not a speculative larger set. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- AccrualSummary: fix unreachable "No accrual yet" empty state. feesApi always builds an accrual object, so the old `!accrual` check could never trigger; a zero-accrual customer saw $0.00 forever. Now checks the real zero-activity condition (peak_value null, no charges, accrual_to_date zero via decimal.js), matching high_water_mark's first-valuation-day creation in the backend. - FeesPage: give the Accrual block a section heading (it had none while its siblings did) and move Payment method's h2 outside its Card to match the app-wide title-outside-content pattern. - DunningBanner: match ApprovalBanner/CompletenessBanner by using --color-text on the subtle background instead of the raw semantic color; add a real anchor to the payment method section instead of prose-only copy. - PaymentMethodForm: wire the existing error prop on Select (aria-invalid + aria-describedby) instead of a disconnected sibling <p role="alert">; replace the 320px-in-a-full-width-card layout with a two-column current-method/change-form grid that fills the space. Verified: npm run lint and npm run build clean. Manually exercised via a temporary route rendering the empty/warning-dunning/exhausted-dunning/ error states with mock props against the real components (no live backend available in this shared dev environment); confirmed via screenshot and accessibility snapshot, then removed before commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
docs/decisions/1-...24-... and docs/specs/0-...13-... sorted wrong alphabetically (1, 10, 11, ..., 2, 20, ...) because single-digit prefixes weren't zero-padded. Rename every file in both directories to 2-digit prefixes (git mv, preserving history) and update every literal reference to an old filename across the repo — the decisions/README.md index table, requirements.md's ADR links, ADR/ spec cross-references, backend docstrings/config comments, and agent guidance docs — including two references that were hard-wrapped mid-filename in DECISION-LOG-adjacent prose (CHANGELOG.md, docs/decisions/17). Numbering itself (which spec/ADR is "S13" or "ADR 24") is unchanged; only the zero-padded filename changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…instant
new Date("2026-09-06") parses as UTC midnight; formatting it in a US
local timezone (behind UTC) then renders one day early. assigned_at is
a server-anchored calendar date with no time component of its own, so
formatDate now parses YYYY-MM-DD strings as local-midnight components
instead of handing them to the Date constructor directly. Full
datetimes (with a "T") are unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Both pages discarded useModels()'s status/error, so assignedModel resolved against a still-empty models array while GET /portfolios/models was in flight -- an already-assigned customer briefly saw the not-yet-assigned view (AssignmentPrompt / "No model assigned yet") on every page load. Both pages now gate on both the assignment and models fetch reaching 'loaded' before deciding, and surface a real ErrorState if either fetch fails. Also: PortfolioPage now fetches models once and passes them down to AssignmentPrompt/ModelDetail instead of each fetching independently (removes a duplicate GET /portfolios/models), wires a success toast through the new reassignment flow, and DashboardPage relabels its "Holdings" section to "Target allocation" -- it renders the assigned model's target weights, not real positions data. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…nding
- Replace ModelCard's hardcoded, allocation-disconnected description
copy with one genuinely derived from the model's real target_weights,
via a small ticker-based asset-class lookup (VTI/VXUS = equity,
BND/BIL = fixed income -- GET /portfolios/models carries no
asset_class field yet, confirmed against
backend/app/views/portfolios.py).
- Add AllocationBar: a real equity vs. fixed-income segmented bar per
model, alongside (not replacing) the per-security weight rows.
- Fix weight rounding from .toFixed(0) to .toFixed(1) in ModelCard and
HoldingsTable so weights visibly sum to 100% (12.5% no longer rounds
to "13%").
- Wire up real reassignment: ModelDetail now renders the full model
grid with the current model's card in a real, reachable "Assigned"
state (Icon check-circle) and the others selectable via onSelect,
matching AssignmentPrompt's existing pattern.
- Add a two-step confirm ("Choose this model" -> "Confirm <name>?" /
"Cancel") before committing to a model -- a real money-shaped
decision that previously had zero confirmation.
- HoldingsTable gains explicit loading/error states (Skeleton/
ErrorState) instead of only handling the empty case, and its empty
copy no longer implies real holdings data.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
… fail to load useSecurities() derives its list from GET /portfolios/models; a failed fetch previously fell through silently to an empty Security dropdown with no explanation beyond a generic "Choose a security" validation message. Now renders ErrorState with retry, matching the fetch-failure pattern used elsewhere (e.g. BankLinkStep). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
docs/specs/frontend/2026-09-05-frontend-completion-design.md and docs/superpowers/specs/2026-09-05-frontend-completion-design.md were byte-identical duplicates. Confirmed nothing references the specs/frontend/ copy; keeping the one under superpowers/specs/, which matches that skill's own dated-doc convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- Fix ToolTraceDisclosure reading call.tool/arguments/result — the real
backend payload is {tool_name, summary}; every trace rendered
"(undefined) -> " before this. Give the trace panel --color-surface
so it's visually distinct from its --color-surface-sunken parent bubble.
- Auto-scroll to the newest message, but only when the viewer was
already near the bottom.
- aria-live="polite" region on the streaming bubble so tokens are
announced to screen readers.
- Hand-rolled Markdown renderer (bold/italic/inline-code/lists) for
assistant replies — plain React elements only, no
dangerouslySetInnerHTML, no new dependency for four inline rules.
- Real timestamps: client Date.now() for locally-created/streamed
messages, created_at passed through for history.
- MessageInput: auto-growing textarea, Enter to send / Shift+Enter for
a newline, visible counter near the backend's 4000-char cap.
- AbortController-backed stop control: streamMessage takes a signal,
chatApi treats an aborted read as a clean stop (returns the partial
text, never throws), so the UI always settles out of "streaming".
- Typing indicator no longer implies text is imminent when a tool call
could be running — the SSE stream gives no tool-start event to infer
a real "checking your account" state honestly, so the wording was
made generic instead of fabricated.
- Expanded suggested questions to cover balance, returns, holdings,
orders, and tax lots.
- Icon component wired in for the trace disclosure chevron, send
button, and stop button.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- security/model_portfolio/target_weight are empty in every environment (dev, staging, deployed) since S9 explicitly defers model composition to a product/investment-committee decision it doesn't invent -- this was blocking the Portfolio page and order placement, which read those tables. - Seeds 4 demo ETFs (VTI/VXUS/BND/BIL) and 4 named model portfolios (Conservative/Balanced/Growth/Aggressive) with target weights that each sum to exactly 1.0000, satisfying target_weight's deferred sum-to-one trigger. Clearly labelled as demo/seed data, not investment-committee- approved, in both the command's --help text and the module docstring. - Idempotent: securities upsert by symbol (unique constraint), model portfolios upsert by name (new ModelPortfolioRepository.get_by_name(), application-layer check since name carries no DB uniqueness). Safe to run twice, including against a database another run already seeded. - Registered as `flask jobs seed-reference-data` in app/jobs/__init__.py, following the outbox-worker precedent (no --market-date; a rerunnable admin bootstrap, not a ScheduledJob-shaped recurring job). - Reads whatever DATABASE_URL* the environment already has configured, so the same command seeds dev, staging, or the live Azure deployment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
- Bug fix: nav rendered all 10 customer links regardless of KYC/account approval, advertising 9 dead ends to an unapproved customer whose only reachable route is /onboarding (RequireOnboarded bounces every other link back). Nav now calls useIdentityStatus itself and shows only the Onboarding link until both kyc_status and account_approval_status are approved, matching guards.jsx's own gating logic exactly. - Wordmark: serif "Trueup" home link to defaultRouteForPrincipal. - Customer nav clustered into Account / Money / Ask Trueup via spacing + hairline dividers (not a dropdown -- primary nav needs to stay a single click away); staff console gets its own labelled, segmented-tab treatment instead of a bare 2-item row. - Real account menu: email-initials avatar, dropdown with full email + logout, focus-trapped, Escape-to-close-and-return-focus, click-outside to dismiss. - Logout gets a real pending state via the existing Button loading prop. - Nav collapses into a menu-icon-triggered drawer below 768px (md), with the same grouping stacked and labelled; drawer is a focus-trapped dialog with body-scroll lock, respecting prefers-reduced-motion via the existing duration tokens. Verified against a running backend: registered a fresh customer (nav shows only Onboarding pre-approval), flipped kyc/account approval status directly in the dev DB and re-logged in (all 9 links render grouped, correctly at 1280px and 1024px), exercised the account menu and drawer's focus trap/Escape/outside-click, and confirmed the mobile drawer collapse below 768px. npm run lint and npm run build both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Plain SQLAlchemy .drop() on security/target_weight fails hard with DependentObjectsStillExist when another test file's fixture (e.g. test_rebalance_flow.py's Security/TargetWeight) holds a live FK at teardown time. Switch to raw DROP TABLE IF EXISTS ... CASCADE, matching tests/integration/conftest.py's ledger_tables fixture's identical fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
get_identity_status pre-checked settings.stripe_secret_key before building a KycPort, which skipped the self-heal poll in CI/test environments where that env var is unset -- even though tests monkeypatch _build_kyc_port directly, the same pattern start_kyc_session already relies on. Call _build_kyc_port() unconditionally and fail open only on its own RuntimeError, so the self-heal path actually runs wherever a port is available. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
mainwas rewound. At2026-09-06T15:58:07Zmainwas pushed from18b1847back toeae1a08,discarding both merged PRs:
5070ac3) — the frontend completion work18b1847) — CD fix, S13 agent-surface data layer, comment cleanupConsequences on
maintoday:.github/workflows/ci.ymlandcd.ymldo not exist, so neither CI nor CD can run at all.flask jobs seed-reference-datacommand is absent.Separately, six commits pushed to
feature_frontend_completionafter PR #17 merged were nevermerged anywhere, and never ran through CI (no PR was open at the time).
Implementation
This PR fast-forwards
maintofeature_frontend_completion(53b0cf0).eae1a08— the currentmain— is a direct ancestor of53b0cf0, so this is additive only; nothing onmainisdiscarded and no force push is involved.
Content beyond what
mainhas today:ci.yml+cd.yml, the frontend completion build, and the S13 agent-surface data layer.fix(backend): stop gating the identity self-heal poll onSTRIPE_SECRET_KEY.get_identity_statusonly ran the KYC self-heal when
settings.stripe_secret_key is not None, which is unset in CI, sothe poll never executed even though the tests substitute a fake port via
_build_kyc_port. Now itbuilds the port and fails open only when genuinely unconfigured.
feat(backend): idempotentflask jobs seed-reference-data— 4 securities (VTI, VXUS, BND, BIL)and 4 model portfolios, each target-weight set summing to exactly 1.0000. Reference data only; no
fabricated customer, holdings, or transaction history.
fix(frontend): chat tool-trace field names + Ask Trueup redesign; nav gated on real approvalstatus; portfolio model-assignment loading race;
assigned_atparsed as a calendar date ratherthan a UTC instant; OrderForm securities error state; Fees page fixes.
docs: zero-padded numeric prefixes acrossdocs/decisions/anddocs/specs/.Validation
uv run pytest tests/api/test_identity.py -q→ 11 passed (the identity fix, run locally).uv run ruff check app/controllers/api/identity.py→ clean.uv run mypy --strict app/controllers/api/identity.py→ clean for this file; 2 pre-existingunrelated errors remain in
app/integrations/openai/openai_agent_adapter.py.53b0cf0. This PR exists to run it. Do not merge on a red check.Security / DB / API impact
skips on a missing
STRIPE_SECRET_KEY. It still fails open rather than erroring the status read.No auth, session, or CSRF behaviour changes.
seed-reference-datawrites reference rows only (security,model_portfolio,target_weight) and is idempotent.