Developer FAQ¶
Why three Go binaries instead of one?¶
Each binary has a distinct resource profile and failure domain.
cmd/api-server— handles HTTP/REST and Google OAuth. Scales horizontally behind the Container Apps ingress. Can restart independently without dropping WebSocket connections.cmd/ws-server— maintains long-lived WebSocket connections from browsers (up to 40 K concurrent). Subscribes to Redis pub/sub and fans price updates out to clients. Kept separate so a bad deploy toapi-servernever drops live connections.cmd/flusher— a single-replica background worker. Drains Redis dirty sets into Postgres on a 100 ms tick. Runs as one replica so there are no write races between flusher instances.
Decision: three binaries over one monolith — independent scaling, isolated failure domains, distinct billing profiles on Container Apps.
See ftl-docs/architecture/12-new-stack-architecture.md for the replica counts and cost breakdown.
How do I run the whole stack locally?¶
From the workspace root (Documents/repos/):
ftl.sh starts Postgres, Redis, api-server, ws-server, flusher, and the frontend dev server. The frontend runs on http://localhost:5173; the API is on http://localhost:8080.
Note
ftl.sh sets DISABLE_RATE_LIMIT=1 and BOT_ENABLED=1 by default. Those values are wrong for production — see the security checklist in CLAUDE.md.
What is the difference between a legacy "trade" and a CFD "position"?¶
The legacy trade model was buy-and-hold: you bought shares in a player and held them until the match ended. The trades table records these.
A CFD (Contract for Difference) position, introduced in ADR-0004, has no share transfer. You open a position with a margin amount and direction (buy/sell), and your PnL is the price movement times your exposure. Positions live in the positions table, managed by internal/positions. The POST /api/trade endpoint was retired in ADR-0012; CFD is now the sole trading mechanism.
How does AMM pricing work? (Show the formula.)¶
The AMM uses a linear bonding curve (ADR-0001, superseded for the CFD layer by ADR-0004 but the underlying price model is unchanged):
Where:
- base_price — the player's starting price (set from Sportmonks rating at fixture kick-off)
- k — the slope constant (controls price sensitivity per share)
- net_shares_sold — cumulative (buys − sells) across all traders
Buying increases net_shares_sold, pushing the price up. Selling decreases it, pulling the price down. The formula is evaluated atomically inside a Redis Lua script on every trade.
The canonical source for the current formula and constants is ftl-docs/architecture/specs/amm-pricing.md.
Why is trade execution in Lua rather than Go?¶
Decision: Redis Lua scripts over message queue — single-threaded atomicity, zero lock contention (ADR-0002).
Redis runs Lua scripts atomically: no other Redis command executes while the script is running. This means the price read and the wallet/position mutation happen as one indivisible unit. In Go, replicating that guarantee would require distributed locks (slow, failure-prone) or a message queue (Service Bus at ~$0.05/million ops). The Lua hot-path is faster, cheaper, and simpler.
The trade scripts live under ftl-backend/internal/redis/lua/.
How does the idempotency guard work? What is client_request_id?¶
The client generates a client_request_id UUID before sending a position open or close request. The Lua script checks a Redis key idem:<client_request_id> before executing. If the key exists, the script returns the cached response without modifying state. If the key is absent, it sets the key (with a TTL), then executes the trade.
This means a network retry with the same client_request_id is safe: the second call gets the original result, and no duplicate row is inserted in trades or positions.
To verify the guard is active after a deploy:
You should see entries after live traffic. The guard is authoritative; the Postgres composite index on (user_id, client_request_id) is a secondary safety net only. See ftl-docs/reports/codexreview-01.md Issue 1 for the original finding.
How do price updates reach the browser without polling?¶
sequenceDiagram
participant Lua as Redis Lua (trade)
participant Pub as Redis pub/sub
participant WS as ws-server replica
participant B as Browser WebSocket
Lua->>Pub: PUBLISH prices:<instrumentId> {price,...}
Pub->>WS: fan-out to all subscribed replicas
WS->>B: send JSON price_update frame
Every trade execution publishes to a Redis channel prices:<instrumentId>. All three ws-server replicas subscribe to the relevant channels. When a message arrives, each replica fans it out over open WebSocket connections to browsers watching that instrument.
The browser connects by first calling POST /api/ws/token (gets a 60-second signed token) and then upgrading to ws://host/ws?token=<token>.
How does login work — there is no password?¶
FTL uses raw Google OAuth 2.0 (no password, no third-party auth service).
- Browser loads a Google sign-in button. The button returns a Google
id_tokenand a client-generatednonce. - Frontend posts
{idToken, nonce}toPOST /api/auth/google(handler:AuthGoogleinftl-backend/internal/handler/routes.go). - api-server verifies the
id_tokensignature with Google's public keys. It stores the nonce in Redis with a 5-minute TTL (one-shot replay guard). - If the Google
sub(subject) matches an existing user, the server issues RS256 JWT cookies (access15 min,refresh7 days) and returns 200. - If the
subis new, the server returns 422 with a short-livedregistrationTicket, triggers an email OTP, and the browser collects the remaining profile fields.POST /api/auth/verify-otp+POST /api/auth/google/completefinish registration.
Decision: raw Google OAuth — $0 at any scale, no MAU charges, no vendor lock-in to an auth-as-a-service provider.
What is a washout and when does it happen?¶
A washout closes all open CFD positions for a player at the player's current market price when the match reaches full time. ADR-0006 describes the "honest washout" design: the flusher replays price ticks emitted during the match so offline players get the same closing price as online ones.
ADR-0005 specifies that positions close at current_price (the live AMM price at FT), not at a fixed or flat price. ADR-0003 (which proposed a flat-price system-sell) was superseded by ADR-0005.
The code path is ftl-backend/internal/sportmonks/postmatch.go.
What is the difference between stop-loss and take-profit?¶
Both are conditional auto-close triggers on a CFD position:
- Stop-loss (SL) — closes the position if the instrument price falls to or below the SL level, capping the downside.
- Take-profit (TP) — closes the position if the instrument price rises to or above the TP level, locking in the gain.
SL/TP levels are stored on the position row. The positions.StopWatch worker (ftl-backend/internal/positions/) polls every 5 seconds and closes positions whose price has crossed the threshold. The Modify handler (PATCH /api/positions/:id) lets the user update SL/TP levels on an open position.
Which environment variables have no default and must be set in production?¶
Derived from ftl-backend/internal/config/config.go:
| Variable | Required when | Notes |
|---|---|---|
JWT_PUBLIC_KEY_B64 |
Always | Both api-server and ws-server validate tokens. Generate with ftl-backend/scripts/gen-dev-keys.sh. |
JWT_PRIVATE_KEY_B64 |
api-server only | Signs access + refresh tokens. |
REG_TICKET_SECRET |
api-server only | Min 32 chars. Signs short-lived registration tickets. |
DATABASE_URL |
api-server only | Postgres connection string. |
REDIS_URL |
Always | Validated at startup; no default is accepted as empty. |
GOOGLE_CLIENT_ID |
Production (ENV=production) |
Google OAuth app client ID. |
SPORTMONKS_API_KEY |
Production (ENV=production) |
Live player event feed. |
Variables with safe local defaults (e.g. API_PORT=8080, FLUSH_INTERVAL=100ms) are omitted from this table.
What happens if DISABLE_RATE_LIMIT=1 is left on in production?¶
All per-endpoint rate limits are bypassed. This includes the auth surface (POST /api/auth/google at 20 req/min/IP), the referral validation endpoint, and the global 200 req/min middleware in main.go. The app is then open to credential stuffing, referral code enumeration, and wallet-balance probing at full network speed. ftl.sh sets this variable to 1 for local load tests. It must be unset (or absent) in the production Container App environment. See the pre-launch security checklist in CLAUDE.md item 7.
How do I add a new REST endpoint to the backend?¶
- Write the handler method on
*Handlersinftl-backend/internal/handler/routes.go(or a new file in the same package). - Register the route in
(*Handlers).Register. Put public routes before theprotectedgroup; authenticated routes inside it. - If the endpoint needs a new service, add the service to
DepsandHandlers, wire it inNew(), and inject it fromcmd/api-server/main.go. -
Add the rate-limit wrapper via the
rl()helper if the endpoint is abuse-prone: -
Write a test in
ftl-backend/internal/handler/and rungo test -race ./internal/handler/....
How do I add a new Zustand store to the frontend?¶
- Create
ftl-frontend/src/stores/<name>Store.ts. - Define state and actions with
create<StoreType>()(...). Follow the existing pattern in adjacent store files for persist middleware or devtools. - Import and call the store hook from your component:
const { data, fetchData } = use<Name>Store(). - Add the store to the Playwright smoke test in
ftl-frontend/tests/if it backs a user-visible feature.
How do I create a new database migration?¶
# From ftl-backend/
touch migrations/<next-number>_<short-description>.up.sql
touch migrations/<next-number>_<short-description>.down.sql
Rules:
- Migration files are additive. Never use DROP TABLE, DROP COLUMN, TRUNCATE, or ALTER COLUMN ... TYPE on user-data columns in an .up.sql file without first opening an ADR in ftl-docs/architecture/decisions/.
- The CI pipeline (deploy-staging.yml, deploy-prod.yml) runs the migrate Container App Job before rolling new app replicas. New code never boots against an old schema.
- To run migrations locally: bash ftl.sh migrate (or the equivalent migrate up command in deployments/docker/Dockerfile.migrate).
How do staging and production deploys work?¶
Deploys are gated by two long-lived branches per repo:
| Branch | Effect |
|---|---|
release/staging |
Triggers deploy-staging.yml: build → migrate staging Postgres → roll staging Container Apps → smoke-check /health. |
release/prod |
Triggers deploy-prod.yml: pauses at the production GitHub Environment approval gate; on approval, migrates prod Postgres, rolls prod Container Apps. |
The promotion path is forward-only:
To promote staging to production after verification:
git checkout release/prod && git fetch origin
git merge --ff-only origin/release/staging && git push
See ftl-backend/CLAUDE.md §"Release branch model" for the full protocol, including hotfix reconciliation.
Why is AZURE_CONFIG_DIR=~/.azure-ftl needed for every az command?¶
This machine has two separate Azure accounts logged in. The default ~/.azure/ directory belongs to an unrelated account. Without the explicit config dir, any az command silently targets that other account, risking resource creation under the wrong subscription and burning unrelated credits.
Every FTL Azure command must use one of:
# Path A — safe in any shell
AZURE_CONFIG_DIR=~/.azure-ftl az account show
# Path B — source once per session
source ftl-infra/env.stg.sh # exports AZURE_CONFIG_DIR and subscription vars
az account show
Verify the active account before any mutation:
Expected: {"user": "jetafifa2026@gmail.com", "sub": "FootbalTradeLeage", ...}.
Never run scale-up.sh or scale-down.sh against an app in Multiple revision mode — that recreates the cost-overrun incident documented in ftl-docs/reports/azure-cost-audit-2026-05-02.md. Confirm activeRevisionsMode == "Single" first.
What is the flusher and why is it needed?¶
The flusher (ftl-backend/internal/flusher/flusher.go) is a background worker that runs in a single Container App replica on a 100 ms tick. Its job is to drain Redis dirty sets into Postgres.
During a trade, the Redis Lua script mutates wallet balances and instrument state in Redis (fast, atomic, in-memory) and adds affected IDs to dirty sets (dirty:instruments, dirty:wallets, dirty:positions). If every trade wrote directly to Postgres, the database would receive thousands of single-row updates per second under peak load. Instead, the flusher batches those IDs, reads the current Redis state, and upserts rows into Postgres in one pass per tick.
Crash safety: on each tick the flusher atomically renames the live dirty set to a timestamped snapshot key (dirty:<kind>:flushing:<ts>), then drains the snapshot. If the process crashes mid-drain, the snapshot key survives. RecoverOrphanSnapshots runs at startup and merges any leftover snapshots back into the live sets so no updates are lost.
Because the flusher's 100 ms loop has no HTTP ingress, Azure Container Apps always bills it at the active rate. Scale to zero overnight with ftl-infra/scripts/scale-down.sh to avoid unnecessary cost during non-development hours.