Skip to content

Restore main to the merged branch state + backend/frontend fixes - #18

Merged
Badri-Rajendran merged 52 commits into
mainfrom
feature_frontend_completion
Sep 6, 2026
Merged

Badri-Rajendran merged 52 commits into
mainfrom
feature_frontend_completion

Conversation

@Badri-Rajendran

Copy link
Copy Markdown
Owner

Problem

main was rewound. At 2026-09-06T15:58:07Z main was pushed from 18b1847 back to eae1a08,
discarding both merged PRs:

Consequences on main today:

  • .github/workflows/ci.yml and cd.yml do not exist, so neither CI nor CD can run at all.
  • The entire frontend completion build is absent.
  • The flask jobs seed-reference-data command is absent.

Separately, six commits pushed to feature_frontend_completion after PR #17 merged were never
merged anywhere, and never ran through CI (no PR was open at the time).

Implementation

This PR fast-forwards main to feature_frontend_completion (53b0cf0). eae1a08 — the current
main — is a direct ancestor of 53b0cf0, so this is additive only; nothing on main is
discarded and no force push is involved.

Content beyond what main has today:

  • Restores ci.yml + cd.yml, the frontend completion build, and the S13 agent-surface data layer.
  • fix(backend): stop gating the identity self-heal poll on STRIPE_SECRET_KEY. get_identity_status
    only ran the KYC self-heal when settings.stripe_secret_key is not None, which is unset in CI, so
    the poll never executed even though the tests substitute a fake port via _build_kyc_port. Now it
    builds the port and fails open only when genuinely unconfigured.
  • feat(backend): idempotent flask 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 approval
    status; portfolio model-assignment loading race; assigned_at parsed as a calendar date rather
    than a UTC instant; OrderForm securities error state; Fees page fixes.
  • docs: zero-padded numeric prefixes across docs/decisions/ and docs/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-existing
    unrelated errors remain in app/integrations/openai/openai_agent_adapter.py.
  • Full CI has not yet run on 53b0cf0. This PR exists to run it. Do not merge on a red check.

Security / DB / API impact

  • Security: the identity change widens when the KYC self-heal poll runs — it no longer silently
    skips on a missing STRIPE_SECRET_KEY. It still fails open rather than erroring the status read.
    No auth, session, or CSRF behaviour changes.
  • DB: no schema change. seed-reference-data writes reference rows only (security,
    model_portfolio, target_weight) and is idempotent.
  • API: no route added or removed; no response shape changed.

Badri-Rajendran and others added 30 commits September 5, 2026 23:51
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
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
Badri-Rajendran and others added 22 commits September 6, 2026 05:48
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
Copilot AI lite review requested due to automatic review settings September 6, 2026 16:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Badri-Rajendran
Badri-Rajendran merged commit 6ad67b5 into main Sep 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants