Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
6417b9a
docs: frontend completion design spec
Badri-Rajendran Sep 6, 2026
531e142
feat(frontend): wire portfolio, orders pricing, and chat to the real …
Badri-Rajendran Sep 6, 2026
7c8074e
feat(frontend): wire fees and the admin fee panel to the real backend
Badri-Rajendran Sep 6, 2026
6f6b6b3
feat(frontend): label the still-mocked screens as Simulated
Badri-Rajendran Sep 6, 2026
16cc11a
feat(frontend): parameterize nginx's backend proxy target for deployment
Badri-Rajendran Sep 6, 2026
99eef99
fix(frontend): correct nginx reverse-proxy config for Azure deployment
Badri-Rajendran Sep 6, 2026
5b551a8
fix(backend): use the attached PaymentMethod's own id, not the input …
Badri-Rajendran Sep 6, 2026
68265fd
docs: record the MVP deployment, its shortcuts, and two bugs found live
Badri-Rajendran Sep 6, 2026
e829a2c
ci: add GitHub Actions CI/CD, fix a flaky test and two bandit findings
Badri-Rajendran Sep 6, 2026
ad70e4e
fix(ci): fix real bugs the first-ever clean CI run surfaced
Badri-Rajendran Sep 6, 2026
ae91f54
fix(docker): patch a real frontend CVE and stop matrix fail-fast mask…
Badri-Rajendran Sep 6, 2026
04247f7
fix(frontend): stop the dashboard white-screening on a balance/return…
Badri-Rajendran Sep 6, 2026
9aa5ff4
fix(backend): wire up the job scheduler, outbox worker, and inbound-e…
Badri-Rajendran Sep 6, 2026
1e18af7
feat(backend): add S12 §3's missing indexes
Badri-Rajendran Sep 6, 2026
5503869
docs: ADR 24 + S13 spec for the MCP agent surface
Badri-Rajendran Sep 6, 2026
7faa43b
feat(backend): add GET /api/v1/lots (S8 §3)
Badri-Rajendran Sep 6, 2026
b99950f
feat(backend): add GET /api/v1/statements/<period>/export (S8 §5)
Badri-Rajendran Sep 6, 2026
9a5f1bf
feat(backend): add admin customer directory/detail/fees routes (S8 §4)
Badri-Rajendran Sep 6, 2026
f2ca823
feat(backend): add the KYC lock gate and admin override (S8 §4 row 5)
Badri-Rajendran Sep 6, 2026
a5afc85
ci: narrow CI scope to tests, migration check, SAST, secrets, Docker …
Badri-Rajendran Sep 6, 2026
0347606
docs(frontend): shorten lots/statements/fees/chat/admin-customers/adm…
Badri-Rajendran Sep 6, 2026
822abed
docs(frontend): shorten auth/onboarding/funding/portfolio/orders/valu…
Badri-Rajendran Sep 6, 2026
bd1507d
docs(frontend): shorten components/pages/services/hooks/routes/utils …
Badri-Rajendran Sep 6, 2026
8533ab9
docs(backend): shorten models/base.py and models/__init__.py docstrings
Badri-Rajendran Sep 6, 2026
25bd8a8
fix(cd): pass the mcp-server entrypoint as a plain script path
Badri-Rajendran Sep 6, 2026
b216d5b
docs(backend): shorten controllers/jobs/mcp comments and docstrings t…
Badri-Rajendran Sep 6, 2026
b4512e1
docs(backend): shorten core and integrations comments to 1-2 lines
Badri-Rajendran Sep 6, 2026
5419b0f
docs(backend): shorten reconciliation/rebalance/restatement comments
Badri-Rajendran Sep 6, 2026
8f7a0cb
docs(backend): shorten api/contract test comments to 1-2 lines
Badri-Rajendran Sep 6, 2026
db74fa8
feat(backend): add S13 agent-surface data layer (ADR 24)
Badri-Rajendran Sep 6, 2026
1bfb3c8
docs: add project scoring rubric and frontend completion design spec
Badri-Rajendran Sep 6, 2026
869f112
fix(frontend): revert dev-proxy target to port 5000
Badri-Rajendran Sep 6, 2026
bcf3bb4
fix(backend): suppress bandit B104 on the MCP bind host
Badri-Rajendran Sep 6, 2026
565663a
test(backend): drop test tables with raw CASCADE, not Table.drop()
Badri-Rajendran Sep 6, 2026
a829061
feat(frontend): live per-field validation on the register form
Badri-Rajendran Sep 6, 2026
8f2700f
feat(frontend): real email and MFA-code validation on the login form
Badri-Rajendran Sep 6, 2026
6ea8279
feat(frontend): ship the Vault color palette
Badri-Rajendran Sep 6, 2026
cc82a7f
docs: design spec for NN3 correction path and money-row immutability
Badri-Rajendran Sep 6, 2026
e45cc0d
fix(backend): self-heal identity verification status via live provide…
Badri-Rajendran Sep 6, 2026
1dbb076
feat(frontend): add shared Icon component and motion tokens
Badri-Rajendran Sep 6, 2026
499ebcb
fix(frontend): Fees page bug fix + visual consistency pass
Badri-Rajendran Sep 6, 2026
4ab660e
docs: zero-pad numeric prefixes in decisions and specs filenames
Badri-Rajendran Sep 6, 2026
03e380b
fix(frontend): parse assigned_at as a plain calendar date, not a UTC …
Badri-Rajendran Sep 6, 2026
01178dc
fix(frontend): fix model-assignment loading race on Portfolio/Dashboard
Badri-Rajendran Sep 6, 2026
26eb215
feat(frontend): redesign portfolio model selection and fix weight rou…
Badri-Rajendran Sep 6, 2026
a296025
fix(frontend): surface a real error state when OrderForm's securities…
Badri-Rajendran Sep 6, 2026
0e2c101
docs: remove duplicate frontend-completion design doc
Badri-Rajendran Sep 6, 2026
4bb5715
fix(frontend): repair chat tool-trace fields, redesign Ask Trueup
Badri-Rajendran Sep 6, 2026
0b2a2e0
feat(backend): add idempotent flask jobs seed-reference-data command
Badri-Rajendran Sep 6, 2026
cb830b9
fix(frontend): gate nav on real approval status, redesign the app shell
Badri-Rajendran Sep 6, 2026
bb66ad7
fix(backend): drop seed-reference-data test tables with CASCADE
Badri-Rajendran Sep 6, 2026
53b0cf0
fix(backend): stop gating identity self-heal on STRIPE_SECRET_KEY
Badri-Rajendran Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .claude/agents/backend-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ hard-coded secrets, no unhandled failure path.

## Source of truth, in this order

1. `docs/specs/0-backend-foundation-design.md` and the S1–S12 spec that owns the current work.
1. `docs/specs/00-backend-foundation-design.md` and the S1–S12 spec that owns the current work.
2. `docs/decisions/*` (ADRs) for *why* a mechanism is shaped the way it is.
3. `docs/requirements/requirements.md` for the FR/NFR ID behind any requirement.

Expand Down
2 changes: 1 addition & 1 deletion .claude/agents/frontend-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ real users — no placeholder logic, no debug code, no hard-coded secrets, no un
the way it is (session auth, SSE, withdrawable vs. investable cash, as-published vs. live).
4. The real backend controllers (`backend/app/controllers/api/*.py`, `backend/app/views/*.py`) for
the exact URL, payload, and response shape of any endpoint that actually exists — never
`docs/specs/8-surfaces.md` alone, since that spec is ahead of what's actually built. If a
`docs/specs/08-surfaces.md` alone, since that spec is ahead of what's actually built. If a
controller's shape and a frontend spec's documented shape disagree, the controller wins;
escalate the discrepancy rather than silently picking one.

Expand Down
300 changes: 300 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
name: CD

# Root CLAUDE.md: push images to Azure Container Registry, run Alembic migrations, deploy to
# Azure. Gated on green CI (the `ci` workflow already required on `main`) plus a manual approval
# via the `production` GitHub Environment -- rollback is re-running the previous successful
# workflow run, since every image is tagged with its commit SHA, never `:latest` (Container Apps
# does not reliably re-pull a moved tag, learned the hard way during the MVP deploy).
on:
workflow_run:
workflows: ["CI"]
branches: [main]
types: [completed]

permissions:
id-token: write
contents: read

env:
RESOURCE_GROUP: trueup-mvp-rg
REGISTRY: trueupmvpacr.azurecr.io
IMAGE_TAG: ${{ github.event.workflow_run.head_sha }}
CONTAINER_ENV: trueup-mvp-env
KEY_VAULT_URI: https://trueup-mvp-kv.vault.azure.net/
KEY_VAULT_ID: /subscriptions/5c16376b-0431-4786-895e-93e01db5f553/resourceGroups/trueup-mvp-rg/providers/Microsoft.KeyVault/vaults/trueup-mvp-kv
KEYVAULT_WRAP_KEY_NAME: trueup-wrap-key

jobs:
build-and-push:
name: Build + push images
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- run: az acr login --name trueupmvpacr
- uses: docker/setup-buildx-action@v3
- name: Build + push backend (linux/amd64 -- Container Apps requirement)
uses: docker/build-push-action@v6
with:
context: backend
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
- name: Build + push frontend (linux/amd64)
uses: docker/build-push-action@v6
with:
context: frontend
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/trueup-frontend:${{ env.IMAGE_TAG }}

migrate:
name: Run Alembic migrations
needs: build-and-push
runs-on: ubuntu-latest
environment: production
steps:
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# backend-migrate is a Container Apps Job (Manual trigger), matching ADR 13's own pattern:
# `az containerapp exec` needs a real TTY and cannot run non-interactively, so migrations
# run as a one-off Job instead of a `containerapp exec` step.
- name: Point the migration job at this build's image
run: |
az containerapp job update \
--name backend-migrate \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
- name: Run migrations and wait for completion
run: |
EXECUTION=$(az containerapp job start \
--name backend-migrate \
--resource-group ${{ env.RESOURCE_GROUP }} \
--query name -o tsv)
echo "Execution: $EXECUTION"
for i in $(seq 1 30); do
STATUS=$(az containerapp job execution show \
--name backend-migrate \
--resource-group ${{ env.RESOURCE_GROUP }} \
--job-execution-name "$EXECUTION" \
--query properties.status -o tsv 2>/dev/null || echo "Unknown")
echo "status: $STATUS"
case "$STATUS" in
Succeeded) exit 0 ;;
Failed|Stopped) echo "::error::Migration job $STATUS" && exit 1 ;;
esac
sleep 10
done
echo "::error::Migration job did not complete within the wait window" && exit 1

deploy:
name: Deploy backend + frontend
needs: migrate
runs-on: ubuntu-latest
environment: production
steps:
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy backend revision
run: |
az containerapp update \
--name backend \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
- name: Deploy frontend revision
run: |
az containerapp update \
--name frontend \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/trueup-frontend:${{ env.IMAGE_TAG }}
- name: Verify backend + frontend health
run: |
FQDN=$(az containerapp show --name frontend --resource-group ${{ env.RESOURCE_GROUP }} --query properties.configuration.ingress.fqdn -o tsv)
for i in $(seq 1 10); do
CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$FQDN/health/ready" || echo "000")
[ "$CODE" = "200" ] && echo "healthy" && exit 0
sleep 5
done
echo "::error::Deployed frontend never reported healthy" && exit 1

deploy-worker-and-mcp-server:
name: Deploy worker + mcp-server
needs: migrate
runs-on: ubuntu-latest
environment: production
steps:
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Both worker (ADR 13's always-on job_outbox drain process) and mcp-server (S13/ADR 24's
# MCP agent surface) reuse the backend image with a different command -- the same "different
# process, same database" shape docker-compose.yml already establishes locally. Neither
# existed as a Container App before this workflow; both are created idempotently here (a
# first run creates, every run after just updates the image) rather than provisioned by
# hand outside the pipeline, so this workflow is the one place their config is defined.
- name: Create or update the worker Container App
run: |
if az containerapp show --name worker --resource-group ${{ env.RESOURCE_GROUP }} &>/dev/null; then
echo "worker exists -- updating image only"
az containerapp update \
--name worker \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
exit 0
fi

echo "worker does not exist -- bootstrapping"
az containerapp create \
--name worker \
--resource-group ${{ env.RESOURCE_GROUP }} \
--environment ${{ env.CONTAINER_ENV }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }} \
--command flask \
--args "jobs" "outbox-worker" \
--min-replicas 1 --max-replicas 1 \
--system-assigned \
--registry-server ${{ env.REGISTRY }}

PRINCIPAL_ID=$(az containerapp show --name worker --resource-group ${{ env.RESOURCE_GROUP }} --query identity.principalId -o tsv)
az role assignment create --assignee "$PRINCIPAL_ID" --role "Key Vault Secrets User" --scope ${{ env.KEY_VAULT_ID }}
az role assignment create --assignee "$PRINCIPAL_ID" --role "Key Vault Crypto User" --scope ${{ env.KEY_VAULT_ID }}

# RBAC assignments are not instantaneous -- the secret-set step below resolves each
# keyvaultref immediately, so a fixed wait here is the simplest correct fix (confirmed
# during the manual OIDC setup earlier this session: ~15s was sufficient in practice).
sleep 30

az containerapp secret set \
--name worker \
--resource-group ${{ env.RESOURCE_GROUP }} \
--secrets \
database-url=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url,identityref:system \
database-url-worker=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-worker,identityref:system \
database-url-owner=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-owner,identityref:system \
database-url-chat=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-chat,identityref:system \
redis-url=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/redis-url,identityref:system \
secret-key=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/secret-key,identityref:system \
stripe-secret-key=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/stripe-secret-key,identityref:system \
alpaca-api-key-id=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/alpaca-api-key-id,identityref:system \
alpaca-api-secret-key=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/alpaca-api-secret-key,identityref:system

az containerapp update \
--name worker \
--resource-group ${{ env.RESOURCE_GROUP }} \
--set-env-vars \
FLASK_ENV=production \
FLASK_APP=wsgi \
DATABASE_URL=secretref:database-url \
DATABASE_URL_WORKER=secretref:database-url-worker \
DATABASE_URL_OWNER=secretref:database-url-owner \
DATABASE_URL_CHAT=secretref:database-url-chat \
REDIS_URL=secretref:redis-url \
SECRET_KEY=secretref:secret-key \
AZURE_KEY_VAULT_URL=${{ env.KEY_VAULT_URI }} \
AZURE_KEYVAULT_WRAP_KEY_NAME=${{ env.KEYVAULT_WRAP_KEY_NAME }} \
STRIPE_SECRET_KEY=secretref:stripe-secret-key \
ALPACA_API_KEY_ID=secretref:alpaca-api-key-id \
ALPACA_API_SECRET_KEY=secretref:alpaca-api-secret-key \
ALPACA_BASE_URL=https://paper-api.alpaca.markets \
FEE_RATE_PCT=0.0
- name: Create or update the mcp-server Container App
run: |
if az containerapp show --name mcp-server --resource-group ${{ env.RESOURCE_GROUP }} &>/dev/null; then
echo "mcp-server exists -- updating image only"
az containerapp update \
--name mcp-server \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
exit 0
fi

echo "mcp-server does not exist -- bootstrapping"
# External ingress: an MCP client connects from outside Azure (ADR 24 "Transport and
# deployment"). Auth is the app's own staff_api_token bearer check on every tool call
# (app/mcp/auth.py), not network-level restriction -- matching backend's own external
# ingress, which relies on its session/CSRF layer the same way.
az containerapp create \
--name mcp-server \
--resource-group ${{ env.RESOURCE_GROUP }} \
--environment ${{ env.CONTAINER_ENV }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }} \
--command python \
--args "app/mcp/entrypoint.py" \
--min-replicas 1 --max-replicas 1 \
--system-assigned \
--ingress external \
--target-port 8001 \
--registry-server ${{ env.REGISTRY }}

PRINCIPAL_ID=$(az containerapp show --name mcp-server --resource-group ${{ env.RESOURCE_GROUP }} --query identity.principalId -o tsv)
az role assignment create --assignee "$PRINCIPAL_ID" --role "Key Vault Secrets User" --scope ${{ env.KEY_VAULT_ID }}
az role assignment create --assignee "$PRINCIPAL_ID" --role "Key Vault Crypto User" --scope ${{ env.KEY_VAULT_ID }}

sleep 30

az containerapp secret set \
--name mcp-server \
--resource-group ${{ env.RESOURCE_GROUP }} \
--secrets \
database-url=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url,identityref:system \
database-url-worker=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-worker,identityref:system \
database-url-owner=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-owner,identityref:system \
database-url-chat=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-chat,identityref:system \
redis-url=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/redis-url,identityref:system \
secret-key=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/secret-key,identityref:system

az containerapp update \
--name mcp-server \
--resource-group ${{ env.RESOURCE_GROUP }} \
--set-env-vars \
FLASK_ENV=production \
DATABASE_URL=secretref:database-url \
DATABASE_URL_WORKER=secretref:database-url-worker \
DATABASE_URL_OWNER=secretref:database-url-owner \
DATABASE_URL_CHAT=secretref:database-url-chat \
REDIS_URL=secretref:redis-url \
SECRET_KEY=secretref:secret-key \
AZURE_KEY_VAULT_URL=${{ env.KEY_VAULT_URI }} \
AZURE_KEYVAULT_WRAP_KEY_NAME=${{ env.KEYVAULT_WRAP_KEY_NAME }} \
FEE_RATE_PCT=0.0 \
MCP_SERVER_HOST=0.0.0.0 \
MCP_SERVER_PORT=8001
- name: Verify worker is running (no HTTP endpoint of its own)
run: |
for i in $(seq 1 10); do
STATE=$(az containerapp revision list --name worker --resource-group ${{ env.RESOURCE_GROUP }} --query "[0].properties.runningState" -o tsv)
[ "$STATE" = "Running" ] && echo "worker running" && exit 0
sleep 5
done
echo "::error::worker never reached Running state ($STATE)" && exit 1
- name: Verify mcp-server health
run: |
FQDN=$(az containerapp show --name mcp-server --resource-group ${{ env.RESOURCE_GROUP }} --query properties.configuration.ingress.fqdn -o tsv)
for i in $(seq 1 10); do
# No /health route on the MCP transport itself -- a bare TCP/HTTP reachability check
# against the streamable-HTTP path is what proves the process is actually listening.
CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$FQDN/mcp" || echo "000")
[ "$CODE" != "000" ] && echo "mcp-server reachable (HTTP $CODE)" && exit 0
sleep 5
done
echo "::error::mcp-server never became reachable" && exit 1
Loading
Loading