§0TL;DR & current state
The Phase-1 single-day pipeline is live end-to-end in production — a real round-trip wrote June-19 holdings to the database. A Phase-2 multi-day upgrade is coded, merged, and tested, but its deployment to the running Railway service is unconfirmed, and the nightly schedule sits behind one operator activation gate.
trading.* tables written per statement, idempotentlyThe one thing to know
Merging Phase-2 (Fix A,31ae127) to main did not necessarily ship it. ViskaDB has no railway.toml / Procfile / GitHub-Actions deploy config — the service is deployed imperatively (Hades / operator via Railway). So the live apply-svc may still run the older single-day image. A manual redeploy + confirm is the real remaining technical step. See §9.
§1The end goal
The IBKR Flex stack is the data foundation of the "Viska Trading Intelligence" product (board #8, epic viska-pm#7). Its job: keep an authoritative, daily-updating book of the fund's holdings, NAV, cash, FX, trades and dividends in Postgres, so every downstream surface reads one source of truth instead of stale manual snapshots.
Mímir Slack bot consumer
Reads trading.v_* views to answer live portfolio questions and generate the daily Morning Brief (board #8 WS-4). migrations/20260610-03-trading_views.sql:4 — "Mimir bot API contract"
viska.gg dashboard consumer
Renders live positions + NAV + theme allocation via public.* views derived from trading.*. producers/README.md:1–12
Trading decision layer consumer
ViskaStrat / ViskaTrader (wr-viska-live) consume positions, P/L and FX for the idea→gate→decide→execute loop (board #5).
Corpus WS-A consumer
Trading facts feed the verified research corpus that backs morning-brief lede generation and the council ensemble (board #9).
Frame: CLAUDE.md:69 — board #8 "data product — IBKR/Flex ingest, trading schema, morning brief, corpus WS-A".
§2Architecture at a glance
Three moving parts, one ruling decision. The ruling decision (055-A, operator-ratified): n8n is pure transport; apply-svc is the SSOT parser. n8n pulls the raw Flex XML and uploads it untouched; the Railway service re-parses it and is the only writer to trading.*. This removed an earlier ambiguity about where parsing lives.
① n8n workflow live
ADeTjBZiHOMIXzvr — "Viska Macro Daily — IBKR Flex Pull". Triggers, polling, normalize, guard, upload, apply. Transport only.
② apply-svc on Railway deployed
flex-apply-svc-production.up.railway.app. Flask via gunicorn 'apply_service:create_app()'. Endpoints /apply, /sign-upload, /healthz. The SSOT writer.
③ Supabase live
Private flex-inbox Storage bucket (raw XML) + the trading.* schema (10 tables) + public.* read views. The book of record.
The Railway service surface
| Endpoint | Auth | Does |
|---|---|---|
POST /sign-upload | Bearer | Server-mints a short-lived Supabase signed upload URL (service-role key never leaves the server). apply_service.py:177–220 |
POST /apply | Bearer | Fetches the raw XML from the bucket, parses per-day, writes trading.* in one transaction, archives the object, alerts on failure. apply_service.py:125–169 |
GET /healthz | none | Liveness → 200 {"ok":true}. No build/version field — the live image can't be fingerprinted without Railway creds. apply_service.py:357–359 |
Environment surface (read in create_app())
| Var | Purpose |
|---|---|
FLEX_APPLY_WEBHOOK_SECRET | Static Bearer token for both endpoints (constant-time compare) |
VISKA_DB_URL | Postgres connection (service-role / owner) — the SQL apply target |
VISKA_SUPABASE_URL | Supabase project URL (Storage REST base) |
VISKA_SUPABASE_SERVICE_ROLE_KEY | Service-role key — Storage sign / fetch / move |
FLEX_INBOX_BUCKET | Bucket name (default flex-inbox) |
PORT | HTTP port (Railway-injected) |
Cited: apply_service.py:322–331 (env reads) · :46 — FLEX_UPLOADER_JWT DROPPED in task 060.
§3The data flow, end to end
┌──────────────────────────────────────────────────────────────────────────┐ │ IBKR Flex Web Service → n8n (transport) → apply-svc → trading.* │ └──────────────────────────────────────────────────────────────────────────┘ TRIGGER cron 0 30 22 * * 1-5 (22:30, Mon–Fri) ┐ on-demand webhook POST /viska/flex-pull ┘ (header-auth) │ IBKR PULL POST .../FlexWebService/SendRequest q=1384817 v=3 → ReferenceCode → poll GetStatement (20s × up to 6) → raw FlexQueryResponse XML │ guards: SendRequest "Success"? · Statement ready? (else Slack alert) ▼ NORMALIZE Code node → normalizeFlexDataset() (scripts/lib/flex-normalize.js) multi-day aware: report_dates[], position_count = Σ days, date_from/to guard "Statement Usable?" position_count > 0 AND report_date present │ (false → respond count=0, skip apply — empty-XML guard) ▼ SIGN POST /sign-upload (Bearer) ─────────────────────────────┐ apply-svc mints short-lived signed URL (service-role, server-side)│ │◄──────────────────────── signedUrl ───────────────────┘ UPLOAD PUT {signedUrl} raw XML → Supabase Storage flex-inbox/incoming/*.xml ▼ APPLY POST /apply (Bearer) ├─ fetch raw XML from flex-inbox ├─ build_sections_by_day() → one tuple per distinct report_date ◄── Fix A ├─ emit_days() → ONE BEGIN…COMMIT, per-day _stmt_<i> temp tables │ ON CONFLICT throughout (idempotent) ├─ apply_sql() over VISKA_DB_URL ├─ move object → processed/ (best-effort, non-blocking) └─ on error → stderr alert + INSERT ops.health_events + 500 (retry-safe) ▼ WRITE trading.statements · positions_snapshots · nav_snapshots · fx_balances trading.trades · cash_flows · dividends · fees_interest · instruments ▼ RESPOND { ok, account, reportDate, report_dates[], statements, positions, fx, nav_rows } READ v_positions_current · v_nav_latest · v_pnl_monthly → Mímir brief public.allocation_snapshots · positions_current · nav_latest → viska.gg
Cited: ViskaN8N flex-pull-ADeTjBZiHOMIXzvr.json:5–546 (nodes) · apply_service.py:104,116,125–169,227–299 · ibkr_import.py:142–407 · scripts/lib/flex-normalize.js.
§4The trading.* schema
Every table carries statement_id lineage back to the statement row that produced it. The importer writes all of them in one transaction per /apply call.
| Table | Holds | Dedup key |
|---|---|---|
statements | Per-(account, report_date) anchor | UNIQUE(file_hash) = sha256("flex:{account}:{report_date}") |
instruments | Security master (conid → symbol) | conid PK |
instrument_themes | Operator-editable taxonomy | UNIQUE(symbol) WHERE valid_to IS NULL |
positions_snapshots | Point-in-time open positions | UNIQUE(statement_id, conid) + partial NULL-conid index (FX pairs) |
nav_snapshots | NAV decomposition + TWR | UNIQUE(statement_id) |
fx_balances | FX position snapshots | UNIQUE(statement_id, currency) |
trades | Execution records | UNIQUE(conid, executed_at, qty, t_price, proceeds) |
cash_flows | Deposits / withdrawals | UNIQUE(statement_id, settle_date, currency, amount, md5(description)) |
dividends | Dividends + withholding | UNIQUE(statement_id, pay_date, symbol, gross, withholding) |
fees_interest | Fees + broker interest | ON CONFLICT DO NOTHING |
candles | EOD OHLC (n8n nightly job, separate) | UNIQUE(symbol, d, source) |
Cited: migrations/20260610-01-trading_schema_core.sql:17–79 · 20260610-02-trading_book.sql:11–194 · ibkr_import.py:181–352.
§5Idempotency — why re-firing is safe
Statement grain
file_hash = sha256("flex:{account}:{report_date}") — a semantic key, not a byte hash. One row per (account, report_date). ON CONFLICT(file_hash) DO UPDATE … RETURNING id. flex_import.py:20–31
Why not hash the bytes?
IBKR stamps whenGenerated fresh on every pull, so two pulls of the same trading day are byte-different but the same snapshot. A byte hash would mint a duplicate every night. The semantic key collapses them. task 056 / commit b1664ff
Each event table then carries its own UNIQUE constraint with ON CONFLICT DO NOTHING, so a re-fire of /apply with the same object produces zero net-new rows. This is what makes the 500-on-error / retry-from-inbox design correct, and what made the multi-day fix a pure importer change with no migration.
§6Development history
From operator directive to live pipeline in ~6 days. Phases below; status badges reflect state as of this report.
PHASE 0 · Foundation — CSV book (May → early June)
WS-1 stood up the trading.* schema + read-only views; WS-2 did a one-time IBKR Activity CSV first-import. That CSV book was the baseline — but static, not auto-updating. The whole Flex project exists to replace it with a live feed. STATE.md (ViskaN8N-010): "trading.* were populated by the one-time WS-2 CSV first-import — NOT auto-updating".
PHASE 1 · Flex XML parser + first live landing (Jun 17 → 19)
| Date | Ref | Milestone | State |
|---|---|---|---|
| 06-17 | b2eefa7 · task 003 | Flex XML parser built (flex_to_sections.py + flex_import.py). Gate-stream snapshots at latest reportDate; XXE / billion-laughs guard. Verified vs operator dump (NAV $14.96M, 31 pos exact). | live |
| 06-17 | task 053 | Public anon views for the viska.gg 7-stream FE contract. | live |
| 06-19 PM | prod apply | IBKR token validated live (the "length-suspect" red herring; Hades re-mirrored vault). Live prod @ 06-18: 21 positions, NAV $15,525,205.04, 5 FX. | live |
| 06-19 PM | b6434d4 | Task 003 Phase-2 code: daily NAV series + cash flows. Tested vs live statement (23 daily rows). Unpushed — held for CIO ratification. | coded |
PHASE 2 · Architecture decisions (Jun 16 → 22)
- 06-16 — Operator directive: "live IBKR query to shift the SSOT on holdings." Task 054 filed; Flex token env-vars wired.
- 06-17 — Decision 055-A ratified: n8n = transport, apply-svc = SSOT parser. Governs everything downstream.
- 06-19 — CIO ratification packet prepared: 9 items A–P (ISK→NAV interim, TWR method, MTD rename, market-quotes vendor, fx_rates, allocation, TSLA theme, position count 31→21, public-data posture). awaiting signature
PHASE 3 · Apply-service + auth (Jun 21)
| Time | Ref | Milestone | State |
|---|---|---|---|
| 09:40 | b1664ff · task 056 | Semantic dedup key (account, report_date) replaces the wrong bytestream hash. +4 TDD. | merged |
| 13:28 | a814f3f · task 057 · PR #28 | Private flex-inbox bucket migration + authenticated apply_service.py (HMAC). Heph APPROVE. | merged |
| 14:15 | bb0eb83 · task 057 fix · PR #29 | HMAC → Bearer: n8n blocks $env and Code nodes can't read creds, so HMAC-over-body was dead on arrival. Constant-time Bearer instead. | merged |
| 17:55 | d182bcb · task 060 · PR #30 | POST /sign-upload: FLEX_UPLOADER_JWT failed 403 live because Viska Supabase signs ES256 (asymmetric), not HS256. Fix: server mints short-lived signed upload URLs via service-role. + path-traversal / scope-escalation hardening. +18 TDD (62 green). | merged |
PHASE 4 · Deploy, wire, go live (Jun 21 → 22)
| Date | Ref | Milestone | State |
|---|---|---|---|
| 06-21 14:30 | Hades 058a | flex-inbox bucket migration applied to prod + env bindings resolved (value-safe). | prod |
| 06-22 | Hades 058b | apply-svc deployed to Railway GREEN. Smoke: 6×401 (auth-fail) + 1×200 (correct Bearer, no key leak). | deployed |
| 06-22 | ViskaN8N 059 · exec 35607 | Live end-to-end round-trip: IBKR pull 30 → sign 200 → PUT 200 → apply 200. June-19 holdings written (21 positions, 5 FX, 3 NAV rows). | live |
| 06-22 09:05 | MCP update | Operator insight: floating Flex date could overwrite good data with empty XML. "Statement Usable?" guard inserted before upload. | live |
| 06-22 22:30 | verify-only | Ground-truthed the "1 of 22 days lands" symptom on a real 22-statement pull: 1 report_date emitted, 21 dropped — confirmed by-design latest-reportDate filter, not a bug. | verified |
| 06-22 23:10 | f810998/31ae127 | Fix A — multi-day importer. build_sections_by_day() + emit_days() emit every distinct report_date in one transaction, per-day file_hash. No migration. TDD 10 new / 75 green. Real 22-day smoke: 22/22 land, idempotent. | merged |
| 06-22 23:10 | n8n#15 · 2f12e5c | Fix B — multi-day Normalize lib (scripts/lib/flex-normalize.js): multi-day-aware guard/metadata. 7/7 TDD green. | merged |
§7Decision records
| Decision | What & why | State |
|---|---|---|
| 055-A · apply-svc = SSOT | n8n uploads raw XML; apply-svc re-parses and is the sole trading.* writer. Removes the parse-locus ambiguity. | ratified |
| auth = Bearer | HMAC-over-body impossible in Viska n8n ($env blocked, Code nodes can't read creds). Static Bearer, constant-time compare. | ratified |
| signed-URLs | Server mints short-lived Supabase upload URLs (not a static JWT) — because the project signs ES256. | ratified |
| ES256 signing (infra fact) | Viska prod Supabase signs JWTs with ES256, not HS256 — the root cause behind the signed-URL design. | verified |
| 056 · semantic dedup | Dedup on (account, report_date), not raw bytes — IBKR restamps every pull. | ratified |
| CIO items A–P | NAV/ISK mechanism, TWR method, market-quotes vendor, public-data posture, etc. Gate Phase-2 full ingestion + the public live-flip. | awaiting CIO |
Captured in Viska-Wiki: [[flex-apply-service-auth-bearer]] · [[flex-upload-signed-urls]] · [[viska-supabase-es256-jwt-signing]].
§8Delivery status
| Component | Build | Merge | Deploy | Live verify |
|---|---|---|---|---|
| Task 056 — dedup key | ✅ | ✅ b1664ff | — | ✅ |
| Task 057 — bucket + apply-svc | ✅ | ✅ a814f3f/bb0eb83 | ✅ 058a | ✅ 401/200 smoke |
| Task 060 — sign-upload | ✅ | ✅ d182bcb | ✅ 058b | ✅ PUT 200 (exec 35607) |
| Task 059 — n8n wiring | ✅ | — | ✅ | ✅ June-19 data in DB |
| Phase-2 multiday — ViskaDB (Fix A) | ✅ 31ae127 | ✅ | unconfirmed | in-process only (22/22) |
| Phase-2 multiday — ViskaN8N (Fix B) | ✅ 2f12e5c | ✅ n8n#15 | node-sync pending | — |
| CIO ratification A–P | ✅ packet | — | — | awaiting CIO |
| On-demand webhook | ✅ design | — | — | blocked on creds |
§9Current unknowns & blockers
Ordered by what stands between "coded" and "the nightly feed lands all 22 days in prod." Confirmed facts and honest unknowns are marked distinctly — the deploy trigger is genuinely unconfirmed and should not be assumed.
🔴 Blocker 1 — Multi-day deploy is unconfirmed (the live gate)
Confirmed: ViskaDB has no railway.toml, Procfile, or .github/workflows/ (verified by find). The Mastra/Mímir repo does auto-deploy on push (its railway.toml + DEPLOYMENT.md). apply-svc does not follow that pattern — its initial deploy was done imperatively by Hades.
Unknown: whether the Railway service has an out-of-repo GitHub auto-deploy hook. Best inference (chronicle "post-merge Railway redeploy" + Hades manual 058b + absent config) is that it is manual — meaning merging Fix A (31ae127) did not ship it, and the live image is likely still single-day. Cannot fingerprint the live commit (no /version route, RAILWAY_TOKEN unset).
Unblock: Hades or operator triggers a Railway redeploy off 31ae127 and confirms green; then a pinned-fixture POST confirms the 200 carries statements:22 / 22 report_dates.
🔴 Blocker 2 — Operator activation gate
Workflow ADeTjBZiHOMIXzvr is left INACTIVE pending the operator's visual check that the June-19 data renders correctly on viska.gg (the page is behind an auth wall ViskaFront can't self-verify). On operator "go", the 22:30 Mon–Fri schedule arms. This is the single gate between the live single-day pipeline and a running nightly feed.
🟡 Blocker 3 — CIO ratification A–P pending signature
Shapes the NAV total (ISK interim mechanism), TWR method, market-quotes vendor, and the public-data posture. Gates the Phase-2 full ingestion build (all 18 Flex sections, not just the 4 gate-streams) and the public live-flip. All code is ready except where the posture decision (item P) gates the public view flip.
🟡 Blocker 4 — No test schema / staging service
There is only the one production Railway service. The joint A+B test can't run end-to-end against a non-prod target — any live /apply is a prod write. An in-process proof exists (22/22 idempotent on the real pull), but a true non-prod E2E either accepts that proof + a gated single prod fire, or first stands up a test schema / second service.
🟡 Blocker 5 — On-demand webhook blocked on n8n API creds
The second entry point (webhook alongside the 22:30 schedule) is designed and approved but build-blocked: N8N_API_URL / N8N_API_KEY don't reload after /clear (the direnv whitelist doesn't cover _Client-Orgs). Root-caused this session to nono profile drift; relaunch reloads them. Does not block the live nightly pull.
⚪ Smaller open items
/applyper-day response contract — the multi-day spec marks thestatements/report_datesresponse fields UNKNOWN for the ViskaFront contract; ViskaDB to confirm/enhance (v1.1).- Recurring infra gap — viska-pm
git pushto the Viska org needs a git-credential-helper (App installation token); durable state currently lands via boards / Wiki / devlog instead. - Recommended hardening — add a
/versionroute to apply-svc returning the build SHA, so future deploy-confirms need no Railway creds (closes the fingerprint gap in Blocker 1).