From 04d4373b41b0633350901812c656f80664d9a122 Mon Sep 17 00:00:00 2001 From: Raf Agent Date: Thu, 7 May 2026 10:09:13 +0800 Subject: [PATCH] feat: fold SSI Control Tower hybrid flow Fold SSI Control Tower under apps/ssi-control-tower, add hybrid CSV and SSI Plus instruction ingestion, source lineage, validation, exception queues, privacy hardening, and verification docs. Verified: apps/ssi-control-tower make test, ruff check ., root pnpm verify, git diff --check, staged secret/static scan, and Codex read-only review. --- .gitignore | 10 + .prettierignore | 1 + HANDOFF.md | 7 + README.md | 14 + ROADMAP.md | 13 + apps/ssi-control-tower/.gitignore | 19 + apps/ssi-control-tower/Dockerfile | 16 + apps/ssi-control-tower/Makefile | 21 + apps/ssi-control-tower/README.md | 199 +++ apps/ssi-control-tower/app/__init__.py | 0 apps/ssi-control-tower/app/api/__init__.py | 23 + apps/ssi-control-tower/app/api/approvals.py | 31 + apps/ssi-control-tower/app/api/audit.py | 20 + apps/ssi-control-tower/app/api/dashboard.py | 152 ++ apps/ssi-control-tower/app/api/exceptions.py | 42 + apps/ssi-control-tower/app/api/exports.py | 30 + apps/ssi-control-tower/app/api/imports.py | 28 + .../ssi-control-tower/app/api/instructions.py | 94 ++ apps/ssi-control-tower/app/api/ssis.py | 56 + apps/ssi-control-tower/app/api/validation.py | 43 + apps/ssi-control-tower/app/config.py | 22 + apps/ssi-control-tower/app/db.py | 64 + apps/ssi-control-tower/app/deps.py | 20 + apps/ssi-control-tower/app/errors.py | 19 + apps/ssi-control-tower/app/main.py | 42 + apps/ssi-control-tower/app/models.py | 353 +++++ apps/ssi-control-tower/app/rules/__init__.py | 0 apps/ssi-control-tower/app/rules/engine.py | 43 + .../ssi-control-tower/app/rules/evaluators.py | 155 +++ apps/ssi-control-tower/app/rules/loader.py | 30 + apps/ssi-control-tower/app/schemas.py | 36 + apps/ssi-control-tower/app/seed.py | 87 ++ .../app/services/approvals.py | 190 +++ apps/ssi-control-tower/app/services/audit.py | 58 + .../app/services/exceptions.py | 140 ++ apps/ssi-control-tower/app/services/export.py | 42 + .../app/services/ingestion.py | 227 +++ .../app/services/instruction_exceptions.py | 114 ++ .../app/services/instruction_ingestion.py | 278 ++++ .../app/services/instruction_validation.py | 271 ++++ .../ssi-control-tower/app/services/mapping.py | 32 + .../app/services/normalisation.py | 119 ++ .../app/services/readiness.py | 61 + .../app/services/source_adapters.py | 301 ++++ .../app/services/ssiplus_v3.py | 247 ++++ .../app/services/validation.py | 70 + apps/ssi-control-tower/app/web/__init__.py | 0 apps/ssi-control-tower/app/web/routes.py | 65 + apps/ssi-control-tower/app/web/static/app.js | 1 + .../app/web/templates/approvals.html | 22 + .../app/web/templates/audit.html | 22 + .../app/web/templates/base.html | 47 + .../app/web/templates/dashboard.html | 70 + .../app/web/templates/exceptions.html | 22 + .../app/web/templates/imports.html | 22 + .../app/web/templates/rules.html | 20 + .../app/web/templates/ssi_detail.html | 38 + .../app/web/templates/ssis_list.html | 24 + apps/ssi-control-tower/config.example | 2 + .../ssi-control-tower/data/country_codes.yaml | 18 + .../data/currency_codes.yaml | 12 + .../ssi-control-tower/data/field_aliases.yaml | 32 + .../data/market_directory.yaml | 18 + .../sample_realistic_multi_market_ssi.csv | 61 + .../sample_swiftref_ssiplus_v3_synthetic.tsv | 61 + apps/ssi-control-tower/data/seed_ssi.csv | 26 + apps/ssi-control-tower/data/seed_users.yaml | 18 + apps/ssi-control-tower/docker-compose.yml | 10 + ...DOFF_2026-05-06_ssi_control_tower_v2_ui.md | 64 + ...si_instruction_flow_implementation_plan.md | 1237 +++++++++++++++++ .../docs/private_data_lab_methodology.md | 43 + .../docs/product_positioning.md | 19 + .../docs/swiftref_ssi_structure_notes.md | 46 + .../docs/v2_business_logic_gap_analysis.md | 241 ++++ ...siplus_source_truth_implementation_plan.md | 236 ++++ .../docs/v2_ssiplus_source_truth_slice.md | 120 ++ apps/ssi-control-tower/pyproject.toml | 29 + .../rules/duplicate_active.yaml | 9 + apps/ssi-control-tower/rules/formats.yaml | 27 + apps/ssi-control-tower/rules/governance.yaml | 18 + .../rules/required_fields.yaml | 18 + apps/ssi-control-tower/rules/stale.yaml | 18 + .../ssi-control-tower/rules/t1_readiness.yaml | 18 + apps/ssi-control-tower/tests/conftest.py | 27 + .../tests/test_app_lifespan.py | 17 + .../tests/test_approvals_four_eyes.py | 81 ++ .../tests/test_audit_immutability.py | 17 + .../tests/test_csv_hybrid_ingestion.py | 51 + .../tests/test_csv_source_adapter.py | 135 ++ .../tests/test_e2e_demo_flow.py | 75 + .../tests/test_exceptions.py | 36 + apps/ssi-control-tower/tests/test_export.py | 25 + .../tests/test_import_ssiplus_v3_api.py | 44 + .../ssi-control-tower/tests/test_ingestion.py | 121 ++ .../tests/test_instruction_exceptions.py | 92 ++ .../test_instruction_ingestion_lineage.py | 125 ++ .../tests/test_instruction_validation.py | 111 ++ .../tests/test_normalisation.py | 46 + .../tests/test_raafet_style_web.py | 60 + .../tests/test_readiness_score.py | 8 + .../tests/test_rules_duplicate.py | 9 + .../tests/test_rules_formats.py | 11 + .../tests/test_rules_governance.py | 5 + .../tests/test_rules_required.py | 6 + .../tests/test_rules_stale.py | 6 + .../tests/test_schema_compatibility.py | 28 + .../tests/test_source_adapters_contract.py | 64 + .../tests/test_ssiplus_source_adapter.py | 74 + .../tests/test_ssiplus_v3_ingestion.py | 141 ++ .../tests/test_ssiplus_v3_parser.py | 116 ++ .../tests/test_unified_exception_queue.py | 49 + .../tests/test_v2_dashboard.py | 116 ++ .../ssi-control-tower/tests/test_v2_models.py | 117 ++ .../tests/test_v2_web_dashboard.py | 25 + docs/ssi-control-tower-fold-plan.md | 53 + 115 files changed, 8485 insertions(+) create mode 100644 apps/ssi-control-tower/.gitignore create mode 100644 apps/ssi-control-tower/Dockerfile create mode 100644 apps/ssi-control-tower/Makefile create mode 100644 apps/ssi-control-tower/README.md create mode 100644 apps/ssi-control-tower/app/__init__.py create mode 100644 apps/ssi-control-tower/app/api/__init__.py create mode 100644 apps/ssi-control-tower/app/api/approvals.py create mode 100644 apps/ssi-control-tower/app/api/audit.py create mode 100644 apps/ssi-control-tower/app/api/dashboard.py create mode 100644 apps/ssi-control-tower/app/api/exceptions.py create mode 100644 apps/ssi-control-tower/app/api/exports.py create mode 100644 apps/ssi-control-tower/app/api/imports.py create mode 100644 apps/ssi-control-tower/app/api/instructions.py create mode 100644 apps/ssi-control-tower/app/api/ssis.py create mode 100644 apps/ssi-control-tower/app/api/validation.py create mode 100644 apps/ssi-control-tower/app/config.py create mode 100644 apps/ssi-control-tower/app/db.py create mode 100644 apps/ssi-control-tower/app/deps.py create mode 100644 apps/ssi-control-tower/app/errors.py create mode 100644 apps/ssi-control-tower/app/main.py create mode 100644 apps/ssi-control-tower/app/models.py create mode 100644 apps/ssi-control-tower/app/rules/__init__.py create mode 100644 apps/ssi-control-tower/app/rules/engine.py create mode 100644 apps/ssi-control-tower/app/rules/evaluators.py create mode 100644 apps/ssi-control-tower/app/rules/loader.py create mode 100644 apps/ssi-control-tower/app/schemas.py create mode 100644 apps/ssi-control-tower/app/seed.py create mode 100644 apps/ssi-control-tower/app/services/approvals.py create mode 100644 apps/ssi-control-tower/app/services/audit.py create mode 100644 apps/ssi-control-tower/app/services/exceptions.py create mode 100644 apps/ssi-control-tower/app/services/export.py create mode 100644 apps/ssi-control-tower/app/services/ingestion.py create mode 100644 apps/ssi-control-tower/app/services/instruction_exceptions.py create mode 100644 apps/ssi-control-tower/app/services/instruction_ingestion.py create mode 100644 apps/ssi-control-tower/app/services/instruction_validation.py create mode 100644 apps/ssi-control-tower/app/services/mapping.py create mode 100644 apps/ssi-control-tower/app/services/normalisation.py create mode 100644 apps/ssi-control-tower/app/services/readiness.py create mode 100644 apps/ssi-control-tower/app/services/source_adapters.py create mode 100644 apps/ssi-control-tower/app/services/ssiplus_v3.py create mode 100644 apps/ssi-control-tower/app/services/validation.py create mode 100644 apps/ssi-control-tower/app/web/__init__.py create mode 100644 apps/ssi-control-tower/app/web/routes.py create mode 100644 apps/ssi-control-tower/app/web/static/app.js create mode 100644 apps/ssi-control-tower/app/web/templates/approvals.html create mode 100644 apps/ssi-control-tower/app/web/templates/audit.html create mode 100644 apps/ssi-control-tower/app/web/templates/base.html create mode 100644 apps/ssi-control-tower/app/web/templates/dashboard.html create mode 100644 apps/ssi-control-tower/app/web/templates/exceptions.html create mode 100644 apps/ssi-control-tower/app/web/templates/imports.html create mode 100644 apps/ssi-control-tower/app/web/templates/rules.html create mode 100644 apps/ssi-control-tower/app/web/templates/ssi_detail.html create mode 100644 apps/ssi-control-tower/app/web/templates/ssis_list.html create mode 100644 apps/ssi-control-tower/config.example create mode 100644 apps/ssi-control-tower/data/country_codes.yaml create mode 100644 apps/ssi-control-tower/data/currency_codes.yaml create mode 100644 apps/ssi-control-tower/data/field_aliases.yaml create mode 100644 apps/ssi-control-tower/data/market_directory.yaml create mode 100644 apps/ssi-control-tower/data/sample_realistic_multi_market_ssi.csv create mode 100644 apps/ssi-control-tower/data/sample_swiftref_ssiplus_v3_synthetic.tsv create mode 100644 apps/ssi-control-tower/data/seed_ssi.csv create mode 100644 apps/ssi-control-tower/data/seed_users.yaml create mode 100644 apps/ssi-control-tower/docker-compose.yml create mode 100644 apps/ssi-control-tower/docs/HANDOFF_2026-05-06_ssi_control_tower_v2_ui.md create mode 100644 apps/ssi-control-tower/docs/hybrid_unified_ssi_instruction_flow_implementation_plan.md create mode 100644 apps/ssi-control-tower/docs/private_data_lab_methodology.md create mode 100644 apps/ssi-control-tower/docs/product_positioning.md create mode 100644 apps/ssi-control-tower/docs/swiftref_ssi_structure_notes.md create mode 100644 apps/ssi-control-tower/docs/v2_business_logic_gap_analysis.md create mode 100644 apps/ssi-control-tower/docs/v2_ssiplus_source_truth_implementation_plan.md create mode 100644 apps/ssi-control-tower/docs/v2_ssiplus_source_truth_slice.md create mode 100644 apps/ssi-control-tower/pyproject.toml create mode 100644 apps/ssi-control-tower/rules/duplicate_active.yaml create mode 100644 apps/ssi-control-tower/rules/formats.yaml create mode 100644 apps/ssi-control-tower/rules/governance.yaml create mode 100644 apps/ssi-control-tower/rules/required_fields.yaml create mode 100644 apps/ssi-control-tower/rules/stale.yaml create mode 100644 apps/ssi-control-tower/rules/t1_readiness.yaml create mode 100644 apps/ssi-control-tower/tests/conftest.py create mode 100644 apps/ssi-control-tower/tests/test_app_lifespan.py create mode 100644 apps/ssi-control-tower/tests/test_approvals_four_eyes.py create mode 100644 apps/ssi-control-tower/tests/test_audit_immutability.py create mode 100644 apps/ssi-control-tower/tests/test_csv_hybrid_ingestion.py create mode 100644 apps/ssi-control-tower/tests/test_csv_source_adapter.py create mode 100644 apps/ssi-control-tower/tests/test_e2e_demo_flow.py create mode 100644 apps/ssi-control-tower/tests/test_exceptions.py create mode 100644 apps/ssi-control-tower/tests/test_export.py create mode 100644 apps/ssi-control-tower/tests/test_import_ssiplus_v3_api.py create mode 100644 apps/ssi-control-tower/tests/test_ingestion.py create mode 100644 apps/ssi-control-tower/tests/test_instruction_exceptions.py create mode 100644 apps/ssi-control-tower/tests/test_instruction_ingestion_lineage.py create mode 100644 apps/ssi-control-tower/tests/test_instruction_validation.py create mode 100644 apps/ssi-control-tower/tests/test_normalisation.py create mode 100644 apps/ssi-control-tower/tests/test_raafet_style_web.py create mode 100644 apps/ssi-control-tower/tests/test_readiness_score.py create mode 100644 apps/ssi-control-tower/tests/test_rules_duplicate.py create mode 100644 apps/ssi-control-tower/tests/test_rules_formats.py create mode 100644 apps/ssi-control-tower/tests/test_rules_governance.py create mode 100644 apps/ssi-control-tower/tests/test_rules_required.py create mode 100644 apps/ssi-control-tower/tests/test_rules_stale.py create mode 100644 apps/ssi-control-tower/tests/test_schema_compatibility.py create mode 100644 apps/ssi-control-tower/tests/test_source_adapters_contract.py create mode 100644 apps/ssi-control-tower/tests/test_ssiplus_source_adapter.py create mode 100644 apps/ssi-control-tower/tests/test_ssiplus_v3_ingestion.py create mode 100644 apps/ssi-control-tower/tests/test_ssiplus_v3_parser.py create mode 100644 apps/ssi-control-tower/tests/test_unified_exception_queue.py create mode 100644 apps/ssi-control-tower/tests/test_v2_dashboard.py create mode 100644 apps/ssi-control-tower/tests/test_v2_models.py create mode 100644 apps/ssi-control-tower/tests/test_v2_web_dashboard.py create mode 100644 docs/ssi-control-tower-fold-plan.md diff --git a/.gitignore b/.gitignore index e735131..4612425 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,13 @@ lerna-debug.log* .env .env.local .env.*.local + +# Folded SSI Control Tower local artifacts +apps/ssi-control-tower/.venv/ +apps/ssi-control-tower/.pytest_cache/ +apps/ssi-control-tower/.ruff_cache/ +apps/ssi-control-tower/**/__pycache__/ +apps/ssi-control-tower/data/*.db +apps/ssi-control-tower/data/*.sqlite +apps/ssi-control-tower/exports/ +apps/ssi-control-tower/cache/ diff --git a/.prettierignore b/.prettierignore index 49dcdca..bf49bd3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ coverage playwright-report test-results pnpm-lock.yaml +apps/ssi-control-tower diff --git a/HANDOFF.md b/HANDOFF.md index 36caf60..30abae6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,6 +7,13 @@ Read this file first, then check live `git` / GitHub state before acting. ## Current repository state +- Repository: `https://github.com/Raafet57/payment-intelligence-modules` +- Local path on Hermes: `/Users/Shared/AgentWork/repos/payment-intelligence-modules` +- Active feature branch for SSI work: `feat/ssi-hybrid-instruction-flow` +- SSI Control Tower is being folded into `apps/ssi-control-tower/` as a separate FastAPI/Jinja backend module. The root Vite/React browser suite remains static/browser-only, and root `src/` plus `scripts/privacy-audit.sh src` must stay scoped to the browser runtime. + +## Previous handoff snapshot + - Repository: `https://github.com/Raafet57/payment-intelligence-modules` - Local path on Hermes: `/Users/Shared/AgentWork/repos/payment-intelligence-modules` - Branch to start from next time: `main` diff --git a/README.md b/README.md index ca59e27..2c6f8ac 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ data. For the latest repo/session handoff, start with [`HANDOFF.md`](./HANDOFF.md). +The root suite remains static and browser-only. Backend/product-control modules live under `apps/**` as separate, explicitly scoped applications and are not part of the root browser runtime. + ## Modules | Module | Route | Status | What it does | @@ -69,6 +71,11 @@ banned APIs or absolute-URL `fetch` calls appear under `src/`. - Node.js >= 20 - pnpm >= 9 +For the folded SSI Control Tower backend module only: + +- Python 3.11 +- Run commands from `apps/ssi-control-tower/`; the module owns its own `pyproject.toml`, `Makefile`, tests, and SQLite development data. + ## Scripts ```bash @@ -154,6 +161,13 @@ scripts/ build-cbprplus-schema-manifest.ts build-iban-registry.ts privacy-audit.sh +apps/ + ssi-control-tower/ # Separate FastAPI/Jinja backend module; not part of root browser runtime + app/ # Python app, routers, services, web templates + data/ # Synthetic fixtures/reference YAML only; local DBs ignored + tests/ # pytest suite for the SSI backend module + pyproject.toml # Python dependencies owned by the nested module + Makefile # module-local test/install commands ``` ## Hosting notes (target-neutral) diff --git a/ROADMAP.md b/ROADMAP.md index 9cc091f..634764d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,19 @@ the suite is browser-only, static-host friendly, and privacy-first. ## Recommended next steps +### 0. SSI Control Tower folded backend module + +SSI Control Tower now lives under `apps/ssi-control-tower/` as a separate FastAPI/Jinja backend module inside this repository. It is not part of the root static browser suite runtime, and it must not weaken the browser-only privacy boundary for `src/`. + +Current branch scope is the approved hybrid unified SSI instruction flow, Phase -1 through Phase 2: + +- fold the module into this repo as ordinary tracked files; +- keep CSV demo imports compatible while also creating canonical `SsiInstruction` records with source lineage; +- make SSI Plus V3 imports use the same canonical ingestion path; +- add instruction-level validation, instruction exceptions, and a unified control queue API. + +Approvals, exports, readiness expansion, and full UI polish remain follow-up scope. + ### 1. Deployment target decision Pick one static hosting target and add only the minimal required SPA fallback diff --git a/apps/ssi-control-tower/.gitignore b/apps/ssi-control-tower/.gitignore new file mode 100644 index 0000000..967152e --- /dev/null +++ b/apps/ssi-control-tower/.gitignore @@ -0,0 +1,19 @@ +# Python / local runtime +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# Generated runtime state +data/ssi.db +data/exports/ +exports/ + +# Local agent metadata +.hermes/ +.claude/ + +# OS/editor noise +.DS_Store +*.swp diff --git a/apps/ssi-control-tower/Dockerfile b/apps/ssi-control-tower/Dockerfile new file mode 100644 index 0000000..3c6883d --- /dev/null +++ b/apps/ssi-control-tower/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + SSI_DB_PATH=data/ssi.db \ + SSI_EXPORT_DIR=data/exports + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY app ./app +COPY data ./data +COPY rules ./rules +COPY docs ./docs +RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -e .[test] +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/ssi-control-tower/Makefile b/apps/ssi-control-tower/Makefile new file mode 100644 index 0000000..59f0fb6 --- /dev/null +++ b/apps/ssi-control-tower/Makefile @@ -0,0 +1,21 @@ +PYTHON ?= python3.11 +VENV := .venv +PIP := $(VENV)/bin/pip +PY := $(VENV)/bin/python +UVICORN := $(VENV)/bin/uvicorn + +$(PY): pyproject.toml + $(PYTHON) -m venv $(VENV) + $(PIP) install --upgrade pip + $(PIP) install -e ".[test]" + +.PHONY: run test clean +run: $(PY) + $(UVICORN) app.main:app --host 0.0.0.0 --port 8000 --reload + +test: $(PY) + $(PY) -m pytest -q + +clean: + rm -rf $(VENV) .pytest_cache data/ssi.db data/exports __pycache__ + find . -name "__pycache__" -type d -prune -exec rm -rf {} + diff --git a/apps/ssi-control-tower/README.md b/apps/ssi-control-tower/README.md new file mode 100644 index 0000000..4d3fa72 --- /dev/null +++ b/apps/ssi-control-tower/README.md @@ -0,0 +1,199 @@ +# SSI Control Tower + +SSI Control Tower is a T+1 Standing Settlement Instruction readiness and governance prototype. The thesis is simple: SSI automation is lifecycle governance, not field validation. A failed rule without owner, SLA, remediation, approval, and audit evidence is not a control, so the app combines ingestion, mapping, normalization, YAML rules, exception ownership, four-eyes approval, immutable audit, readiness scoring, dashboards, and approved-record export. + +## Synthetic public prototype disclaimer + +This project is a synthetic-data public prototype for SSI readiness and governance. It is not affiliated with DTCC, not affiliated with SSImple, not affiliated with Swift, not affiliated with FMSB, not affiliated with ISITC, and not affiliated with any custodian or market infrastructure. It does not connect to production SSI utilities and does not contain real settlement instructions, client data, account numbers, or proprietary workflows. + +All demo data is synthetic. All account numbers are masked. No external APIs and no external LLMs are called. + +## Quickstart + +```bash +make run +``` + +Open: + +- Web UI: +- API docs: + +The app creates `data/ssi.db`, loads `data/seed_users.yaml`, imports `data/seed_ssi.csv`, runs validation, creates exception cases, and renders the dashboard. + +## Realistic synthetic sample files + +Additional synthetic sample files are provided for richer SSI demonstrations: + +- `data/sample_realistic_multi_market_ssi.csv` - app-compatible import CSV with 60 synthetic rows across 18 countries, 12 currencies, and 10 asset categories including Securities. +- `data/sample_swiftref_ssiplus_v3_synthetic.tsv` - SwiftRef `SSIPLUS_V3`-shaped tab-delimited synthetic sample with the 28-field structure from the SSI Plus technical specification. +- `docs/swiftref_ssi_structure_notes.md` - read-only structure notes explaining the SWIFTRef SSI Directory / SSI Plus concepts used to shape the samples. + +These files are synthetic and contain no production SSI records or unmasked account numbers. + +## V2 SSI Plus source-truth slice + +V2 adds a first SSI Plus-shaped source-truth layer while preserving the V1 synthetic CSV flow. Uploading `data/sample_swiftref_ssiplus_v3_synthetic.tsv` to `/api/v1/imports` now auto-routes to the V2 importer, persists source lineage and domain `ssi_instructions`, and exposes control-first aggregates at `/api/v1/dashboard/v2/source-controls`. The home dashboard also shows a compact "SSI Plus source truth" section. + +V2 privacy posture: source rejects, audit payloads, API responses, and dashboard aggregates avoid raw account values, source record keys, BDP keys, EIDs, institution names, and city values. See `docs/v2_ssiplus_source_truth_slice.md`. + +## Tests + +```bash +make test +``` + +The contract tests cover ingestion mapping, normalization, required-field rules, format rules, duplicate-active detection, stale checks, governance rules, exception workflows, four-eyes approvals, immutable audit triggers, readiness scoring, export filtering, and the full demo flow. + +## Commands + +```bash +make run # install local venv and start uvicorn +make test # install local venv and run pytest +make clean # remove venv, pytest cache, generated DB, and exports +``` + +## Docker Compose + +```bash +docker compose up --build +``` + +## Architecture + +```text +CSV upload / seed data + | + v ++---------------------+ +------------------+ +| Mapping aliases | -----> | Normalization | +| data/field_aliases | | dates/codes/BICs | ++---------------------+ +------------------+ + | | + v v ++--------------------------------------------------+ +| SQLite canonical model: entities, accounts, SSIs | ++--------------------------------------------------+ + | + v ++---------------------+ +-----------------------+ +| YAML rule loader | ----> | Python evaluator set | +| rules/*.yaml | | app/rules/evaluators | ++---------------------+ +-----------------------+ + | + v ++------------------+ +----------------+ +------------------+ +| Validation | | Exceptions/SLA | | Four-eyes approval| ++------------------+ +----------------+ +------------------+ + | | | + v v v ++--------------------------------------------------+ +| Immutable audit events with SQLite no-update/no-delete triggers | ++--------------------------------------------------+ + | + v ++------------------+ +------------------+ +| Readiness score | | Approved exports | ++------------------+ +------------------+ +``` + +## API demo flow + +Run the app first with `make run`, then in a second terminal: + +```bash +BASE=http://localhost:8000 + +curl -s $BASE/api/v1/dashboard/readiness-score | jq + +curl -s "$BASE/api/v1/exceptions?severity=critical&rule_id=SSI.DUPLICATE.ACTIVE" | jq + +PAIR_CONTEXT=$(curl -s "$BASE/api/v1/exceptions?severity=critical&rule_id=SSI.DUPLICATE.ACTIVE" | jq -r 'group_by(.context_key)[] | select(length==2) | .[0].context_key' | head -1) +STALE_EXCEPTION=$(curl -s "$BASE/api/v1/exceptions?severity=critical&rule_id=SSI.DUPLICATE.ACTIVE" | jq -r --arg ctx "$PAIR_CONTEXT" '.[] | select(.context_key==$ctx) | select(.ssi_id as $id | true) | .exception_id' | head -1) +STALE_SSI=$(curl -s "$BASE/api/v1/exceptions?severity=critical&rule_id=SSI.DUPLICATE.ACTIVE" | jq -r --arg ctx "$PAIR_CONTEXT" '.[] | select(.context_key==$ctx) | .ssi_id' | head -1) +REMAINING_SSI=$(curl -s "$BASE/api/v1/exceptions?severity=critical&rule_id=SSI.DUPLICATE.ACTIVE" | jq -r --arg ctx "$PAIR_CONTEXT" --arg retired "$STALE_SSI" '.[] | select(.context_key==$ctx and .ssi_id!=$retired) | .ssi_id' | head -1) + +curl -s -X PATCH "$BASE/api/v1/exceptions/$STALE_EXCEPTION/assign" \ + -H 'Content-Type: application/json' \ + -H 'X-User-Email: analyst@example.com' \ + -d '{"owner_user_email":"ops-owner@example.com"}' | jq + +curl -s -X POST "$BASE/api/v1/ssis/$STALE_SSI/retire" \ + -H 'X-User-Email: ops-owner@example.com' | jq + +APPROVAL_ID=$(curl -s -X POST "$BASE/api/v1/ssis/$REMAINING_SSI/submit-for-approval" \ + -H 'Content-Type: application/json' \ + -H 'X-User-Email: ops-owner@example.com' \ + -d '{"change_payload":{"local_agent_bic":"AGNTFRPPXXX"},"change_summary":"Correct local agent BIC after owner review","evidence_reference":"EV-001"}' | jq -r .approval_id) + +curl -i -X POST "$BASE/api/v1/approvals/$APPROVAL_ID/approve" \ + -H 'X-User-Email: ops-owner@example.com' + +curl -s -X POST "$BASE/api/v1/approvals/$APPROVAL_ID/approve" \ + -H 'X-User-Email: approver@example.com' | jq + +curl -s $BASE/api/v1/dashboard/readiness-score | jq + +EXPORT_ID=$(curl -s -X POST "$BASE/api/v1/exports" \ + -H 'Content-Type: application/json' \ + -H 'X-User-Email: approver@example.com' \ + -d '{"format":"csv"}' | jq -r .export_id) + +curl -s "$BASE/api/v1/exports/$EXPORT_ID/download" +``` + +## User roles + +- `analyst@example.com`: imports data, assigns exceptions, reviews dashboard +- `ops-owner@example.com`: owns remediation, retires obsolete SSIs, submits critical changes +- `refdata@example.com`: reference-data owner +- `approver@example.com`: approves or rejects pending settlement-critical changes +- `risk@example.com`: waives or reviews exceptions +- `admin@example.com`: seed administrator + +Authentication is a stub: set `X-User-Email` to select the actor. + +## Rule catalogue + +| Rule ID | Severity | Purpose | +| --- | --- | --- | +| `SSI.REQUIRED.MARKET_FIELDS` | critical | Active-like SSIs require market, country, asset class, and settlement method | +| `SSI.REQUIRED.EFFECTIVE_FROM` | high | Active-like SSIs require an effective-from date | +| `SSI.GOVERNANCE.OWNER_REQUIRED` | high | Active-like SSIs require an accountable owner | +| `SSI.FORMAT.BIC` | high | BIC syntax must be 8 or 11 uppercase alphanumeric characters | +| `SSI.FORMAT.COUNTRY_CODE` | medium | Country code must be two letters and present in the synthetic directory | +| `SSI.FORMAT.CURRENCY_CODE` | medium | Currency code must be three letters and present in the synthetic directory | +| `SSI.DATE.INVALID_RANGE` | high | `effective_to` cannot be earlier than `effective_from` | +| `SSI.DUPLICATE.ACTIVE` | critical | Active-like SSIs cannot overlap in the same duplicate context | +| `SSI.STALE.12M` | high | Last confirmation older than 365 days is stale | +| `SSI.STALE.CRITICAL_MARKET` | critical | Stale SSI in FR, DE, GB, IT, or ES is critical | +| `SSI.T1.RECENT_CONFIRMATION` | high | T+1 markets need confirmation within 180 days | +| `SSI.T1.NO_CRITICAL_EXCEPTIONS` | critical | T+1 records must not have unresolved critical exceptions | + +BIC validation is syntax-only and synthetic-demo scoped. It is not a current production BIC directory check. + +## Readiness formula + +```text +score = 100 + - min(25, critical_exceptions * 1.25) + - min(20, high_exceptions * 0.20) + - min(10, medium_exceptions * 0.03) + - min(15, stale_ssis / max(total_ssis, 1) * 100) + - min(15, duplicate_active_ssis * 0.50) + - min(10, missing_owner * 0.20) + - min(10, approval_sla_breaches * 0.30) + - min(10, missing_audit_evidence * 0.50) +score = max(0, round(score, 1)) +``` + +Bands: + +- 90-100: Green +- 75-89: Amber +- 50-74: Red +- 0-49: Critical + +## Private data-lab note + +Real SSI data must stay private and authorized. This public repository uses synthetic data only. Raw SSI records, account numbers, client names, proprietary workflows, credentials, and reversible redaction maps must never be committed. External model calls must not contain raw SSI records or proprietary post-trade data. See `docs/private_data_lab_methodology.md`. diff --git a/apps/ssi-control-tower/app/__init__.py b/apps/ssi-control-tower/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ssi-control-tower/app/api/__init__.py b/apps/ssi-control-tower/app/api/__init__.py new file mode 100644 index 0000000..6083485 --- /dev/null +++ b/apps/ssi-control-tower/app/api/__init__.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy.inspection import inspect + + +def as_dict(obj: Any) -> dict[str, Any]: + return {column.key: getattr(obj, column.key) for column in inspect(obj).mapper.column_attrs} + + +def rules_dict(rule: Any) -> dict[str, Any]: + return { + "rule_id": rule.rule_id, + "name": rule.name, + "severity": rule.severity, + "version": rule.version, + "enabled": rule.enabled, + "applies_to": rule.applies_to, + "evaluator": rule.evaluator, + "message": rule.message, + "suggested_fix": rule.suggested_fix, + } diff --git a/apps/ssi-control-tower/app/api/approvals.py b/apps/ssi-control-tower/app/api/approvals.py new file mode 100644 index 0000000..7fa7df1 --- /dev/null +++ b/apps/ssi-control-tower/app/api/approvals.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import current_actor, get_db +from app.models import ApprovalRequest +from app.schemas import RejectApprovalRequest +from app.services.approvals import approve_request, reject_request + +router = APIRouter(prefix="/api/v1/approvals", tags=["approvals"]) + + +@router.get("") +def list_approvals(status: str | None = None, session: Session = Depends(get_db)): + query = session.query(ApprovalRequest) + if status: + query = query.filter(ApprovalRequest.status == status) + return [as_dict(approval) for approval in query.order_by(ApprovalRequest.created_at.desc()).all()] + + +@router.post("/{approval_id}/approve") +def approve(approval_id: str, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + return as_dict(approve_request(session, approval_id, actor)) + + +@router.post("/{approval_id}/reject") +def reject(approval_id: str, request: RejectApprovalRequest | None = None, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + reason = request.decision_reason if request else "Rejected" + return as_dict(reject_request(session, approval_id, actor, reason)) diff --git a/apps/ssi-control-tower/app/api/audit.py b/apps/ssi-control-tower/app/api/audit.py new file mode 100644 index 0000000..ebf4a57 --- /dev/null +++ b/apps/ssi-control-tower/app/api/audit.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import get_db +from app.models import AuditEvent + +router = APIRouter(prefix="/api/v1/audit-events", tags=["audit"]) + + +@router.get("") +def audit_events(entity_id: str | None = None, action: str | None = None, session: Session = Depends(get_db)): + query = session.query(AuditEvent) + if entity_id: + query = query.filter(AuditEvent.entity_id == entity_id) + if action: + query = query.filter(AuditEvent.action == action) + return [as_dict(event) for event in query.order_by(AuditEvent.created_at.desc()).all()] diff --git a/apps/ssi-control-tower/app/api/dashboard.py b/apps/ssi-control-tower/app/api/dashboard.py new file mode 100644 index 0000000..da7d3fa --- /dev/null +++ b/apps/ssi-control-tower/app/api/dashboard.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from collections import Counter, defaultdict + +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import get_db +from app.models import ExceptionCase, SourceFile, SourceReject, SsiInstruction +from app.services.readiness import UNRESOLVED, readiness_score + +router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"]) +PREFERRED_TRUE_FLAGS = {"Y", "P", "TRUE", "1"} + + +@router.get("/readiness-score") +def get_readiness_score(session: Session = Depends(get_db)): + return readiness_score(session) + + +@router.get("/exceptions-by-severity") +def exceptions_by_severity(session: Session = Depends(get_db)): + cases = session.query(ExceptionCase).filter(ExceptionCase.status.in_(UNRESOLVED)).all() + return dict(Counter(case.severity for case in cases)) + + +@router.get("/exceptions-by-market") +def exceptions_by_market(session: Session = Depends(get_db)): + rows = session.query(ExceptionCase).filter(ExceptionCase.status.in_(UNRESOLVED)).all() + return dict(Counter((case.context_key or "").split("|")[3] if case.context_key and "|" in case.context_key else "unknown" for case in rows)) + + +@router.get("/stale-ssis") +def stale_ssis(session: Session = Depends(get_db)): + return [as_dict(case) for case in session.query(ExceptionCase).filter(ExceptionCase.rule_id == "SSI.STALE.12M", ExceptionCase.status.in_(UNRESOLVED)).all()] + + +@router.get("/duplicate-ssis") +def duplicate_ssis(session: Session = Depends(get_db)): + return [as_dict(case) for case in session.query(ExceptionCase).filter(ExceptionCase.rule_id == "SSI.DUPLICATE.ACTIVE", ExceptionCase.status.in_(UNRESOLVED)).all()] + + +def _date_intervals_overlap(a_start: str | None, a_stop: str | None, b_start: str | None, b_stop: str | None) -> bool: + """Return True when two [start, stop] intervals overlap; missing stop means open-ended.""" + if not a_start or not b_start: + return False + a_end = a_stop or "9999-12-31" + b_end = b_stop or "9999-12-31" + return a_start <= b_end and b_start <= a_end + + +def _count_by(session: Session, column) -> dict[str, int]: + rows = ( + session.query(column, func.count(SsiInstruction.ssi_instruction_id)) + .filter(SsiInstruction.status == "active") + .group_by(column) + .all() + ) + return {(value or "UNKNOWN"): count for value, count in rows} + + +@router.get("/v2/source-controls") +def v2_source_controls(session: Session = Depends(get_db)): + """Control-first aggregates over the V2 SSI Plus domain layer. + + Emits only counts and code-shaped category labels (currency/country codes, preferred flag + letters, asset categories). No raw account values, source keys, BDP keys, EIDs, institution + names, or city values appear in the response. + """ + source_file_total = session.query(func.count(SourceFile.source_file_id)).scalar() or 0 + rejects_total = session.query(func.count(SourceReject.source_reject_id)).scalar() or 0 + instructions_total = ( + session.query(func.count(SsiInstruction.ssi_instruction_id)) + .filter(SsiInstruction.status == "active") + .scalar() + or 0 + ) + + coverage = { + "by_currency": _count_by(session, SsiInstruction.currency_code), + "by_asset_category": _count_by(session, SsiInstruction.asset_category), + "by_owner_country": _count_by(session, SsiInstruction.owner_country_code), + "by_account_holder_country": _count_by(session, SsiInstruction.account_holder_country_code), + } + + preferred_rows = ( + session.query( + SsiInstruction.preferred_flag, + func.count(SsiInstruction.ssi_instruction_id), + ) + .filter(SsiInstruction.status == "active") + .group_by(SsiInstruction.preferred_flag) + .all() + ) + preferred_by_flag: dict[str, int] = {} + for flag, count in preferred_rows: + key = (flag or "").strip() or "UNSET" + preferred_by_flag[key] = preferred_by_flag.get(key, 0) + count + + active = ( + session.query(SsiInstruction) + .filter(SsiInstruction.status == "active") + .all() + ) + + groups: dict[tuple[str, str, str, str], list[SsiInstruction]] = defaultdict(list) + for inst in active: + groups[ + ( + inst.owner_bic or "", + inst.currency_code or "", + inst.asset_category or "", + inst.account_holder_bic or "", + ) + ].append(inst) + + preferred_conflict_groups = 0 + overlapping_active_groups = 0 + for members in groups.values(): + preferred_count = sum(1 for m in members if (m.preferred_flag or "").strip().upper() in PREFERRED_TRUE_FLAGS) + if preferred_count > 1: + preferred_conflict_groups += 1 + if len(members) > 1: + overlap_found = False + for i in range(len(members)): + for j in range(i + 1, len(members)): + a, b = members[i], members[j] + if _date_intervals_overlap(a.start_date, a.stop_date, b.start_date, b.stop_date): + overlap_found = True + break + if overlap_found: + break + if overlap_found: + overlapping_active_groups += 1 + + missing_start = sum(1 for m in active if not m.start_date) + missing_update = sum(1 for m in active if not m.update_date) + + return { + "source_files": {"total": int(source_file_total), "rejects_total": int(rejects_total)}, + "instructions": {"total": int(instructions_total)}, + "coverage": coverage, + "preferred": {"by_flag": preferred_by_flag}, + "conflicts": { + "preferred_conflict_groups": preferred_conflict_groups, + "overlapping_active_groups": overlapping_active_groups, + }, + "lifecycle": {"missing_start_date": missing_start}, + "freshness": {"missing_update_date": missing_update}, + } diff --git a/apps/ssi-control-tower/app/api/exceptions.py b/apps/ssi-control-tower/app/api/exceptions.py new file mode 100644 index 0000000..9adbb22 --- /dev/null +++ b/apps/ssi-control-tower/app/api/exceptions.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import current_actor, get_db +from app.models import ExceptionCase +from app.schemas import AssignExceptionRequest, ResolveExceptionRequest, WaiveExceptionRequest +from app.services.exceptions import assign_exception, resolve_exception, waive_exception + +router = APIRouter(prefix="/api/v1/exceptions", tags=["exceptions"]) + + +@router.get("") +def list_exceptions(severity: str | None = None, rule_id: str | None = None, status: str | None = None, session: Session = Depends(get_db)): + query = session.query(ExceptionCase) + if severity: + query = query.filter(ExceptionCase.severity == severity) + if rule_id: + query = query.filter(ExceptionCase.rule_id == rule_id) + if status: + if status == "open": + query = query.filter(ExceptionCase.status.in_(["open", "assigned", "in_remediation", "pending_approval"])) + else: + query = query.filter(ExceptionCase.status == status) + return [as_dict(case) for case in query.order_by(ExceptionCase.created_at.asc()).all()] + + +@router.patch("/{exception_id}/assign") +def assign(exception_id: str, request: AssignExceptionRequest, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + return as_dict(assign_exception(session, exception_id, request.owner_user_email, actor)) + + +@router.patch("/{exception_id}/resolve") +def resolve(exception_id: str, request: ResolveExceptionRequest, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + return as_dict(resolve_exception(session, exception_id, request.resolution_evidence, actor)) + + +@router.patch("/{exception_id}/waive") +def waive(exception_id: str, request: WaiveExceptionRequest, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + return as_dict(waive_exception(session, exception_id, request.reason, request.expiry_date, actor)) diff --git a/apps/ssi-control-tower/app/api/exports.py b/apps/ssi-control-tower/app/api/exports.py new file mode 100644 index 0000000..3b5390c --- /dev/null +++ b/apps/ssi-control-tower/app/api/exports.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import current_actor, get_db +from app.errors import raise_app_error +from app.models import ExportBatch +from app.schemas import ExportRequest +from app.services.export import create_export + +router = APIRouter(prefix="/api/v1/exports", tags=["exports"]) + + +@router.post("", status_code=201) +def export_records(request: ExportRequest, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + return as_dict(create_export(session, request.format, actor)) + + +@router.get("/{export_id}/download") +def download_export(export_id: str, session: Session = Depends(get_db)): + export = session.get(ExportBatch, export_id) + if not export: + raise_app_error(404, "Export not found", "EXPORT_NOT_FOUND") + path = Path(export.file_path) + return FileResponse(path, media_type="text/csv" if export.format == "csv" else "application/json", filename=path.name) diff --git a/apps/ssi-control-tower/app/api/imports.py b/apps/ssi-control-tower/app/api/imports.py new file mode 100644 index 0000000..7eb56f1 --- /dev/null +++ b/apps/ssi-control-tower/app/api/imports.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, UploadFile +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import current_actor, get_db +from app.models import ImportBatch +from app.services.ingestion import ingest_csv_bytes, ingest_ssiplus_v3_bytes +from app.services.ssiplus_v3 import is_ssiplus_v3_bytes, looks_like_ssiplus_v3_bytes + +router = APIRouter(prefix="/api/v1/imports", tags=["imports"]) + + +@router.post("", status_code=201) +async def import_csv(file: UploadFile = File(...), session: Session = Depends(get_db), actor: str = Depends(current_actor)): + data = await file.read() + file_name = file.filename or "upload" + if is_ssiplus_v3_bytes(data) or looks_like_ssiplus_v3_bytes(data): + batch = ingest_ssiplus_v3_bytes(session, data, file_name, uploaded_by=actor) + else: + batch = ingest_csv_bytes(session, data, file_name, uploaded_by=actor, run_validation=True) + return as_dict(batch) + + +@router.get("") +def list_imports(session: Session = Depends(get_db)): + return [as_dict(batch) for batch in session.query(ImportBatch).order_by(ImportBatch.created_at.desc()).all()] diff --git a/apps/ssi-control-tower/app/api/instructions.py b/apps/ssi-control-tower/app/api/instructions.py new file mode 100644 index 0000000..13d1f0b --- /dev/null +++ b/apps/ssi-control-tower/app/api/instructions.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.deps import get_db +from app.models import ExceptionCase, InstructionExceptionCase +from app.services.exceptions import UNRESOLVED + +router = APIRouter(tags=["instructions"]) + + +def _instruction_exception_dict(case: InstructionExceptionCase) -> dict[str, str | None]: + return { + "record_type": "ssi_instruction", + "record_id": case.ssi_instruction_id, + "exception_id": case.exception_id, + "rule_id": case.rule_id, + "severity": case.severity, + "failed_field": case.failed_field, + "description": case.description, + "suggested_fix": case.suggested_fix, + "owner_user_email": case.owner_user_email, + "status": case.status, + "created_at": case.created_at, + "updated_at": case.updated_at, + "context_key": case.context_key, + } + + +def _legacy_queue_dict(case: ExceptionCase) -> dict[str, str | None]: + return { + "queue_type": "legacy_ssi", + "exception_id": case.exception_id, + "record_type": "ssi_record", + "record_id": case.ssi_id, + "rule_id": case.rule_id, + "severity": case.severity, + "failed_field": case.failed_field, + "description": case.description, + "suggested_fix": case.suggested_fix, + "owner_user_email": case.owner_user_email, + "status": case.status, + "created_at": case.created_at, + "updated_at": case.updated_at, + "context_key": case.context_key, + } + + +def _instruction_queue_dict(case: InstructionExceptionCase) -> dict[str, str | None]: + payload = _instruction_exception_dict(case) + return { + "queue_type": "instruction", + "exception_id": payload["exception_id"], + "record_type": payload["record_type"], + "record_id": payload["record_id"], + "rule_id": payload["rule_id"], + "severity": payload["severity"], + "failed_field": payload["failed_field"], + "description": payload["description"], + "suggested_fix": payload["suggested_fix"], + "owner_user_email": payload["owner_user_email"], + "status": payload["status"], + "created_at": payload["created_at"], + "updated_at": payload["updated_at"], + "context_key": payload["context_key"], + } + + +@router.get("/api/v1/instruction-exceptions") +def list_instruction_exceptions(status: str | None = None, session: Session = Depends(get_db)): + query = session.query(InstructionExceptionCase) + if status: + if status == "open": + query = query.filter(InstructionExceptionCase.status.in_(UNRESOLVED)) + else: + query = query.filter(InstructionExceptionCase.status == status) + return [_instruction_exception_dict(case) for case in query.order_by(InstructionExceptionCase.created_at.asc()).all()] + + +@router.get("/api/v1/control-exceptions") +def list_control_exceptions(status: str | None = None, session: Session = Depends(get_db)): + legacy_query = session.query(ExceptionCase) + instruction_query = session.query(InstructionExceptionCase) + if status: + if status == "open": + legacy_query = legacy_query.filter(ExceptionCase.status.in_(UNRESOLVED)) + instruction_query = instruction_query.filter(InstructionExceptionCase.status.in_(UNRESOLVED)) + else: + legacy_query = legacy_query.filter(ExceptionCase.status == status) + instruction_query = instruction_query.filter(InstructionExceptionCase.status == status) + rows = [_legacy_queue_dict(case) for case in legacy_query.all()] + rows.extend(_instruction_queue_dict(case) for case in instruction_query.all()) + return sorted(rows, key=lambda item: str(item["created_at"] or "")) diff --git a/apps/ssi-control-tower/app/api/ssis.py b/apps/ssi-control-tower/app/api/ssis.py new file mode 100644 index 0000000..f16bf49 --- /dev/null +++ b/apps/ssi-control-tower/app/api/ssis.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import current_actor, get_db +from app.errors import raise_app_error +from app.models import SsiRecord +from app.schemas import SubmitApprovalRequest +from app.services.approvals import patch_ssi, retire_ssi, submit_for_approval +from app.services.validation import validate_record + +router = APIRouter(prefix="/api/v1/ssis", tags=["ssis"]) + + +@router.get("") +def list_ssis(status: str | None = None, session: Session = Depends(get_db)): + query = session.query(SsiRecord) + if status: + query = query.filter(SsiRecord.status == status) + return [as_dict(record) for record in query.order_by(SsiRecord.ssi_id).all()] + + +@router.get("/{ssi_id}") +def get_ssi(ssi_id: str, session: Session = Depends(get_db)): + record = session.get(SsiRecord, ssi_id) + if not record: + raise_app_error(404, "SSI not found", "SSI_NOT_FOUND") + return as_dict(record) + + +@router.patch("/{ssi_id}") +def update_ssi(ssi_id: str, payload: dict, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + return as_dict(patch_ssi(session, ssi_id, payload, actor)) + + +@router.post("/{ssi_id}/validate") +def validate_ssi(ssi_id: str, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + record = session.get(SsiRecord, ssi_id) + if not record: + raise_app_error(404, "SSI not found", "SSI_NOT_FOUND") + results = validate_record(session, record, actor=actor) + session.commit() + return [as_dict(result) for result in results] + + +@router.post("/{ssi_id}/submit-for-approval", status_code=201) +def submit_change(ssi_id: str, request: SubmitApprovalRequest, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + approval = submit_for_approval(session, ssi_id, request.change_payload, request.change_summary, request.evidence_reference, actor) + return as_dict(approval) + + +@router.post("/{ssi_id}/retire") +def retire(ssi_id: str, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + return as_dict(retire_ssi(session, ssi_id, actor)) diff --git a/apps/ssi-control-tower/app/api/validation.py b/apps/ssi-control-tower/app/api/validation.py new file mode 100644 index 0000000..67cb11a --- /dev/null +++ b/apps/ssi-control-tower/app/api/validation.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api import as_dict, rules_dict +from app.deps import get_db +from app.models import ValidationResult +from app.rules.loader import load_rules +from app.schemas import RulePatchRequest + +router = APIRouter(prefix="/api/v1", tags=["validation"]) +_RULE_OVERRIDES: dict[str, bool] = {} + + +@router.get("/validation-results") +def validation_results(ssi_id: str | None = None, rule_id: str | None = None, status: str | None = None, session: Session = Depends(get_db)): + query = session.query(ValidationResult) + if ssi_id: + query = query.filter(ValidationResult.ssi_id == ssi_id) + if rule_id: + query = query.filter(ValidationResult.rule_id == rule_id) + if status: + query = query.filter(ValidationResult.status == status) + return [as_dict(result) for result in query.order_by(ValidationResult.created_at.desc()).all()] + + +@router.get("/rules") +def rules(): + payload = [] + for rule in load_rules(): + item = rules_dict(rule) + if rule.rule_id in _RULE_OVERRIDES: + item["enabled"] = _RULE_OVERRIDES[rule.rule_id] + payload.append(item) + return payload + + +@router.patch("/rules/{rule_id}") +def patch_rule(rule_id: str, request: RulePatchRequest): + if request.enabled is not None: + _RULE_OVERRIDES[rule_id] = request.enabled + return next((item for item in rules() if item["rule_id"] == rule_id), {"rule_id": rule_id, "enabled": request.enabled}) diff --git a/apps/ssi-control-tower/app/config.py b/apps/ssi-control-tower/app/config.py new file mode 100644 index 0000000..3b573bc --- /dev/null +++ b/apps/ssi-control-tower/app/config.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = ROOT_DIR / "data" +RULES_DIR = ROOT_DIR / "rules" + + +def database_path() -> Path: + return Path(os.getenv("SSI_DB_PATH", str(DATA_DIR / "ssi.db"))) + + +def export_dir() -> Path: + return Path(os.getenv("SSI_EXPORT_DIR", str(DATA_DIR / "exports"))) + + +def utc_now() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/apps/ssi-control-tower/app/db.py b/apps/ssi-control-tower/app/db.py new file mode 100644 index 0000000..db8106c --- /dev/null +++ b/apps/ssi-control-tower/app/db.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import database_path + + +class Base(DeclarativeBase): + pass + +_ENGINE = None +SessionLocal = sessionmaker(autoflush=False, autocommit=False, expire_on_commit=False) + + +def get_engine(): + """Return the process-local SQLite engine for the configured database path.""" + global _ENGINE + if _ENGINE is None: + path = database_path() + path.parent.mkdir(parents=True, exist_ok=True) + _ENGINE = create_engine(f"sqlite:///{path}", connect_args={"check_same_thread": False}) + SessionLocal.configure(bind=_ENGINE) + return _ENGINE + + +def reset_engine() -> None: + """Dispose the cached engine so tests can swap database paths deterministically.""" + global _ENGINE + if _ENGINE is not None: + _ENGINE.dispose() + _ENGINE = None + SessionLocal.configure(bind=None) + + +def create_audit_triggers() -> None: + engine = get_engine() + with engine.begin() as conn: + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS audit_events_no_update + BEFORE UPDATE ON audit_events + BEGIN + SELECT RAISE(ABORT, 'audit_events are insert-only'); + END; + """)) + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS audit_events_no_delete + BEFORE DELETE ON audit_events + BEGIN + SELECT RAISE(ABORT, 'audit_events are insert-only'); + END; + """)) + + +def init_db() -> None: + """Create tables and audit immutability triggers.""" + from app import models # noqa: F401 + + Base.metadata.create_all(get_engine()) + create_audit_triggers() + + +def get_session() -> Session: + return SessionLocal() diff --git a/apps/ssi-control-tower/app/deps.py b/apps/ssi-control-tower/app/deps.py new file mode 100644 index 0000000..6aeb392 --- /dev/null +++ b/apps/ssi-control-tower/app/deps.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from collections.abc import Generator + +from fastapi import Header +from sqlalchemy.orm import Session + +from app.db import SessionLocal + + +def get_db() -> Generator[Session, None, None]: + session = SessionLocal() + try: + yield session + finally: + session.close() + + +def current_actor(x_user_email: str = Header(default="analyst@example.com", alias="X-User-Email")) -> str: + return x_user_email diff --git a/apps/ssi-control-tower/app/errors.py b/apps/ssi-control-tower/app/errors.py new file mode 100644 index 0000000..c9767b3 --- /dev/null +++ b/apps/ssi-control-tower/app/errors.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from fastapi import Request +from fastapi.responses import JSONResponse + + +class AppError(Exception): + def __init__(self, status_code: int, detail: str, code: str) -> None: + self.status_code = status_code + self.detail = detail + self.code = code + + +async def app_error_handler(_: Request, exc: AppError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail, "code": exc.code}) + + +def raise_app_error(status_code: int, detail: str, code: str) -> None: + raise AppError(status_code, detail, code) diff --git a/apps/ssi-control-tower/app/main.py b/apps/ssi-control-tower/app/main.py new file mode 100644 index 0000000..efd7e49 --- /dev/null +++ b/apps/ssi-control-tower/app/main.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.api import approvals, audit, dashboard, exceptions, exports, imports, instructions, ssis, validation +from app.db import get_session, init_db +from app.errors import AppError, app_error_handler +from app.seed import seed_database +from app.web.routes import router as web_router + + +@asynccontextmanager +async def app_lifespan(_app: FastAPI) -> AsyncIterator[None]: + init_db() + with get_session() as session: + seed_database(session) + yield + + +def create_app() -> FastAPI: + app = FastAPI(title="SSI Control Tower", version="0.1.0", lifespan=app_lifespan) + app.add_exception_handler(AppError, app_error_handler) + app.include_router(imports.router) + app.include_router(ssis.router) + app.include_router(validation.router) + app.include_router(exceptions.router) + app.include_router(instructions.router) + app.include_router(approvals.router) + app.include_router(audit.router) + app.include_router(dashboard.router) + app.include_router(exports.router) + app.include_router(web_router) + app.mount("/static", StaticFiles(directory="app/web/static"), name="static") + + return app + + +app = create_app() diff --git a/apps/ssi-control-tower/app/models.py b/apps/ssi-control-tower/app/models.py new file mode 100644 index 0000000..6a823af --- /dev/null +++ b/apps/ssi-control-tower/app/models.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +from sqlalchemy import Boolean, Float, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class User(Base): + __tablename__ = "users" + email: Mapped[str] = mapped_column(String, primary_key=True) + role: Mapped[str] = mapped_column(String, nullable=False) + display_name: Mapped[str] = mapped_column(String, nullable=False) + active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class LegalEntity(Base): + __tablename__ = "legal_entities" + legal_entity_id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + lei: Mapped[str] = mapped_column(String, nullable=False) + bic: Mapped[str] = mapped_column(String, nullable=False) + jurisdiction: Mapped[str] = mapped_column(String, nullable=False) + entity_type: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class Account(Base): + __tablename__ = "accounts" + account_id: Mapped[str] = mapped_column(String, primary_key=True) + legal_entity_id: Mapped[str] = mapped_column(String, ForeignKey("legal_entities.legal_entity_id"), nullable=False) + account_name: Mapped[str] = mapped_column(String, nullable=False) + account_number_masked: Mapped[str] = mapped_column(String, nullable=False) + fund_code: Mapped[str] = mapped_column(String, nullable=False) + base_currency: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False) + owner_team: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class SourceTemplate(Base): + __tablename__ = "source_templates" + source_template_id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + source_system: Mapped[str] = mapped_column(String, nullable=False) + version: Mapped[str] = mapped_column(String, nullable=False) + owner_team: Mapped[str] = mapped_column(String, nullable=False) + trust_level: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class ImportBatch(Base): + __tablename__ = "import_batches" + import_id: Mapped[str] = mapped_column(String, primary_key=True) + file_name: Mapped[str] = mapped_column(String, nullable=False) + file_hash: Mapped[str] = mapped_column(String, nullable=False) + source_system: Mapped[str] = mapped_column(String, nullable=False) + source_template_id: Mapped[str] = mapped_column(String, nullable=False) + uploaded_by: Mapped[str] = mapped_column(String, nullable=False) + records_received: Mapped[int] = mapped_column(nullable=False) + records_imported: Mapped[int] = mapped_column(nullable=False) + records_rejected: Mapped[int] = mapped_column(nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class FieldMapping(Base): + __tablename__ = "field_mappings" + mapping_id: Mapped[str] = mapped_column(String, primary_key=True) + source_template_id: Mapped[str] = mapped_column(String, nullable=False) + source_field: Mapped[str] = mapped_column(String, nullable=False) + canonical_field: Mapped[str] = mapped_column(String, nullable=False) + confidence: Mapped[float] = mapped_column(Float, nullable=False) + mapping_source: Mapped[str] = mapped_column(String, nullable=False) + approved_by: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class SsiRecord(Base): + __tablename__ = "ssi_records" + ssi_id: Mapped[str] = mapped_column(String, primary_key=True) + legal_entity_id: Mapped[str] = mapped_column(String, nullable=False) + account_id: Mapped[str | None] = mapped_column(String, nullable=True) + asset_class: Mapped[str | None] = mapped_column(String, nullable=True) + market: Mapped[str | None] = mapped_column(String, nullable=True) + country_code: Mapped[str | None] = mapped_column(String, nullable=True) + currency_code: Mapped[str | None] = mapped_column(String, nullable=True) + place_of_settlement_bic: Mapped[str | None] = mapped_column(String, nullable=True) + depository: Mapped[str | None] = mapped_column(String, nullable=True) + global_custodian_bic: Mapped[str | None] = mapped_column(String, nullable=True) + local_agent_bic: Mapped[str | None] = mapped_column(String, nullable=True) + intermediary_bic: Mapped[str | None] = mapped_column(String, nullable=True) + securities_account_masked: Mapped[str] = mapped_column(String, nullable=False) + cash_account_masked: Mapped[str] = mapped_column(String, nullable=False) + payment_system: Mapped[str | None] = mapped_column(String, nullable=True) + settlement_method: Mapped[str | None] = mapped_column(String, nullable=True) + effective_from: Mapped[str | None] = mapped_column(String, nullable=True) + effective_to: Mapped[str | None] = mapped_column(String, nullable=True) + status: Mapped[str] = mapped_column(String, nullable=False) + owner_user_email: Mapped[str | None] = mapped_column(String, nullable=True) + owner_team: Mapped[str | None] = mapped_column(String, nullable=True) + last_confirmed_at: Mapped[str | None] = mapped_column(String, nullable=True) + source_system: Mapped[str] = mapped_column(String, nullable=False) + source_template_id: Mapped[str | None] = mapped_column(String, nullable=True) + source_trust_level: Mapped[str] = mapped_column(String, nullable=False) + approval_status: Mapped[str] = mapped_column(String, nullable=False) + risk_rating: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class ValidationResult(Base): + __tablename__ = "validation_results" + validation_result_id: Mapped[str] = mapped_column(String, primary_key=True) + ssi_id: Mapped[str] = mapped_column(String, nullable=False) + rule_id: Mapped[str] = mapped_column(String, nullable=False) + rule_version: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False) + severity: Mapped[str] = mapped_column(String, nullable=False) + failed_field: Mapped[str | None] = mapped_column(String, nullable=True) + message: Mapped[str] = mapped_column(String, nullable=False) + suggested_fix: Mapped[str | None] = mapped_column(String, nullable=True) + context_key: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class ExceptionCase(Base): + __tablename__ = "exception_cases" + exception_id: Mapped[str] = mapped_column(String, primary_key=True) + ssi_id: Mapped[str] = mapped_column(String, nullable=False) + rule_id: Mapped[str] = mapped_column(String, nullable=False) + severity: Mapped[str] = mapped_column(String, nullable=False) + failed_field: Mapped[str | None] = mapped_column(String, nullable=True) + description: Mapped[str] = mapped_column(String, nullable=False) + suggested_fix: Mapped[str | None] = mapped_column(String, nullable=True) + owner_user_email: Mapped[str | None] = mapped_column(String, nullable=True) + status: Mapped[str] = mapped_column(String, nullable=False) + sla_deadline: Mapped[str | None] = mapped_column(String, nullable=True) + resolved_at: Mapped[str | None] = mapped_column(String, nullable=True) + resolution_evidence: Mapped[str | None] = mapped_column(String, nullable=True) + context_key: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class ExceptionComment(Base): + __tablename__ = "exception_comments" + comment_id: Mapped[str] = mapped_column(String, primary_key=True) + exception_id: Mapped[str] = mapped_column(String, nullable=False) + actor_user_email: Mapped[str] = mapped_column(String, nullable=False) + comment: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class PendingChange(Base): + __tablename__ = "pending_changes" + pending_change_id: Mapped[str] = mapped_column(String, primary_key=True) + ssi_id: Mapped[str] = mapped_column(String, nullable=False) + requested_by: Mapped[str] = mapped_column(String, nullable=False) + change_payload_json: Mapped[str] = mapped_column(Text, nullable=False) + change_summary: Mapped[str] = mapped_column(String, nullable=False) + evidence_reference: Mapped[str | None] = mapped_column(String, nullable=True) + status: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + applied_at: Mapped[str | None] = mapped_column(String, nullable=True) + + +class ApprovalRequest(Base): + __tablename__ = "approval_requests" + approval_id: Mapped[str] = mapped_column(String, primary_key=True) + ssi_id: Mapped[str] = mapped_column(String, nullable=False) + pending_change_id: Mapped[str | None] = mapped_column(String, nullable=True) + requested_by: Mapped[str] = mapped_column(String, nullable=False) + change_summary: Mapped[str] = mapped_column(String, nullable=False) + evidence_reference: Mapped[str | None] = mapped_column(String, nullable=True) + risk_level: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False) + decided_by: Mapped[str | None] = mapped_column(String, nullable=True) + decided_at: Mapped[str | None] = mapped_column(String, nullable=True) + decision_reason: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class Waiver(Base): + __tablename__ = "waivers" + waiver_id: Mapped[str] = mapped_column(String, primary_key=True) + exception_id: Mapped[str] = mapped_column(String, nullable=False) + requested_by: Mapped[str] = mapped_column(String, nullable=False) + approved_by: Mapped[str | None] = mapped_column(String, nullable=True) + reason: Mapped[str] = mapped_column(Text, nullable=False) + expiry_date: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class AuditEvent(Base): + __tablename__ = "audit_events" + audit_event_id: Mapped[str] = mapped_column(String, primary_key=True) + entity_type: Mapped[str] = mapped_column(String, nullable=False) + entity_id: Mapped[str] = mapped_column(String, nullable=False) + action: Mapped[str] = mapped_column(String, nullable=False) + actor_user_email: Mapped[str] = mapped_column(String, nullable=False) + actor_role: Mapped[str] = mapped_column(String, nullable=False) + previous_value: Mapped[str | None] = mapped_column(Text, nullable=True) + new_value: Mapped[str | None] = mapped_column(Text, nullable=True) + reason_code: Mapped[str | None] = mapped_column(String, nullable=True) + evidence_reference: Mapped[str | None] = mapped_column(String, nullable=True) + correlation_id: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class ExportBatch(Base): + __tablename__ = "export_batches" + export_id: Mapped[str] = mapped_column(String, primary_key=True) + format: Mapped[str] = mapped_column(String, nullable=False) + requested_by: Mapped[str] = mapped_column(String, nullable=False) + record_count: Mapped[int] = mapped_column(nullable=False) + file_path: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class SourceFile(Base): + __tablename__ = "source_files" + source_file_id: Mapped[str] = mapped_column(String, primary_key=True) + import_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + file_name: Mapped[str] = mapped_column(String, nullable=False) + file_hash: Mapped[str] = mapped_column(String, nullable=False, index=True) + source_system: Mapped[str] = mapped_column(String, nullable=False) + source_schema: Mapped[str] = mapped_column(String, nullable=False) + parser_version: Mapped[str] = mapped_column(String, nullable=False) + uploaded_by: Mapped[str] = mapped_column(String, nullable=False) + records_received: Mapped[int] = mapped_column(nullable=False) + records_accepted: Mapped[int] = mapped_column(nullable=False) + records_rejected: Mapped[int] = mapped_column(nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class SourceRecord(Base): + __tablename__ = "source_records" + source_record_id: Mapped[str] = mapped_column(String, primary_key=True) + source_file_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + import_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + row_number: Mapped[int] = mapped_column(nullable=False) + source_record_key: Mapped[str] = mapped_column(String, nullable=False, index=True) + source_hash: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class SourceReject(Base): + __tablename__ = "source_rejects" + source_reject_id: Mapped[str] = mapped_column(String, primary_key=True) + source_file_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + import_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + row_number: Mapped[int] = mapped_column(nullable=False) + reject_code: Mapped[str] = mapped_column(String, nullable=False) + reject_message: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class Institution(Base): + __tablename__ = "institutions" + institution_id: Mapped[str] = mapped_column(String, primary_key=True) + bic: Mapped[str] = mapped_column(String, nullable=False, index=True) + name: Mapped[str | None] = mapped_column(String, nullable=True) + city: Mapped[str | None] = mapped_column(String, nullable=True) + country_code: Mapped[str | None] = mapped_column(String, nullable=True) + source_system: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class SsiInstruction(Base): + __tablename__ = "ssi_instructions" + ssi_instruction_id: Mapped[str] = mapped_column(String, primary_key=True) + source_record_key: Mapped[str] = mapped_column(String, nullable=False, index=True) + owner_institution_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + owner_bic: Mapped[str] = mapped_column(String, nullable=False, index=True) + owner_name: Mapped[str | None] = mapped_column(String, nullable=True) + owner_city: Mapped[str | None] = mapped_column(String, nullable=True) + owner_country_code: Mapped[str | None] = mapped_column(String, nullable=True, index=True) + currency_code: Mapped[str] = mapped_column(String, nullable=False, index=True) + asset_category: Mapped[str] = mapped_column(String, nullable=False, index=True) + account_holder_institution_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + account_holder_bic: Mapped[str] = mapped_column(String, nullable=False, index=True) + account_holder_name: Mapped[str | None] = mapped_column(String, nullable=True) + account_holder_country_code: Mapped[str | None] = mapped_column(String, nullable=True, index=True) + account_number_masked: Mapped[str] = mapped_column(String, nullable=False) + preferred_flag: Mapped[str | None] = mapped_column(String, nullable=True) + account_holder_type: Mapped[str | None] = mapped_column(String, nullable=True) + group_key_owner: Mapped[str | None] = mapped_column(String, nullable=True) + record_key_bdp_owner: Mapped[str | None] = mapped_column(String, nullable=True) + eid_owner: Mapped[str | None] = mapped_column(String, nullable=True) + record_key_bdp_account_holder: Mapped[str | None] = mapped_column(String, nullable=True) + eid_account_holder: Mapped[str | None] = mapped_column(String, nullable=True) + update_date: Mapped[str | None] = mapped_column(String, nullable=True) + traffic_flag: Mapped[str | None] = mapped_column(String, nullable=True) + traffic_date: Mapped[str | None] = mapped_column(String, nullable=True) + start_date: Mapped[str | None] = mapped_column(String, nullable=True) + stop_date: Mapped[str | None] = mapped_column(String, nullable=True) + status: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class SsiSourceLink(Base): + __tablename__ = "ssi_source_links" + ssi_source_link_id: Mapped[str] = mapped_column(String, primary_key=True) + ssi_instruction_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + source_record_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + source_file_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + import_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class InstructionValidationResult(Base): + __tablename__ = "instruction_validation_results" + instruction_validation_result_id: Mapped[str] = mapped_column(String, primary_key=True) + ssi_instruction_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + rule_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + rule_version: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False) + severity: Mapped[str] = mapped_column(String, nullable=False) + failed_field: Mapped[str | None] = mapped_column(String, nullable=True) + message: Mapped[str] = mapped_column(String, nullable=False) + suggested_fix: Mapped[str | None] = mapped_column(String, nullable=True) + context_key: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class InstructionExceptionCase(Base): + __tablename__ = "instruction_exception_cases" + exception_id: Mapped[str] = mapped_column(String, primary_key=True) + ssi_instruction_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + rule_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + severity: Mapped[str] = mapped_column(String, nullable=False) + failed_field: Mapped[str | None] = mapped_column(String, nullable=True) + description: Mapped[str] = mapped_column(String, nullable=False) + suggested_fix: Mapped[str | None] = mapped_column(String, nullable=True) + owner_user_email: Mapped[str | None] = mapped_column(String, nullable=True) + status: Mapped[str] = mapped_column(String, nullable=False) + sla_deadline: Mapped[str | None] = mapped_column(String, nullable=True) + resolved_at: Mapped[str | None] = mapped_column(String, nullable=True) + resolution_evidence: Mapped[str | None] = mapped_column(String, nullable=True) + context_key: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) diff --git a/apps/ssi-control-tower/app/rules/__init__.py b/apps/ssi-control-tower/app/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ssi-control-tower/app/rules/engine.py b/apps/ssi-control-tower/app/rules/engine.py new file mode 100644 index 0000000..eaf5806 --- /dev/null +++ b/apps/ssi-control-tower/app/rules/engine.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models import SsiRecord +from app.rules.evaluators import EVALUATORS +from app.rules.loader import RuleDefinition, load_rules + + +@dataclass +class RuleOutcome: + rule: RuleDefinition + status: str + failed_field: str | None + message: str + suggested_fix: str | None + context_key: str | None = None + + +def evaluate_record(record: SsiRecord, session: Session) -> list[RuleOutcome]: + outcomes: list[RuleOutcome] = [] + for rule in load_rules(): + if not rule.enabled: + outcomes.append(RuleOutcome(rule, "skipped", None, rule.message, rule.suggested_fix)) + continue + evaluator = EVALUATORS[rule.evaluator] + failures = evaluator(record, session) + if not failures: + outcomes.append(RuleOutcome(rule, "pass", None, rule.message, rule.suggested_fix)) + for failure in failures: + outcomes.append( + RuleOutcome( + rule=rule, + status="fail", + failed_field=failure.get("failed_field"), + message=rule.message, + suggested_fix=rule.suggested_fix, + context_key=failure.get("context_key"), + ) + ) + return outcomes diff --git a/apps/ssi-control-tower/app/rules/evaluators.py b/apps/ssi-control-tower/app/rules/evaluators.py new file mode 100644 index 0000000..1e9e849 --- /dev/null +++ b/apps/ssi-control-tower/app/rules/evaluators.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import re +from datetime import date, datetime +from typing import Any + +from sqlalchemy.orm import Session + +from app.models import ExceptionCase, SsiRecord + +ACTIVE_STATUSES = {"imported", "validation_failed", "validation_passed", "pending_owner", "pending_approval", "approved", "published"} +T1_MARKETS = {"FR", "DE", "GB", "IT", "ES"} +BIC_RE = re.compile(r"^[A-Z0-9]{8}([A-Z0-9]{3})?$") +BIC_FIELDS = ["place_of_settlement_bic", "global_custodian_bic", "local_agent_bic", "intermediary_bic"] +COUNTRY_CODES = {"FR", "DE", "GB", "IT", "ES", "US", "CH", "LU", "NL", "BE", "JP", "SG", "AU", "CA", "HK", "SE", "NO", "DK"} +CURRENCY_CODES = {"EUR", "GBP", "USD", "CHF", "JPY", "SGD", "AUD", "CAD", "HKD", "SEK", "NOK", "DKK"} + + +def is_active(record: SsiRecord) -> bool: + return record.status in ACTIVE_STATUSES + + +def _parse(value: str | None) -> date | None: + if not value: + return None + try: + return datetime.strptime(value[:10], "%Y-%m-%d").date() + except ValueError: + return None + + +def _days_old(value: str | None) -> int | None: + parsed = _parse(value) + if not parsed: + return None + return (date(2026, 5, 6) - parsed).days + + +def _overlap(a_start: date | None, a_end: date | None, b_start: date | None, b_end: date | None) -> bool: + lo_a = a_start or date.min + hi_a = a_end or date.max + lo_b = b_start or date.min + hi_b = b_end or date.max + return lo_a <= hi_b and lo_b <= hi_a + + +def duplicate_context(record: SsiRecord) -> str: + return "|".join(str(x or "") for x in [record.legal_entity_id, record.account_id, record.asset_class, record.market, record.currency_code, record.settlement_method]) + + +def required_market_fields(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + if not is_active(record): + return [] + missing = [field for field in ("market", "country_code", "asset_class", "settlement_method") if not getattr(record, field)] + return [{"failed_field": ",".join(missing)}] if missing else [] + + +def required_effective_from(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + return [{"failed_field": "effective_from"}] if is_active(record) and not record.effective_from else [] + + +def owner_required(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + return [{"failed_field": "owner_user_email"}] if is_active(record) and not record.owner_user_email else [] + + +def bic_format(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + if not is_active(record): + return [] + failures = [] + for field in BIC_FIELDS: + value = getattr(record, field) + if value and not BIC_RE.match(value): + failures.append({"failed_field": field}) + return failures + + +def country_code(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + if not is_active(record) or not record.country_code: + return [] + return [{"failed_field": "country_code"}] if not re.match(r"^[A-Z]{2}$", record.country_code) or record.country_code not in COUNTRY_CODES else [] + + +def currency_code(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + if not is_active(record) or not record.currency_code: + return [] + return [{"failed_field": "currency_code"}] if not re.match(r"^[A-Z]{3}$", record.currency_code) or record.currency_code not in CURRENCY_CODES else [] + + +def invalid_date_range(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + if not is_active(record) or not record.effective_from or not record.effective_to: + return [] + start = _parse(record.effective_from) + end = _parse(record.effective_to) + return [{"failed_field": "effective_to"}] if start and end and end < start else [] + + +def duplicate_active(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + if not is_active(record): + return [] + context = duplicate_context(record) + candidates = session.query(SsiRecord).filter(SsiRecord.status.in_(ACTIVE_STATUSES)).all() + mine_start = _parse(record.effective_from) + mine_end = _parse(record.effective_to) + matches = [] + for other in candidates: + if other.ssi_id == record.ssi_id: + continue + if duplicate_context(other) != context: + continue + if _overlap(mine_start, mine_end, _parse(other.effective_from), _parse(other.effective_to)): + matches.append(other.ssi_id) + return [{"failed_field": None, "context_key": context}] if matches else [] + + +def stale_12m(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + age = _days_old(record.last_confirmed_at) + return [{"failed_field": "last_confirmed_at"}] if is_active(record) and age is not None and age > 365 else [] + + +def stale_critical_market(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + age = _days_old(record.last_confirmed_at) + return [{"failed_field": "last_confirmed_at"}] if is_active(record) and record.market in T1_MARKETS and age is not None and age > 365 else [] + + +def t1_recent_confirmation(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + age = _days_old(record.last_confirmed_at) + return [{"failed_field": "last_confirmed_at"}] if is_active(record) and record.market in T1_MARKETS and age is not None and age > 180 else [] + + +def t1_no_critical_exceptions(record: SsiRecord, session: Session) -> list[dict[str, Any]]: + if not is_active(record) or record.market not in T1_MARKETS: + return [] + unresolved = session.query(ExceptionCase).filter( + ExceptionCase.ssi_id == record.ssi_id, + ExceptionCase.severity == "critical", + ExceptionCase.status.in_(["open", "assigned", "in_remediation", "pending_approval"]), + ExceptionCase.rule_id != "SSI.T1.NO_CRITICAL_EXCEPTIONS", + ).count() + return [{"failed_field": None}] if unresolved else [] + + +EVALUATORS = { + "required_market_fields": required_market_fields, + "required_effective_from": required_effective_from, + "owner_required": owner_required, + "bic_format": bic_format, + "country_code": country_code, + "currency_code": currency_code, + "invalid_date_range": invalid_date_range, + "duplicate_active": duplicate_active, + "stale_12m": stale_12m, + "stale_critical_market": stale_critical_market, + "t1_recent_confirmation": t1_recent_confirmation, + "t1_no_critical_exceptions": t1_no_critical_exceptions, +} diff --git a/apps/ssi-control-tower/app/rules/loader.py b/apps/ssi-control-tower/app/rules/loader.py new file mode 100644 index 0000000..382113a --- /dev/null +++ b/apps/ssi-control-tower/app/rules/loader.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import yaml + +from app.config import RULES_DIR + + +@dataclass(frozen=True) +class RuleDefinition: + rule_id: str + name: str + severity: str + version: str + enabled: bool + applies_to: str + evaluator: str + message: str + suggested_fix: str | None = None + + +def load_rules() -> list[RuleDefinition]: + rules: list[RuleDefinition] = [] + for path in sorted(RULES_DIR.glob("*.yaml")): + data = yaml.safe_load(path.read_text()) or [] + items = data if isinstance(data, list) else data.get("rules", []) + for item in items: + rules.append(RuleDefinition(**item)) + return rules diff --git a/apps/ssi-control-tower/app/schemas.py b/apps/ssi-control-tower/app/schemas.py new file mode 100644 index 0000000..fafb32a --- /dev/null +++ b/apps/ssi-control-tower/app/schemas.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel + + +class AssignExceptionRequest(BaseModel): + owner_user_email: str + + +class ResolveExceptionRequest(BaseModel): + resolution_evidence: str + + +class WaiveExceptionRequest(BaseModel): + reason: str + expiry_date: str + + +class SubmitApprovalRequest(BaseModel): + change_payload: dict[str, Any] + change_summary: str + evidence_reference: str | None = None + + +class RejectApprovalRequest(BaseModel): + decision_reason: str = "Rejected" + + +class ExportRequest(BaseModel): + format: Literal["csv", "json"] = "csv" + + +class RulePatchRequest(BaseModel): + enabled: bool | None = None diff --git a/apps/ssi-control-tower/app/seed.py b/apps/ssi-control-tower/app/seed.py new file mode 100644 index 0000000..4b713a2 --- /dev/null +++ b/apps/ssi-control-tower/app/seed.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json + +import yaml +from sqlalchemy.orm import Session + +from app.config import DATA_DIR, utc_now +from app.models import ApprovalRequest, FieldMapping, PendingChange, SourceTemplate, User +from app.services.audit import stable_id, write_audit +from app.services.ingestion import DEFAULT_TEMPLATE_ID, ingest_csv_path + + +def seed_database(session: Session) -> None: + """Seed users, field mappings, templates, synthetic SSIs, and initial controls once.""" + if session.query(User).count() > 0: + return + now = utc_now() + users = yaml.safe_load((DATA_DIR / "seed_users.yaml").read_text()) + for item in users: + session.add(User(email=item["email"], role=item["role"], display_name=item["display_name"], active=True, created_at=now)) + template = SourceTemplate( + source_template_id=DEFAULT_TEMPLATE_ID, + name="Synthetic SSI CSV Template", + source_system="CSV_DEMO", + version="1.0", + owner_team="Reference Data", + trust_level="medium", + created_at=now, + updated_at=now, + ) + session.add(template) + aliases = yaml.safe_load((DATA_DIR / "field_aliases.yaml").read_text()) + for canonical, source_fields in aliases.items(): + for source_field in source_fields: + session.add(FieldMapping( + mapping_id=stable_id("mapping", DEFAULT_TEMPLATE_ID, source_field, canonical), + source_template_id=DEFAULT_TEMPLATE_ID, + source_field=source_field, + canonical_field=canonical, + confidence=0.98, + mapping_source="seed_alias", + approved_by="admin@example.com", + created_at=now, + )) + session.commit() + ingest_csv_path( + session, + DATA_DIR / "seed_ssi.csv", + uploaded_by="analyst@example.com", + run_validation=True, + hybrid_instructions=False, + ) + # Seed one pending approval requested by the owner for governance queue coverage. + from app.models import SsiRecord + pending_record = session.query(SsiRecord).filter(SsiRecord.approval_status == "pending").first() + if pending_record: + pc_id = stable_id("pending-change", pending_record.ssi_id, "seed") + pending = PendingChange( + pending_change_id=pc_id, + ssi_id=pending_record.ssi_id, + requested_by="ops-owner@example.com", + change_payload_json=json.dumps({"settlement_method": "DVP"}, sort_keys=True), + change_summary="Seeded pending critical settlement method confirmation", + evidence_reference="EV-SEED-001", + status="pending", + created_at=now, + applied_at=None, + ) + approval = ApprovalRequest( + approval_id=stable_id("approval", pc_id), + ssi_id=pending_record.ssi_id, + pending_change_id=pc_id, + requested_by="ops-owner@example.com", + change_summary=pending.change_summary, + evidence_reference=pending.evidence_reference, + risk_level="high", + status="pending", + decided_by=None, + decided_at=None, + decision_reason=None, + created_at=now, + ) + session.add(pending) + session.add(approval) + write_audit(session, entity_type="approval", entity_id=approval.approval_id, action="approval.requested", actor_user_email="ops-owner@example.com", new_value={"seed": True}, evidence_reference="EV-SEED-001") + session.commit() diff --git a/apps/ssi-control-tower/app/services/approvals.py b/apps/ssi-control-tower/app/services/approvals.py new file mode 100644 index 0000000..4991d84 --- /dev/null +++ b/apps/ssi-control-tower/app/services/approvals.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.errors import raise_app_error +from app.models import ApprovalRequest, PendingChange, SsiRecord +from app.services.audit import stable_id, write_audit +from app.services.validation import validate_all, validate_record + +CRITICAL_FIELDS = { + "securities_account_masked", "cash_account_masked", "place_of_settlement_bic", "depository", + "global_custodian_bic", "local_agent_bic", "intermediary_bic", "settlement_method", +} +MASKED_ACCOUNT_FIELDS = {"securities_account_masked", "cash_account_masked"} +NON_CRITICAL_PATCH_FIELDS = { + "owner_user_email", + "owner_team", + "last_confirmed_at", + "source_trust_level", + "risk_rating", +} + + +def _reject_unsupported_fields(fields: set[str], allowed: set[str]) -> None: + unsupported = sorted(fields - allowed) + if unsupported: + raise_app_error( + 400, + f"Unsupported SSI update field(s): {', '.join(unsupported)}", + "UNSUPPORTED_SSI_UPDATE_FIELD", + ) + + +def _validate_masked_account_payload(payload: dict) -> None: + for field in sorted(MASKED_ACCOUNT_FIELDS & set(payload)): + value = str(payload.get(field) or "").strip() + if "****" not in value: + raise_app_error( + 400, + f"{field} must be masked before submission", + "UNMASKED_ACCOUNT_FIELD", + ) + + +def _validate_critical_change_payload(payload: dict) -> None: + touched = set(payload) + if not touched & CRITICAL_FIELDS: + raise_app_error(400, "No critical fields submitted", "NO_CRITICAL_FIELDS") + _reject_unsupported_fields(touched, CRITICAL_FIELDS) + _validate_masked_account_payload(payload) + + +def patch_ssi(session: Session, ssi_id: str, payload: dict, actor: str) -> SsiRecord: + """Apply non-critical edits directly and force approval workflow for critical fields.""" + record = session.get(SsiRecord, ssi_id) + if not record: + raise_app_error(404, "SSI not found", "SSI_NOT_FOUND") + if set(payload) & CRITICAL_FIELDS: + raise_app_error(409, "Critical settlement fields require four-eyes approval", "APPROVAL_REQUIRED") + _reject_unsupported_fields(set(payload), NON_CRITICAL_PATCH_FIELDS) + before = {key: getattr(record, key) for key in payload} + for key, value in payload.items(): + setattr(record, key, value) + record.updated_at = utc_now() + write_audit(session, entity_type="ssi", entity_id=ssi_id, action="ssi.updated", actor_user_email=actor, previous_value=before, new_value=payload) + validate_record(session, record, actor=actor) + session.commit() + return record + + +def retire_ssi(session: Session, ssi_id: str, actor: str) -> SsiRecord: + """Retire an SSI and rerun validation for duplicate contexts across active records.""" + record = session.get(SsiRecord, ssi_id) + if not record: + raise_app_error(404, "SSI not found", "SSI_NOT_FOUND") + before = {"status": record.status} + record.status = "retired" + record.updated_at = utc_now() + write_audit(session, entity_type="ssi", entity_id=ssi_id, action="ssi.retired", actor_user_email=actor, previous_value=before, new_value={"status": "retired"}) + validate_all(session, actor=actor) + record.status = "retired" + session.commit() + return record + + +def submit_for_approval(session: Session, ssi_id: str, payload: dict, summary: str, evidence: str | None, actor: str) -> ApprovalRequest: + """Persist critical changes in pending_changes until a different approver accepts them.""" + record = session.get(SsiRecord, ssi_id) + if not record: + raise_app_error(404, "SSI not found", "SSI_NOT_FOUND") + touched = set(payload) + _validate_critical_change_payload(payload) + now = utc_now() + pc_id = stable_id("pending-change", ssi_id, actor, summary, now) + pending = PendingChange( + pending_change_id=pc_id, + ssi_id=ssi_id, + requested_by=actor, + change_payload_json=json.dumps(payload, sort_keys=True), + change_summary=summary, + evidence_reference=evidence, + status="pending", + created_at=now, + applied_at=None, + ) + approval = ApprovalRequest( + approval_id=stable_id("approval", pc_id), + ssi_id=ssi_id, + pending_change_id=pc_id, + requested_by=actor, + change_summary=summary, + evidence_reference=evidence, + risk_level="high" if touched != {"settlement_method"} else "critical", + status="pending", + decided_by=None, + decided_at=None, + decision_reason=None, + created_at=now, + ) + before = {"status": record.status, "approval_status": record.approval_status} + record.status = "pending_approval" + record.approval_status = "pending" + record.updated_at = now + session.add(pending) + session.add(approval) + write_audit(session, entity_type="ssi", entity_id=ssi_id, action="ssi.change_submitted", actor_user_email=actor, previous_value=before, new_value=payload, evidence_reference=evidence) + session.commit() + return approval + + +def approve_request(session: Session, approval_id: str, actor: str) -> ApprovalRequest: + """Apply a pending settlement-critical change only when approver differs from maker.""" + approval = session.get(ApprovalRequest, approval_id) + if not approval: + raise_app_error(404, "Approval not found", "APPROVAL_NOT_FOUND") + if approval.requested_by == actor: + raise_app_error(403, "Self-approval is not allowed", "SELF_APPROVAL_REJECTED") + if approval.status != "pending": + raise_app_error(409, "Approval is no longer pending", "APPROVAL_NOT_PENDING") + pending = session.get(PendingChange, approval.pending_change_id) if approval.pending_change_id else None + record = session.get(SsiRecord, approval.ssi_id) + if not record: + raise_app_error(404, "SSI not found", "SSI_NOT_FOUND") + payload = json.loads(pending.change_payload_json) if pending else {} + _validate_critical_change_payload(payload) + before = {key: getattr(record, key) for key in payload} + for key, value in payload.items(): + setattr(record, key, value) + now = utc_now() + approval.status = "approved" + approval.decided_by = actor + approval.decided_at = now + approval.decision_reason = "Approved with evidence" + if pending: + pending.status = "applied" + pending.applied_at = now + record.status = "approved" + record.approval_status = "approved" + record.updated_at = now + write_audit(session, entity_type="approval", entity_id=approval_id, action="approval.approved", actor_user_email=actor, previous_value=before, new_value=payload, evidence_reference=approval.evidence_reference) + validate_record(session, record, actor=actor) + record.status = "approved" + record.approval_status = "approved" + session.commit() + return approval + + +def reject_request(session: Session, approval_id: str, actor: str, reason: str = "Rejected") -> ApprovalRequest: + approval = session.get(ApprovalRequest, approval_id) + if not approval: + raise_app_error(404, "Approval not found", "APPROVAL_NOT_FOUND") + now = utc_now() + approval.status = "rejected" + approval.decided_by = actor + approval.decided_at = now + approval.decision_reason = reason + if approval.pending_change_id: + pending = session.get(PendingChange, approval.pending_change_id) + if pending: + pending.status = "rejected" + record = session.get(SsiRecord, approval.ssi_id) + if record: + record.approval_status = "rejected" + record.status = "validation_failed" + write_audit(session, entity_type="approval", entity_id=approval_id, action="approval.rejected", actor_user_email=actor, new_value={"reason": reason}) + session.commit() + return approval diff --git a/apps/ssi-control-tower/app/services/audit.py b/apps/ssi-control-tower/app/services/audit.py new file mode 100644 index 0000000..b4d3532 --- /dev/null +++ b/apps/ssi-control-tower/app/services/audit.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +import uuid +from itertools import count +from typing import Any + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.models import AuditEvent, User + +NAMESPACE = uuid.UUID("2e5cf51a-4cc2-4a45-b358-8b1fd02a5dd2") +_AUDIT_COUNTER = count() + + +def stable_id(*parts: object) -> str: + return str(uuid.uuid5(NAMESPACE, ":".join(str(p) for p in parts))) + + +def actor_role(session: Session, email: str) -> str: + user = session.get(User, email) + return user.role if user else "system" + + +def write_audit( + session: Session, + *, + entity_type: str, + entity_id: str, + action: str, + actor_user_email: str, + previous_value: Any | None = None, + new_value: Any | None = None, + reason_code: str | None = None, + evidence_reference: str | None = None, + correlation_id: str | None = None, +) -> AuditEvent: + """Append an immutable audit event; callers never update existing audit rows.""" + now = utc_now() + seq = next(_AUDIT_COUNTER) + corr = correlation_id or stable_id("corr", entity_type, entity_id, action, now, seq) + event = AuditEvent( + audit_event_id=stable_id("audit", entity_type, entity_id, action, now, seq), + entity_type=entity_type, + entity_id=entity_id, + action=action, + actor_user_email=actor_user_email, + actor_role=actor_role(session, actor_user_email), + previous_value=json.dumps(previous_value, sort_keys=True) if previous_value is not None else None, + new_value=json.dumps(new_value, sort_keys=True) if new_value is not None else None, + reason_code=reason_code, + evidence_reference=evidence_reference, + correlation_id=corr, + created_at=now, + ) + session.add(event) + return event diff --git a/apps/ssi-control-tower/app/services/exceptions.py b/apps/ssi-control-tower/app/services/exceptions.py new file mode 100644 index 0000000..4c137b8 --- /dev/null +++ b/apps/ssi-control-tower/app/services/exceptions.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from itertools import count + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.models import ExceptionCase, Waiver +from app.services.audit import stable_id, write_audit + +SLA_HOURS = {"critical": 4, "high": 24, "medium": 72, "low": 24 * 7} +UNRESOLVED = ["open", "assigned", "in_remediation", "pending_approval"] +_EXCEPTION_COUNTER = count() + + +def sla_deadline(severity: str) -> str | None: + hours = SLA_HOURS.get(severity) + if hours is None: + return None + return (datetime.now(timezone.utc).replace(microsecond=0) + timedelta(hours=hours)).isoformat().replace("+00:00", "Z") + + +def upsert_exception( + session: Session, + *, + ssi_id: str, + rule_id: str, + severity: str, + failed_field: str | None, + description: str, + suggested_fix: str | None, + context_key: str | None, +) -> ExceptionCase: + """Create one unresolved exception per SSI/rule/field/context combination.""" + for pending in session.new: + if not isinstance(pending, ExceptionCase): + continue + if ( + pending.ssi_id == ssi_id + and pending.rule_id == rule_id + and pending.failed_field == failed_field + and pending.context_key == context_key + and pending.status in UNRESOLVED + ): + return pending + existing = session.query(ExceptionCase).filter( + ExceptionCase.ssi_id == ssi_id, + ExceptionCase.rule_id == rule_id, + ExceptionCase.failed_field.is_(failed_field) if failed_field is None else ExceptionCase.failed_field == failed_field, + ExceptionCase.context_key.is_(context_key) if context_key is None else ExceptionCase.context_key == context_key, + ExceptionCase.status.in_(UNRESOLVED), + ).first() + if existing: + return existing + now = utc_now() + seq = next(_EXCEPTION_COUNTER) + case = ExceptionCase( + exception_id=stable_id("exception", ssi_id, rule_id, failed_field or "", context_key or "", now, seq), + ssi_id=ssi_id, + rule_id=rule_id, + severity=severity, + failed_field=failed_field, + description=description, + suggested_fix=suggested_fix, + owner_user_email=None, + status="open", + sla_deadline=sla_deadline(severity), + resolved_at=None, + resolution_evidence=None, + context_key=context_key, + created_at=now, + updated_at=now, + ) + session.add(case) + return case + + +def close_resolved_exceptions(session: Session, ssi_id: str, failed_keys: set[tuple[str, str | None, str | None]]) -> None: + now = utc_now() + open_cases = session.query(ExceptionCase).filter(ExceptionCase.ssi_id == ssi_id, ExceptionCase.status.in_(UNRESOLVED)).all() + for case in open_cases: + key = (case.rule_id, case.failed_field, case.context_key) + if key not in failed_keys: + case.status = "closed" + case.resolved_at = now + case.updated_at = now + + +def assign_exception(session: Session, exception_id: str, owner: str, actor: str) -> ExceptionCase: + case = session.get(ExceptionCase, exception_id) + if case is None: + from app.errors import raise_app_error + raise_app_error(404, "Exception not found", "EXCEPTION_NOT_FOUND") + before = {"status": case.status, "owner_user_email": case.owner_user_email} + case.owner_user_email = owner + case.status = "assigned" + case.updated_at = utc_now() + write_audit(session, entity_type="exception", entity_id=exception_id, action="exception.assigned", actor_user_email=actor, previous_value=before, new_value={"status": case.status, "owner_user_email": owner}) + session.commit() + return case + + +def resolve_exception(session: Session, exception_id: str, evidence: str, actor: str) -> ExceptionCase: + case = session.get(ExceptionCase, exception_id) + if case is None: + from app.errors import raise_app_error + raise_app_error(404, "Exception not found", "EXCEPTION_NOT_FOUND") + case.status = "closed" + case.resolution_evidence = evidence + case.resolved_at = utc_now() + case.updated_at = case.resolved_at + write_audit(session, entity_type="exception", entity_id=exception_id, action="exception.resolved", actor_user_email=actor, new_value={"evidence": evidence}, evidence_reference=evidence) + session.commit() + return case + + +def waive_exception(session: Session, exception_id: str, reason: str, expiry_date: str, actor: str) -> ExceptionCase: + case = session.get(ExceptionCase, exception_id) + if case is None: + from app.errors import raise_app_error + raise_app_error(404, "Exception not found", "EXCEPTION_NOT_FOUND") + now = utc_now() + waiver = Waiver( + waiver_id=stable_id("waiver", exception_id, actor, now), + exception_id=exception_id, + requested_by=actor, + approved_by=actor, + reason=reason, + expiry_date=expiry_date, + status="approved", + created_at=now, + updated_at=now, + ) + session.add(waiver) + case.status = "waived" + case.updated_at = now + write_audit(session, entity_type="exception", entity_id=exception_id, action="exception.waived", actor_user_email=actor, new_value={"reason": reason, "expiry_date": expiry_date}) + session.commit() + return case diff --git a/apps/ssi-control-tower/app/services/export.py b/apps/ssi-control-tower/app/services/export.py new file mode 100644 index 0000000..20757ff --- /dev/null +++ b/apps/ssi-control-tower/app/services/export.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import csv +import json +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from app.config import export_dir, utc_now +from app.models import ExportBatch, SsiRecord +from app.services.audit import stable_id, write_audit + +EXPORT_FIELDS = ["ssi_id", "asset_class", "market", "country_code", "currency_code", "place_of_settlement_bic", "depository", "global_custodian_bic", "local_agent_bic", "securities_account_masked", "cash_account_masked", "settlement_method", "status", "owner_user_email", "last_confirmed_at"] + + +def export_rows(records: list[SsiRecord]) -> list[dict[str, object]]: + return [{field: getattr(record, field) for field in EXPORT_FIELDS} for record in records] + + +def create_export(session: Session, fmt: str, actor: str) -> ExportBatch: + """Export only approved or published synthetic SSI records.""" + if fmt not in {"csv", "json"}: + from app.errors import raise_app_error + raise_app_error(400, "Unsupported export format", "UNSUPPORTED_EXPORT_FORMAT") + records = session.query(SsiRecord).filter(SsiRecord.status.in_(["approved", "published"])).all() + out_dir = export_dir() + out_dir.mkdir(parents=True, exist_ok=True) + export_id = stable_id("export", fmt, actor, utc_now(), uuid4().hex) + path = out_dir / f"{export_id}.{fmt}" + rows = export_rows(records) + if fmt == "csv": + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=EXPORT_FIELDS) + writer.writeheader() + writer.writerows(rows) + else: + path.write_text(json.dumps(rows, indent=2), encoding="utf-8") + batch = ExportBatch(export_id=export_id, format=fmt, requested_by=actor, record_count=len(rows), file_path=str(path), created_at=utc_now()) + session.add(batch) + write_audit(session, entity_type="export", entity_id=export_id, action="export.created", actor_user_email=actor, new_value={"record_count": len(rows), "format": fmt}) + session.commit() + return batch diff --git a/apps/ssi-control-tower/app/services/ingestion.py b/apps/ssi-control-tower/app/services/ingestion.py new file mode 100644 index 0000000..7328c52 --- /dev/null +++ b/apps/ssi-control-tower/app/services/ingestion.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import csv +import hashlib +from pathlib import Path + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.models import Account, ImportBatch, LegalEntity, SsiRecord +from app.services.audit import stable_id, write_audit +from app.services.instruction_ingestion import ingest_instruction_adapter_result +from app.services.mapping import map_source_row +from app.services.normalisation import normalize_row +from app.services.source_adapters import ( + CSV_SOURCE_SYSTEM, + adapt_csv_rows_to_instruction_candidates, + adapt_ssiplus_v3_bytes_to_instruction_candidates, +) +from app.services.ssiplus_v3 import SOURCE_SYSTEM as SSIPLUS_SOURCE_SYSTEM +from app.services.validation import validate_all + +DEFAULT_TEMPLATE_ID = stable_id("source-template", "Synthetic CSV", "CSV_DEMO", "1.0") +SSIPLUS_TEMPLATE_ID = "SSIPLUS_V3" + + +def _file_hash(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _is_masked_account(value: object) -> bool: + return isinstance(value, str) and "****" in value.strip() + + +def ingest_csv_bytes( + session: Session, + data: bytes, + file_name: str, + uploaded_by: str, + run_validation: bool = True, + hybrid_instructions: bool = True, +) -> ImportBatch: + """Ingest CSV rows into legacy V1 records and canonical instruction lineage.""" + text = data.decode("utf-8-sig") + rows = list(csv.DictReader(text.splitlines())) + now = utc_now() + digest = _file_hash(data) + import_id = stable_id("import", file_name, digest) + batch = ImportBatch( + import_id=import_id, + file_name=file_name, + file_hash=digest, + source_system=CSV_SOURCE_SYSTEM, + source_template_id=DEFAULT_TEMPLATE_ID, + uploaded_by=uploaded_by, + records_received=len(rows), + records_imported=0, + records_rejected=0, + status="pending", + created_at=now, + ) + session.merge(batch) + seen_legal_entities: set[str] = set() + seen_accounts: set[str] = set() + for index, row in enumerate(rows, start=1): + mapped = normalize_row(map_source_row(row)) + if not mapped.get("legal_entity_name") or not mapped.get("asset_class") or not mapped.get("market"): + batch.records_rejected += 1 + continue + account_number_masked = mapped.get("account_number_masked") + securities_account_masked = mapped.get("securities_account_masked") + cash_account_masked = mapped.get("cash_account_masked") + if not all( + _is_masked_account(value) + for value in (account_number_masked, securities_account_masked, cash_account_masked) + ): + batch.records_rejected += 1 + continue + le_id = stable_id("legal-entity", mapped.get("lei") or mapped["legal_entity_name"]) + le = LegalEntity( + legal_entity_id=le_id, + name=mapped["legal_entity_name"], + lei=mapped.get("lei") or f"529900SYNTH{index:011d}", + bic=mapped.get("legal_entity_bic") or "NSTAGB2L", + jurisdiction=mapped.get("jurisdiction") or mapped.get("country_code") or "GB", + entity_type="fund", + status="active", + created_at=now, + updated_at=now, + ) + if le_id not in seen_legal_entities: + session.merge(le) + seen_legal_entities.add(le_id) + account_id = stable_id("account", le_id, mapped.get("account_name") or mapped.get("fund_code") or index) + acct = Account( + account_id=account_id, + legal_entity_id=le_id, + account_name=mapped.get("account_name") or f"{mapped['legal_entity_name']} Main", + account_number_masked=account_number_masked, + fund_code=mapped.get("fund_code") or f"FUND{index:03d}", + base_currency=mapped.get("base_currency") or mapped.get("currency_code") or "EUR", + status="active", + owner_team=mapped.get("owner_team") or "Reference Data", + created_at=now, + updated_at=now, + ) + if account_id not in seen_accounts: + session.merge(acct) + seen_accounts.add(account_id) + source_key = mapped.get("ssi_reference") or ( + f"{file_name}:{index}:{mapped['legal_entity_name']}:{mapped.get('market')}:{mapped.get('currency_code')}" + ) + ssi = SsiRecord( + ssi_id=stable_id("ssi", source_key), + legal_entity_id=le_id, + account_id=account_id, + asset_class=mapped.get("asset_class"), + market=mapped.get("market"), + country_code=mapped.get("country_code"), + currency_code=mapped.get("currency_code"), + place_of_settlement_bic=mapped.get("place_of_settlement_bic"), + depository=mapped.get("depository"), + global_custodian_bic=mapped.get("global_custodian_bic"), + local_agent_bic=mapped.get("local_agent_bic"), + intermediary_bic=mapped.get("intermediary_bic"), + securities_account_masked=securities_account_masked, + cash_account_masked=cash_account_masked, + payment_system=mapped.get("payment_system"), + settlement_method=mapped.get("settlement_method"), + effective_from=mapped.get("effective_from"), + effective_to=mapped.get("effective_to"), + status=mapped.get("status") or "imported", + owner_user_email=mapped.get("owner_user_email") or None, + owner_team=mapped.get("owner_team") or None, + last_confirmed_at=mapped.get("last_confirmed_at") or None, + source_system=mapped.get("source_system") or CSV_SOURCE_SYSTEM, + source_template_id=DEFAULT_TEMPLATE_ID, + source_trust_level=mapped.get("source_trust_level") or "medium", + approval_status=mapped.get("approval_status") or "not_required", + risk_rating=mapped.get("risk_rating") or "medium", + created_at=now, + updated_at=now, + ) + session.merge(ssi) + batch.records_imported += 1 + + if hybrid_instructions: + adapter_result = adapt_csv_rows_to_instruction_candidates(rows, file_name=file_name) + ingest_instruction_adapter_result( + session, + adapter_result=adapter_result, + file_name=file_name, + file_hash=digest, + import_id=import_id, + uploaded_by=uploaded_by, + now=now, + ) + batch.status = "completed" + write_audit( + session, + entity_type="import", + entity_id=batch.import_id, + action="import.completed", + actor_user_email=uploaded_by, + new_value={"records_imported": batch.records_imported}, + ) + session.commit() + if run_validation: + validate_all(session, actor=uploaded_by) + return batch + + +def ingest_csv_path( + session: Session, + path: Path, + uploaded_by: str = "system", + run_validation: bool = True, + hybrid_instructions: bool = True, +) -> ImportBatch: + return ingest_csv_bytes( + session, + path.read_bytes(), + path.name, + uploaded_by, + run_validation=run_validation, + hybrid_instructions=hybrid_instructions, + ) + + +def ingest_ssiplus_v3_bytes( + session: Session, + data: bytes, + file_name: str, + uploaded_by: str, + run_validation: bool = False, +) -> ImportBatch: + """Ingest tab-delimited SSI Plus V3 bytes through shared canonical lineage.""" + now = utc_now() + digest = _file_hash(data) + import_id = stable_id("import", file_name, digest) + adapter_result = adapt_ssiplus_v3_bytes_to_instruction_candidates(data) + batch = ImportBatch( + import_id=import_id, + file_name=file_name, + file_hash=digest, + source_system=SSIPLUS_SOURCE_SYSTEM, + source_template_id=SSIPLUS_TEMPLATE_ID, + uploaded_by=uploaded_by, + records_received=adapter_result.records_received, + records_imported=len(adapter_result.candidates), + records_rejected=len(adapter_result.rejects), + status="completed", + created_at=now, + ) + session.merge(batch) + ingest_instruction_adapter_result( + session, + adapter_result=adapter_result, + file_name=file_name, + file_hash=digest, + import_id=import_id, + uploaded_by=uploaded_by, + now=now, + ) + if run_validation: + validate_all(session, actor=uploaded_by) + return batch diff --git a/apps/ssi-control-tower/app/services/instruction_exceptions.py b/apps/ssi-control-tower/app/services/instruction_exceptions.py new file mode 100644 index 0000000..db3f151 --- /dev/null +++ b/apps/ssi-control-tower/app/services/instruction_exceptions.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.models import InstructionExceptionCase +from app.services.audit import stable_id, write_audit +from app.services.exceptions import UNRESOLVED, sla_deadline + + +def upsert_instruction_exception( + session: Session, + *, + ssi_instruction_id: str, + rule_id: str, + severity: str, + failed_field: str | None, + description: str, + suggested_fix: str | None, + context_key: str | None, +) -> InstructionExceptionCase: + for pending in session.new: + if ( + isinstance(pending, InstructionExceptionCase) + and pending.ssi_instruction_id == ssi_instruction_id + and pending.rule_id == rule_id + and pending.failed_field == failed_field + and pending.context_key == context_key + and pending.status in UNRESOLVED + ): + return pending + existing = ( + session.query(InstructionExceptionCase) + .filter( + InstructionExceptionCase.ssi_instruction_id == ssi_instruction_id, + InstructionExceptionCase.rule_id == rule_id, + InstructionExceptionCase.failed_field.is_(failed_field) + if failed_field is None + else InstructionExceptionCase.failed_field == failed_field, + InstructionExceptionCase.context_key.is_(context_key) + if context_key is None + else InstructionExceptionCase.context_key == context_key, + InstructionExceptionCase.status.in_(UNRESOLVED), + ) + .first() + ) + if existing: + return existing + now = utc_now() + case = InstructionExceptionCase( + exception_id=stable_id( + "instruction-exception", + ssi_instruction_id, + rule_id, + failed_field or "", + context_key or "", + now, + ), + ssi_instruction_id=ssi_instruction_id, + rule_id=rule_id, + severity=severity, + failed_field=failed_field, + description=description, + suggested_fix=suggested_fix, + owner_user_email=None, + status="open", + sla_deadline=sla_deadline(severity), + resolved_at=None, + resolution_evidence=None, + context_key=context_key, + created_at=now, + updated_at=now, + ) + session.add(case) + return case + + +def close_resolved_instruction_exceptions( + session: Session, + ssi_instruction_id: str, + failed_keys: set[tuple[str, str | None, str | None]], +) -> None: + now = utc_now() + open_cases = ( + session.query(InstructionExceptionCase) + .filter( + InstructionExceptionCase.ssi_instruction_id == ssi_instruction_id, + InstructionExceptionCase.status.in_(UNRESOLVED), + ) + .all() + ) + for case in open_cases: + key = (case.rule_id, case.failed_field, case.context_key) + if key not in failed_keys: + case.status = "closed" + case.resolved_at = now + case.updated_at = now + + +def write_instruction_exception_audit( + session: Session, + *, + ssi_instruction_id: str, + actor: str, + failures: int, +) -> None: + write_audit( + session, + entity_type="ssi_instruction", + entity_id=ssi_instruction_id, + action="ssi_instruction.validated", + actor_user_email=actor, + new_value={"failures": failures}, + ) diff --git a/apps/ssi-control-tower/app/services/instruction_ingestion.py b/apps/ssi-control-tower/app/services/instruction_ingestion.py new file mode 100644 index 0000000..a417ee4 --- /dev/null +++ b/apps/ssi-control-tower/app/services/instruction_ingestion.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models import ( + Institution, + SourceFile, + SourceRecord, + SourceReject, + SsiInstruction, + SsiSourceLink, +) +from app.services.audit import stable_id, write_audit +from app.services.source_adapters import CanonicalInstructionCandidate, SourceAdapterResult + + +@dataclass(frozen=True) +class InstructionIngestionStats: + records_received: int + records_accepted: int + records_rejected: int + + +def _row_hash(candidate: CanonicalInstructionCandidate) -> str: + safe_parts = ( + candidate.source_row_fingerprint, + candidate.owner_bic, + candidate.currency_code, + candidate.asset_category, + candidate.account_holder_bic, + candidate.account_number_masked, + candidate.update_date or "", + candidate.start_date or "", + candidate.stop_date or "", + ) + return hashlib.sha256(":".join(safe_parts).encode("utf-8")).hexdigest() + + +def _upsert_institution( + session: Session, + *, + source_system: str, + bic: str, + name: str | None, + city: str | None, + country_code: str | None, + now: str, +) -> str: + institution_id = stable_id("institution", source_system, bic) + for pending in session.new: + if isinstance(pending, Institution) and pending.institution_id == institution_id: + pending.name = name or pending.name + pending.city = city or pending.city + pending.country_code = country_code or pending.country_code + pending.updated_at = now + return institution_id + existing = session.get(Institution, institution_id) + if existing is None: + session.add( + Institution( + institution_id=institution_id, + bic=bic, + name=name or None, + city=city or None, + country_code=country_code or None, + source_system=source_system, + created_at=now, + updated_at=now, + ) + ) + else: + existing.name = name or existing.name + existing.city = city or existing.city + existing.country_code = country_code or existing.country_code + existing.updated_at = now + return institution_id + + +def _upsert_instruction( + session: Session, + *, + candidate: CanonicalInstructionCandidate, + owner_institution_id: str, + holder_institution_id: str, + now: str, +) -> str: + instruction_id = stable_id( + "ssi-instruction", + candidate.source_system, + candidate.source_schema, + candidate.source_row_fingerprint, + ) + existing = session.get(SsiInstruction, instruction_id) + values = { + "source_record_key": candidate.source_row_fingerprint, + "owner_institution_id": owner_institution_id, + "owner_bic": candidate.owner_bic, + "owner_name": candidate.owner_name, + "owner_city": candidate.owner_city, + "owner_country_code": candidate.owner_country_code, + "currency_code": candidate.currency_code, + "asset_category": candidate.asset_category, + "account_holder_institution_id": holder_institution_id, + "account_holder_bic": candidate.account_holder_bic, + "account_holder_name": candidate.account_holder_name, + "account_holder_country_code": candidate.account_holder_country_code, + "account_number_masked": candidate.account_number_masked, + "preferred_flag": candidate.preferred_flag, + "account_holder_type": candidate.account_holder_type, + "group_key_owner": candidate.group_key_owner, + "record_key_bdp_owner": candidate.record_key_bdp_owner, + "eid_owner": candidate.eid_owner, + "record_key_bdp_account_holder": candidate.record_key_bdp_account_holder, + "eid_account_holder": candidate.eid_account_holder, + "update_date": candidate.update_date, + "traffic_flag": candidate.traffic_flag, + "traffic_date": candidate.traffic_date, + "start_date": candidate.start_date, + "stop_date": candidate.stop_date, + "status": candidate.status or "active", + "updated_at": now, + } + if existing is None: + session.add( + SsiInstruction( + ssi_instruction_id=instruction_id, + created_at=now, + **values, + ) + ) + else: + for field, value in values.items(): + setattr(existing, field, value) + return instruction_id + + +def ingest_instruction_adapter_result( + session: Session, + *, + adapter_result: SourceAdapterResult, + file_name: str, + file_hash: str, + import_id: str, + uploaded_by: str, + now: str, +) -> InstructionIngestionStats: + """Persist source lineage and canonical instructions from an adapter result. + + Audit payloads are aggregate-only. Source keys are safe fingerprints supplied by the + adapter rather than raw private source identifiers. + """ + accepted = len(adapter_result.candidates) + rejected = len(adapter_result.rejects) + received = accepted + rejected + source_file_id = stable_id("source-file", adapter_result.source_system, file_hash) + source_file = session.get(SourceFile, source_file_id) + if source_file is None: + session.add( + SourceFile( + source_file_id=source_file_id, + import_id=import_id, + file_name=file_name, + file_hash=file_hash, + source_system=adapter_result.source_system, + source_schema=adapter_result.source_schema, + parser_version=adapter_result.parser_version, + uploaded_by=uploaded_by, + records_received=received, + records_accepted=accepted, + records_rejected=rejected, + created_at=now, + ) + ) + else: + source_file.import_id = import_id + source_file.file_name = file_name + source_file.uploaded_by = uploaded_by + source_file.records_received = received + source_file.records_accepted = accepted + source_file.records_rejected = rejected + + for reject in adapter_result.rejects: + reject_id = stable_id("source-reject", source_file_id, reject.row_number, reject.reject_code) + if session.get(SourceReject, reject_id) is None: + session.add( + SourceReject( + source_reject_id=reject_id, + source_file_id=source_file_id, + import_id=import_id, + row_number=reject.row_number, + reject_code=reject.reject_code, + reject_message=reject.reject_message, + created_at=now, + ) + ) + + for candidate in adapter_result.candidates: + source_record_id = stable_id( + "source-record", + source_file_id, + candidate.row_number, + candidate.source_row_fingerprint, + ) + if session.get(SourceRecord, source_record_id) is None: + session.add( + SourceRecord( + source_record_id=source_record_id, + source_file_id=source_file_id, + import_id=import_id, + row_number=candidate.row_number, + source_record_key=candidate.source_row_fingerprint, + source_hash=_row_hash(candidate), + status="accepted", + created_at=now, + ) + ) + owner_institution_id = _upsert_institution( + session, + source_system=candidate.source_system, + bic=candidate.owner_bic, + name=candidate.owner_name, + city=candidate.owner_city, + country_code=candidate.owner_country_code, + now=now, + ) + holder_institution_id = _upsert_institution( + session, + source_system=candidate.source_system, + bic=candidate.account_holder_bic, + name=candidate.account_holder_name, + city=None, + country_code=candidate.account_holder_country_code, + now=now, + ) + instruction_id = _upsert_instruction( + session, + candidate=candidate, + owner_institution_id=owner_institution_id, + holder_institution_id=holder_institution_id, + now=now, + ) + link_id = stable_id("ssi-source-link", instruction_id, source_record_id) + if session.get(SsiSourceLink, link_id) is None: + session.add( + SsiSourceLink( + ssi_source_link_id=link_id, + ssi_instruction_id=instruction_id, + source_record_id=source_record_id, + source_file_id=source_file_id, + import_id=import_id, + created_at=now, + ) + ) + + write_audit( + session, + entity_type="import", + entity_id=import_id, + action="import.instructions.ingested", + actor_user_email=uploaded_by, + new_value={ + "source_system": adapter_result.source_system, + "source_schema": adapter_result.source_schema, + "parser_version": adapter_result.parser_version, + "records_received": received, + "records_accepted": accepted, + "records_rejected": rejected, + }, + ) + session.commit() + return InstructionIngestionStats( + records_received=received, + records_accepted=accepted, + records_rejected=rejected, + ) diff --git a/apps/ssi-control-tower/app/services/instruction_validation.py b/apps/ssi-control-tower/app/services/instruction_validation.py new file mode 100644 index 0000000..62fe462 --- /dev/null +++ b/apps/ssi-control-tower/app/services/instruction_validation.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.models import InstructionValidationResult, SsiInstruction +from app.services.audit import stable_id +from app.services.instruction_exceptions import ( + close_resolved_instruction_exceptions, + upsert_instruction_exception, + write_instruction_exception_audit, +) + +RULE_VERSION = "instruction_rules_v1" +_EXCEPTION_SEVERITIES = {"medium", "high", "critical"} +_PREFERRED_TRUE = {"Y", "P", "TRUE", "1"} +_VALID_PREFERRED_FLAGS = _PREFERRED_TRUE | {"N", "FALSE", "0", ""} + + +@dataclass(frozen=True) +class InstructionRuleOutcome: + rule_id: str + status: str + severity: str + failed_field: str | None + message: str + suggested_fix: str | None = None + context_key: str | None = None + + +def _fail( + rule_id: str, + severity: str, + failed_field: str | None, + message: str, + suggested_fix: str | None = None, + context_key: str | None = None, +) -> InstructionRuleOutcome: + return InstructionRuleOutcome( + rule_id=rule_id, + status="fail", + severity=severity, + failed_field=failed_field, + message=message, + suggested_fix=suggested_fix, + context_key=context_key, + ) + + +def _is_active(instruction: SsiInstruction) -> bool: + return (instruction.status or "").strip().lower() == "active" + + +def _preferred_flag(instruction: SsiInstruction) -> str: + return (instruction.preferred_flag or "").strip().upper() + + +def _is_preferred(instruction: SsiInstruction) -> bool: + return _preferred_flag(instruction) in _PREFERRED_TRUE + + +def _group_key(instruction: SsiInstruction) -> tuple[str, str, str, str]: + return ( + instruction.owner_bic or "", + instruction.currency_code or "", + instruction.asset_category or "", + instruction.account_holder_bic or "", + ) + + +def _safe_context(*parts: object) -> str: + return stable_id("instruction-context", *parts) + + +def _date_range_invalid(instruction: SsiInstruction) -> bool: + return bool(instruction.start_date and instruction.stop_date and instruction.stop_date < instruction.start_date) + + +def _date_intervals_overlap(a: SsiInstruction, b: SsiInstruction) -> bool: + if not a.start_date or not b.start_date: + return False + a_stop = a.stop_date or "9999-12-31" + b_stop = b.stop_date or "9999-12-31" + return a.start_date <= b_stop and b.start_date <= a_stop + + +def _individual_outcomes(instruction: SsiInstruction) -> list[InstructionRuleOutcome]: + outcomes: list[InstructionRuleOutcome] = [] + required_rules = ( + ("SSI.INSTRUCTION.CURRENCY_REQUIRED", "currency_code", "Instruction currency is required."), + ("SSI.INSTRUCTION.ASSET_REQUIRED", "asset_category", "Instruction asset category is required."), + ("SSI.INSTRUCTION.OWNER_BIC_REQUIRED", "owner_bic", "Instruction owner BIC is required."), + ( + "SSI.INSTRUCTION.ACCOUNT_HOLDER_BIC_REQUIRED", + "account_holder_bic", + "Instruction account-holder BIC is required.", + ), + ) + for rule_id, field, message in required_rules: + if not getattr(instruction, field): + outcomes.append(_fail(rule_id, "high", field, message, f"Populate {field}.")) + if _is_active(instruction) and not instruction.start_date: + outcomes.append( + _fail( + "SSI.INSTRUCTION.START_DATE_REQUIRED", + "medium", + "start_date", + "Active instruction requires a start date.", + "Add an effective start date before activating the instruction.", + ) + ) + if _date_range_invalid(instruction): + outcomes.append( + _fail( + "SSI.INSTRUCTION.DATE_RANGE", + "high", + "stop_date", + "Instruction stop date must not be earlier than start date.", + "Correct the instruction lifecycle dates.", + ) + ) + if _preferred_flag(instruction) not in _VALID_PREFERRED_FLAGS: + outcomes.append( + _fail( + "SSI.INSTRUCTION.PREFERRED_FLAG", + "medium", + "preferred_flag", + "Preferred flag must use an approved value.", + "Use Y/P for preferred or N for non-preferred.", + ) + ) + return outcomes + + +def _group_outcomes(instructions: list[SsiInstruction]) -> dict[str, list[InstructionRuleOutcome]]: + grouped: dict[tuple[str, str, str, str], list[SsiInstruction]] = {} + for instruction in instructions: + if not _is_active(instruction): + continue + grouped.setdefault(_group_key(instruction), []).append(instruction) + + outcomes: dict[str, list[InstructionRuleOutcome]] = {item.ssi_instruction_id: [] for item in instructions} + for key, members in grouped.items(): + preferred = [member for member in members if _is_preferred(member)] + if len(preferred) > 1: + context_key = _safe_context("preferred", *key) + for member in preferred: + outcomes[member.ssi_instruction_id].append( + _fail( + "SSI.INSTRUCTION.PREFERRED_UNIQUE", + "high", + "preferred_flag", + "Only one active preferred instruction is allowed per operational group.", + "Retain one preferred instruction and mark the others non-preferred.", + context_key, + ) + ) + for index, first in enumerate(members): + for second in members[index + 1 :]: + if _date_intervals_overlap(first, second): + context_key = _safe_context("overlap", *key) + for member in (first, second): + outcomes[member.ssi_instruction_id].append( + _fail( + "SSI.INSTRUCTION.ACTIVE_INTERVAL_OVERLAP", + "high", + "start_date", + "Active instruction intervals overlap within the same operational group.", + "Adjust lifecycle dates or close the superseded instruction.", + context_key, + ) + ) + return outcomes + + +def _dedupe_outcomes(outcomes: list[InstructionRuleOutcome]) -> list[InstructionRuleOutcome]: + deduped: list[InstructionRuleOutcome] = [] + seen: set[tuple[str, str | None, str | None, str]] = set() + for outcome in outcomes: + key = (outcome.rule_id, outcome.failed_field, outcome.context_key, outcome.status) + if key in seen: + continue + seen.add(key) + deduped.append(outcome) + return deduped + + +def _persist_instruction_results( + session: Session, + instruction: SsiInstruction, + outcomes: list[InstructionRuleOutcome], + actor: str, + now: str, +) -> list[InstructionValidationResult]: + outcomes = _dedupe_outcomes(outcomes) + session.query(InstructionValidationResult).filter( + InstructionValidationResult.ssi_instruction_id == instruction.ssi_instruction_id + ).delete() + results: list[InstructionValidationResult] = [] + failed_keys: set[tuple[str, str | None, str | None]] = set() + for index, outcome in enumerate(outcomes): + result = InstructionValidationResult( + instruction_validation_result_id=stable_id( + "instruction-validation", + instruction.ssi_instruction_id, + outcome.rule_id, + outcome.failed_field or "", + outcome.context_key or "", + now, + index, + ), + ssi_instruction_id=instruction.ssi_instruction_id, + rule_id=outcome.rule_id, + rule_version=RULE_VERSION, + status=outcome.status, + severity=outcome.severity, + failed_field=outcome.failed_field, + message=outcome.message, + suggested_fix=outcome.suggested_fix, + context_key=outcome.context_key, + created_at=now, + ) + session.add(result) + results.append(result) + if outcome.status == "fail": + failed_keys.add((outcome.rule_id, outcome.failed_field, outcome.context_key)) + if outcome.severity in _EXCEPTION_SEVERITIES: + upsert_instruction_exception( + session, + ssi_instruction_id=instruction.ssi_instruction_id, + rule_id=outcome.rule_id, + severity=outcome.severity, + failed_field=outcome.failed_field, + description=outcome.message, + suggested_fix=outcome.suggested_fix, + context_key=outcome.context_key, + ) + close_resolved_instruction_exceptions(session, instruction.ssi_instruction_id, failed_keys) + write_instruction_exception_audit( + session, + ssi_instruction_id=instruction.ssi_instruction_id, + actor=actor, + failures=len(failed_keys), + ) + return results + + +def validate_instruction( + session: Session, + instruction: SsiInstruction, + actor: str = "system", +) -> list[InstructionValidationResult]: + """Validate one canonical SSI instruction with privacy-safe result messages.""" + now = utc_now() + results = _persist_instruction_results(session, instruction, _individual_outcomes(instruction), actor, now) + session.commit() + return results + + +def validate_all_instructions(session: Session, actor: str = "system") -> list[InstructionValidationResult]: + instructions = session.query(SsiInstruction).all() + grouped = _group_outcomes(instructions) + all_results: list[InstructionValidationResult] = [] + now = utc_now() + for instruction in instructions: + outcomes = _individual_outcomes(instruction) + grouped.get(instruction.ssi_instruction_id, []) + all_results.extend(_persist_instruction_results(session, instruction, outcomes, actor, now)) + session.commit() + return all_results diff --git a/apps/ssi-control-tower/app/services/mapping.py b/apps/ssi-control-tower/app/services/mapping.py new file mode 100644 index 0000000..8ed36b6 --- /dev/null +++ b/apps/ssi-control-tower/app/services/mapping.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +import yaml + +from app.config import DATA_DIR + +_ALIAS_CACHE: dict[str, str] | None = None + + +def load_aliases() -> dict[str, str]: + global _ALIAS_CACHE + if _ALIAS_CACHE is None: + data = yaml.safe_load((DATA_DIR / "field_aliases.yaml").read_text()) or {} + aliases: dict[str, str] = {} + for canonical, names in data.items(): + for name in names: + aliases[str(name).strip().lower()] = canonical + _ALIAS_CACHE = aliases + return _ALIAS_CACHE + + +def map_source_row(row: dict[str, Any]) -> dict[str, Any]: + """Map source headers onto canonical SSI fields using approved seed aliases.""" + aliases = load_aliases() + mapped: dict[str, Any] = {} + for key, value in row.items(): + canonical = aliases.get(str(key).strip().lower()) + if canonical: + mapped[canonical] = value + return mapped diff --git a/apps/ssi-control-tower/app/services/normalisation.py b/apps/ssi-control-tower/app/services/normalisation.py new file mode 100644 index 0000000..66b549e --- /dev/null +++ b/apps/ssi-control-tower/app/services/normalisation.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +COUNTRY_ALIASES = { + "france": "FR", + "germany": "DE", + "united kingdom": "GB", + "uk": "GB", + "italy": "IT", + "spain": "ES", + "united states": "US", + "switzerland": "CH", + "luxembourg": "LU", + "netherlands": "NL", + "belgium": "BE", + "japan": "JP", + "singapore": "SG", + "australia": "AU", + "canada": "CA", + "hong kong": "HK", + "sweden": "SE", + "norway": "NO", + "denmark": "DK", +} +CURRENCY_ALIASES = { + "eur": "EUR", + "euro": "EUR", + "gbp": "GBP", + "usd": "USD", + "chf": "CHF", + "jpy": "JPY", + "sgd": "SGD", + "aud": "AUD", + "cad": "CAD", + "hkd": "HKD", + "sek": "SEK", + "nok": "NOK", + "dkk": "DKK", +} +ASSET_ALIASES = { + "equities": "equity", + "equity": "equity", + "fixed income": "fixed_income", + "fixed_income": "fixed_income", + "securities": "securities", + "security": "securities", + "secu": "securities", + "commercial payments": "commercial_payments", + "copa": "commercial_payments", + "cash": "cash", + "bank-to-bank cash": "bank_to_bank_cash", + "bank to bank cash": "bank_to_bank_cash", + "foreign exchange": "foreign_exchange", + "foex": "foreign_exchange", + "fx": "foreign_exchange", + "money market": "money_market", + "mmkt": "money_market", + "derivative": "derivatives", + "derivatives": "derivatives", + "deri": "derivatives", + "trade finance": "trade_finance", + "tfin": "trade_finance", + "treasury": "treasury", + "trea": "treasury", + "futures": "futures", + "futu": "futures", + "collections": "collections", + "coll": "collections", +} +METHOD_ALIASES = {"delivery versus payment": "DVP", "dvp": "DVP", "free of payment": "FOP", "fop": "FOP", "receive versus payment": "RVP", "rvp": "RVP", "delivery free of payment": "DFP", "dfp": "DFP", "cash only": "cash_only", "cash_only": "cash_only"} +BIC_FIELDS = ["place_of_settlement_bic", "global_custodian_bic", "local_agent_bic", "intermediary_bic"] + + +def _clean(value: Any) -> Any: + return value.strip() if isinstance(value, str) else value + + +def _date(value: Any) -> str | None: + if value is None or str(value).strip() == "": + return None + raw = str(value).strip() + for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"): + try: + return datetime.strptime(raw, fmt).date().isoformat() + except ValueError: + pass + return raw + + +def normalize_row(row: dict[str, Any]) -> dict[str, Any]: + """Normalize source values while preserving masked account identifiers only.""" + out = {key: _clean(value) for key, value in row.items()} + for key in BIC_FIELDS: + if out.get(key): + out[key] = str(out[key]).strip().upper() + if out.get("country_code"): + raw = str(out["country_code"]).strip() + out["country_code"] = COUNTRY_ALIASES.get(raw.lower(), raw.upper()) + if out.get("market"): + raw = str(out["market"]).strip() + out["market"] = COUNTRY_ALIASES.get(raw.lower(), raw.upper()) + if out.get("currency_code"): + raw = str(out["currency_code"]).strip() + out["currency_code"] = CURRENCY_ALIASES.get(raw.lower(), raw.upper()) + if out.get("base_currency"): + raw = str(out["base_currency"]).strip() + out["base_currency"] = CURRENCY_ALIASES.get(raw.lower(), raw.upper()) + if out.get("asset_class"): + raw = str(out["asset_class"]).strip().lower() + out["asset_class"] = ASSET_ALIASES.get(raw, raw.replace(" ", "_")) + if out.get("settlement_method"): + raw = str(out["settlement_method"]).strip().lower() + out["settlement_method"] = METHOD_ALIASES.get(raw, str(out["settlement_method"]).strip().upper()) + for key in ("effective_from", "effective_to", "last_confirmed_at"): + if key in out: + out[key] = _date(out.get(key)) + return out diff --git a/apps/ssi-control-tower/app/services/readiness.py b/apps/ssi-control-tower/app/services/readiness.py new file mode 100644 index 0000000..026da80 --- /dev/null +++ b/apps/ssi-control-tower/app/services/readiness.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models import ApprovalRequest, ExceptionCase, SsiRecord +from app.rules.evaluators import ACTIVE_STATUSES + +UNRESOLVED = ["open", "assigned", "in_remediation", "pending_approval"] + + +def readiness_score(session: Session) -> dict: + """Calculate deterministic T+1 readiness over active-like SSI records.""" + active = session.query(SsiRecord).filter(SsiRecord.status.in_(ACTIVE_STATUSES)).all() + active_ids = {r.ssi_id for r in active} + total = len(active) + unresolved = session.query(ExceptionCase).filter(ExceptionCase.status.in_(UNRESOLVED)).all() + unresolved = [e for e in unresolved if e.ssi_id in active_ids] + critical = sum(1 for e in unresolved if e.severity == "critical") + high = sum(1 for e in unresolved if e.severity == "high") + medium = sum(1 for e in unresolved if e.severity == "medium") + stale_ssis = len({e.ssi_id for e in unresolved if e.rule_id == "SSI.STALE.12M"}) + duplicate_active_ssis = len({e.ssi_id for e in unresolved if e.rule_id == "SSI.DUPLICATE.ACTIVE"}) + missing_owner = sum(1 for r in active if not r.owner_user_email) + approval_sla_breaches = 0 + missing_audit_evidence = 0 + for r in active: + if r.status == "approved": + latest = session.query(ApprovalRequest).filter(ApprovalRequest.ssi_id == r.ssi_id, ApprovalRequest.status == "approved").order_by(ApprovalRequest.decided_at.desc()).first() + if latest and not latest.evidence_reference: + missing_audit_evidence += 1 + score = 100 + score -= min(25, critical * 1.25) + score -= min(20, high * 0.20) + score -= min(10, medium * 0.03) + score -= min(15, stale_ssis / max(total, 1) * 100) + score -= min(15, duplicate_active_ssis * 0.50) + score -= min(10, missing_owner * 0.20) + score -= min(10, approval_sla_breaches * 0.30) + score -= min(10, missing_audit_evidence * 0.50) + score = max(0, round(score, 1)) + if score >= 90: + band = "Green" + elif score >= 75: + band = "Amber" + elif score >= 50: + band = "Red" + else: + band = "Critical" + return { + "score": score, + "band": band, + "total_ssis": total, + "critical_exceptions": critical, + "high_exceptions": high, + "medium_exceptions": medium, + "stale_ssis": stale_ssis, + "duplicate_active_ssis": duplicate_active_ssis, + "missing_owner": missing_owner, + "approval_sla_breaches": approval_sla_breaches, + "missing_audit_evidence": missing_audit_evidence, + } diff --git a/apps/ssi-control-tower/app/services/source_adapters.py b/apps/ssi-control-tower/app/services/source_adapters.py new file mode 100644 index 0000000..f3e8cab --- /dev/null +++ b/apps/ssi-control-tower/app/services/source_adapters.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import Any + +from app.services.mapping import map_source_row +from app.services.normalisation import normalize_row +from app.services.ssiplus_v3 import ( + PARSER_VERSION as SSIPLUS_PARSER_VERSION, + SOURCE_SCHEMA as SSIPLUS_SOURCE_SCHEMA, + SOURCE_SYSTEM as SSIPLUS_SOURCE_SYSTEM, + parse_ssiplus_v3_rows, +) + +CSV_PARSER_VERSION = "csv_demo_v1" +CSV_SOURCE_SCHEMA = "CSV_DEMO_V1" +CSV_SOURCE_SYSTEM = "CSV_DEMO" + + +@dataclass(frozen=True) +class CanonicalInstructionCandidate: + source_system: str + source_schema: str + row_number: int + source_row_fingerprint: str + owner_bic: str + currency_code: str + asset_category: str + account_holder_bic: str + account_number_masked: str + preferred_flag: str | None = None + start_date: str | None = None + stop_date: str | None = None + owner_name: str | None = None + owner_city: str | None = None + owner_country_code: str | None = None + account_holder_name: str | None = None + account_holder_country_code: str | None = None + account_holder_type: str | None = None + group_key_owner: str | None = None + record_key_bdp_owner: str | None = None + eid_owner: str | None = None + record_key_bdp_account_holder: str | None = None + eid_account_holder: str | None = None + update_date: str | None = None + traffic_flag: str | None = None + traffic_date: str | None = None + status: str = "active" + privacy_metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class CanonicalSourceReject: + row_number: int + reject_code: str + reject_message: str + + +@dataclass(frozen=True) +class SourceAdapterResult: + source_system: str + source_schema: str + parser_version: str + candidates: list[CanonicalInstructionCandidate] = field(default_factory=list) + rejects: list[CanonicalSourceReject] = field(default_factory=list) + + @property + def records_received(self) -> int: + return len(self.candidates) + len(self.rejects) + + +_REQUIRED_CANONICAL_FIELDS = ( + "owner_bic", + "currency_code", + "asset_category", + "account_holder_bic", + "account_number_masked", +) + + +def _safe_fingerprint(*parts: object) -> str: + return hashlib.sha256(":".join(str(part) for part in parts).encode("utf-8")).hexdigest()[:24] + + +def _clean_code(value: Any) -> str: + return str(value or "").strip().upper() + + +def _is_masked_account_value(value: Any) -> bool: + return isinstance(value, str) and "****" in value.strip() + + +def mask_or_reject_account_number(value: Any) -> str: + raw_value = "" if value is None else str(value).strip() + if not raw_value: + return "" + if not _is_masked_account_value(raw_value): + raise ValueError("canonical SSI instructions require a pre-masked account value") + return raw_value + + +def assert_no_sensitive_instruction_payload(candidate: CanonicalInstructionCandidate) -> None: + """Validate the adapter contract only carries masked account values. + + The candidate may still contain operational codes such as BIC/currency because the + canonical instruction table needs them. Raw rows and raw account numbers are blocked + at this boundary. + """ + if "****" not in candidate.account_number_masked: + raise ValueError("canonical candidate must contain a masked account value") + metadata_text = repr(candidate.privacy_metadata) + if "account_number" in metadata_text.lower() and "value" in metadata_text.lower(): + raise ValueError("privacy metadata may list fields, not raw values") + + +def _candidate_or_reject( + *, + row_number: int, + source_system: str, + source_schema: str, + source_row_fingerprint: str, + owner_bic: Any, + currency_code: Any, + asset_category: Any, + account_holder_bic: Any, + account_number: Any, + preferred_flag: Any = None, + start_date: str | None = None, + stop_date: str | None = None, + owner_name: str | None = None, + owner_city: str | None = None, + owner_country_code: str | None = None, + account_holder_name: str | None = None, + account_holder_country_code: str | None = None, + account_holder_type: str | None = None, + group_key_owner: str | None = None, + record_key_bdp_owner: str | None = None, + eid_owner: str | None = None, + record_key_bdp_account_holder: str | None = None, + eid_account_holder: str | None = None, + update_date: str | None = None, + traffic_flag: str | None = None, + traffic_date: str | None = None, + privacy_metadata: dict[str, Any] | None = None, +) -> CanonicalInstructionCandidate | CanonicalSourceReject: + try: + values = { + "owner_bic": _clean_code(owner_bic), + "currency_code": _clean_code(currency_code), + "asset_category": str(asset_category or "").strip(), + "account_holder_bic": _clean_code(account_holder_bic), + "account_number_masked": mask_or_reject_account_number(account_number), + } + except ValueError as exc: + return CanonicalSourceReject( + row_number=row_number, + reject_code="row.invalid_account_value", + reject_message=str(exc), + ) + missing = [name for name in _REQUIRED_CANONICAL_FIELDS if not values.get(name)] + if missing: + return CanonicalSourceReject( + row_number=row_number, + reject_code="row.missing_required_fields", + reject_message=f"row missing required field(s): {','.join(missing)}", + ) + candidate = CanonicalInstructionCandidate( + source_system=source_system, + source_schema=source_schema, + row_number=row_number, + source_row_fingerprint=source_row_fingerprint, + owner_bic=values["owner_bic"], + currency_code=values["currency_code"], + asset_category=values["asset_category"], + account_holder_bic=values["account_holder_bic"], + account_number_masked=values["account_number_masked"], + preferred_flag=(str(preferred_flag).strip().upper() if preferred_flag not in (None, "") else None), + start_date=start_date, + stop_date=stop_date, + owner_name=owner_name or None, + owner_city=owner_city or None, + owner_country_code=_clean_code(owner_country_code) or None, + account_holder_name=account_holder_name or None, + account_holder_country_code=_clean_code(account_holder_country_code) or None, + account_holder_type=account_holder_type or None, + group_key_owner=group_key_owner or None, + record_key_bdp_owner=record_key_bdp_owner or None, + eid_owner=eid_owner or None, + record_key_bdp_account_holder=record_key_bdp_account_holder or None, + eid_account_holder=eid_account_holder or None, + update_date=update_date, + traffic_flag=traffic_flag or None, + traffic_date=traffic_date, + privacy_metadata=privacy_metadata or {}, + ) + assert_no_sensitive_instruction_payload(candidate) + return candidate + + +def adapt_csv_rows_to_instruction_candidates(rows: list[dict[str, Any]], file_name: str) -> SourceAdapterResult: + candidates: list[CanonicalInstructionCandidate] = [] + rejects: list[CanonicalSourceReject] = [] + for row_number, source_row in enumerate(rows, start=2): + mapped = normalize_row(map_source_row(source_row)) + account_field_names = ("account_number_masked", "securities_account_masked", "cash_account_masked") + invalid_account_fields = [name for name in account_field_names if not _is_masked_account_value(mapped.get(name))] + if invalid_account_fields: + rejects.append( + CanonicalSourceReject( + row_number=row_number, + reject_code="row.invalid_account_fields", + reject_message=f"row missing masked account field(s): {','.join(invalid_account_fields)}", + ) + ) + continue + item = _candidate_or_reject( + row_number=row_number, + source_system=str(mapped.get("source_system") or CSV_SOURCE_SYSTEM), + source_schema=CSV_SOURCE_SCHEMA, + source_row_fingerprint=f"csv:{file_name}:{row_number}", + owner_bic=mapped.get("legal_entity_bic"), + currency_code=mapped.get("currency_code") or mapped.get("base_currency"), + asset_category=mapped.get("asset_class"), + account_holder_bic=mapped.get("global_custodian_bic") + or mapped.get("local_agent_bic") + or mapped.get("place_of_settlement_bic"), + account_number=mapped.get("account_number_masked"), + preferred_flag=mapped.get("preferred_flag") or "N", + start_date=mapped.get("effective_from"), + stop_date=mapped.get("effective_to"), + owner_country_code=mapped.get("country_code") or mapped.get("market"), + account_holder_country_code=mapped.get("country_code") or mapped.get("market"), + privacy_metadata={"source_fields": sorted(mapped.keys())}, + ) + if isinstance(item, CanonicalSourceReject): + rejects.append(item) + else: + candidates.append(item) + return SourceAdapterResult( + source_system=CSV_SOURCE_SYSTEM, + source_schema=CSV_SOURCE_SCHEMA, + parser_version=CSV_PARSER_VERSION, + candidates=candidates, + rejects=rejects, + ) + + +def adapt_ssiplus_v3_bytes_to_instruction_candidates(data: bytes) -> SourceAdapterResult: + result = parse_ssiplus_v3_rows(data) + candidates: list[CanonicalInstructionCandidate] = [] + rejects = [ + CanonicalSourceReject( + row_number=reject.row_number, + reject_code=reject.reject_code, + reject_message=reject.reject_message, + ) + for reject in result.rejects + ] + for row in result.rows: + fingerprint = f"ssiplus:{_safe_fingerprint(row.source_record_key, row.row_number)}" + item = _candidate_or_reject( + row_number=row.row_number, + source_system=SSIPLUS_SOURCE_SYSTEM, + source_schema=SSIPLUS_SOURCE_SCHEMA, + source_row_fingerprint=fingerprint, + owner_bic=row.owner_bic, + currency_code=row.currency_code, + asset_category=row.asset_category, + account_holder_bic=row.account_holder_bic, + account_number=row.account_number_masked, + preferred_flag=row.preferred_flag, + start_date=row.start_date, + stop_date=row.stop_date, + owner_name=row.owner_name, + owner_city=row.owner_city, + owner_country_code=row.owner_country_code, + account_holder_name=row.account_holder_name, + account_holder_country_code=row.account_holder_country_code, + account_holder_type=row.account_holder_type, + group_key_owner=row.group_key_owner, + record_key_bdp_owner=row.record_key_bdp_owner, + eid_owner=row.eid_owner, + record_key_bdp_account_holder=row.record_key_bdp_account_holder, + eid_account_holder=row.eid_account_holder, + update_date=row.update_date, + traffic_flag=row.traffic_flag, + traffic_date=row.traffic_date, + privacy_metadata={"source_fields": ["SSIPLUS_V3"], "fingerprint_version": 1}, + ) + if isinstance(item, CanonicalSourceReject): + rejects.append(item) + else: + candidates.append(item) + return SourceAdapterResult( + source_system=SSIPLUS_SOURCE_SYSTEM, + source_schema=SSIPLUS_SOURCE_SCHEMA, + parser_version=SSIPLUS_PARSER_VERSION, + candidates=candidates, + rejects=rejects, + ) diff --git a/apps/ssi-control-tower/app/services/ssiplus_v3.py b/apps/ssi-control-tower/app/services/ssiplus_v3.py new file mode 100644 index 0000000..93ed28b --- /dev/null +++ b/apps/ssi-control-tower/app/services/ssiplus_v3.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import csv +import io +import re +from dataclasses import dataclass, field +from typing import Iterable + +PARSER_VERSION = "ssiplus_v3_v1" +SOURCE_SYSTEM = "SWIFTREF_SSIPLUS" +SOURCE_SCHEMA = "SSIPLUS_V3" + +EXPECTED_SSIPLUS_V3_FIELDS: tuple[str, ...] = ( + "MODIFICATION FLAG", + "RECORD KEY", + "BIC OWNER", + "INSTITUTION NAME OWNER", + "CITY OWNER", + "ISO COUNTRY CODE OWNER", + "ISO CURRENCY CODE", + "ASSET CATEGORY", + "BIC ACCOUNT HOLDING INSTITUTION", + "INSTITUTION NAME ACCOUNT HOLDING INSTITUTION", + "ISO COUNTRY CODE ACCOUNT HOLDING INSTITUTION", + "ACCOUNT NBR WITH ACCOUNT HOLDING INSTITUTION", + "PREFERRED ACCOUNT HOLDING INSTITUTION", + "ACCOUNT HOLDING INSTITUTION TYPE", + "GROUP KEY OWNER", + "RECORD KEY BDP OWNER", + "EID OWNER", + "RECORD KEY BDP ACCOUNT HOLDING INSTITUTION", + "EID ACCOUNT HOLDING INSTITUTION", + "UPDATE DATE", + "TRAFFIC FLAG", + "TRAFFIC DATE", + "START DATE", + "STOP DATE", + "FIELD A", + "FIELD B", + "FIELD C", + "FIELD D", +) + +REQUIRED_FIELDS_FOR_SHAPE = ( + "MODIFICATION FLAG", + "RECORD KEY", + "BIC OWNER", + "INSTITUTION NAME OWNER", +) + + +@dataclass(frozen=True) +class SsiPlusV3Row: + row_number: int + modification_flag: str + source_record_key: str + owner_bic: str + owner_name: str + owner_city: str + owner_country_code: str + currency_code: str + asset_category: str + account_holder_bic: str + account_holder_name: str + account_holder_country_code: str + account_number_masked: str + preferred_flag: str + account_holder_type: str + group_key_owner: str + record_key_bdp_owner: str + eid_owner: str + record_key_bdp_account_holder: str + eid_account_holder: str + update_date: str | None + traffic_flag: str + traffic_date: str | None + start_date: str | None + stop_date: str | None + + +@dataclass(frozen=True) +class SsiPlusV3Reject: + row_number: int + reject_code: str + reject_message: str + + +@dataclass +class ParseResult: + rows: list[SsiPlusV3Row] = field(default_factory=list) + rejects: list[SsiPlusV3Reject] = field(default_factory=list) + + +def _decode(data: bytes) -> str: + return data.decode("utf-8-sig", errors="replace") + + +def _first_line_fields(data: bytes) -> list[str] | None: + text = _decode(data) + if not text: + return None + first = text.splitlines()[0] if text.splitlines() else "" + if "\t" not in first: + return None + return first.split("\t") + + +def is_ssiplus_v3_bytes(data: bytes) -> bool: + """Return True only when the upload's first line matches the exact 28-field SSI Plus V3 header.""" + fields = _first_line_fields(data) + if fields is None: + return False + return tuple(f.strip() for f in fields) == EXPECTED_SSIPLUS_V3_FIELDS + + +def looks_like_ssiplus_v3_bytes(data: bytes) -> bool: + """Return True when the upload appears to be an SSI Plus V3 candidate even if the header is malformed.""" + fields = _first_line_fields(data) + if fields is None: + return False + normalized = {f.strip().upper() for f in fields if f.strip()} + matches = sum(1 for required in REQUIRED_FIELDS_FOR_SHAPE if required in normalized) + return matches >= 2 + + +def mask_account_number(value: str | None) -> str: + """Return a redacted account label that never preserves the full numeric value.""" + if value is None: + return "ACCT-****" + raw = value.strip() + if not raw: + return "ACCT-****" + if "****" in raw: + return raw + suffix_match = re.search(r"(\d{4})\D*$", raw) + if suffix_match: + return f"ACCT-****-{suffix_match.group(1)}" + return "ACCT-****" + + +def normalize_yyyymmdd(value: str | None) -> str | None: + if value is None: + return None + raw = value.strip() + if not raw: + return None + if not (len(raw) == 8 and raw.isdigit()): + return None + year, month, day = raw[0:4], raw[4:6], raw[6:8] + if not (1 <= int(month) <= 12 and 1 <= int(day) <= 31): + return None + return f"{year}-{month}-{day}" + + +def _required_row_fields() -> tuple[str, ...]: + return ( + "RECORD KEY", + "BIC OWNER", + "ISO CURRENCY CODE", + "ASSET CATEGORY", + "BIC ACCOUNT HOLDING INSTITUTION", + "ACCOUNT NBR WITH ACCOUNT HOLDING INSTITUTION", + ) + + +def parse_ssiplus_v3_rows(data: bytes) -> ParseResult: + """Parse SSI Plus V3 tab-delimited bytes into typed rows; never echoes raw row values into rejects.""" + result = ParseResult() + text = _decode(data) + if not text.strip(): + result.rejects.append(SsiPlusV3Reject(row_number=0, reject_code="schema.empty_payload", reject_message="empty payload")) + return result + + if not is_ssiplus_v3_bytes(data): + result.rejects.append( + SsiPlusV3Reject( + row_number=1, + reject_code="schema.header_mismatch", + reject_message=f"header does not match expected {len(EXPECTED_SSIPLUS_V3_FIELDS)} SSI Plus V3 fields", + ) + ) + return result + + reader = csv.DictReader(io.StringIO(text), delimiter="\t") + required = _required_row_fields() + for index, raw_row in enumerate(reader, start=2): + if raw_row is None: + continue + # csv reader may emit None for missing trailing columns; coerce to empty strings. + row = {key: ("" if value is None else value).strip() for key, value in raw_row.items() if key is not None} + # Skip blank rows entirely (no record key and no modification flag). + if not row.get("RECORD KEY") and not row.get("MODIFICATION FLAG"): + continue + missing = [name for name in required if not row.get(name)] + if missing: + result.rejects.append( + SsiPlusV3Reject( + row_number=index, + reject_code="row.missing_required_fields", + reject_message=f"row missing required field(s): {','.join(missing)}", + ) + ) + continue + account_value = row.get("ACCOUNT NBR WITH ACCOUNT HOLDING INSTITUTION", "") + if "****" not in account_value: + result.rejects.append( + SsiPlusV3Reject( + row_number=index, + reject_code="row.invalid_account_value", + reject_message="row requires a pre-masked account value", + ) + ) + continue + result.rows.append( + SsiPlusV3Row( + row_number=index, + modification_flag=row.get("MODIFICATION FLAG", ""), + source_record_key=row["RECORD KEY"], + owner_bic=row["BIC OWNER"], + owner_name=row.get("INSTITUTION NAME OWNER", ""), + owner_city=row.get("CITY OWNER", ""), + owner_country_code=row.get("ISO COUNTRY CODE OWNER", ""), + currency_code=row["ISO CURRENCY CODE"], + asset_category=row["ASSET CATEGORY"], + account_holder_bic=row["BIC ACCOUNT HOLDING INSTITUTION"], + account_holder_name=row.get("INSTITUTION NAME ACCOUNT HOLDING INSTITUTION", ""), + account_holder_country_code=row.get("ISO COUNTRY CODE ACCOUNT HOLDING INSTITUTION", ""), + account_number_masked=account_value, + preferred_flag=row.get("PREFERRED ACCOUNT HOLDING INSTITUTION", ""), + account_holder_type=row.get("ACCOUNT HOLDING INSTITUTION TYPE", ""), + group_key_owner=row.get("GROUP KEY OWNER", ""), + record_key_bdp_owner=row.get("RECORD KEY BDP OWNER", ""), + eid_owner=row.get("EID OWNER", ""), + record_key_bdp_account_holder=row.get("RECORD KEY BDP ACCOUNT HOLDING INSTITUTION", ""), + eid_account_holder=row.get("EID ACCOUNT HOLDING INSTITUTION", ""), + update_date=normalize_yyyymmdd(row.get("UPDATE DATE", "")), + traffic_flag=row.get("TRAFFIC FLAG", ""), + traffic_date=normalize_yyyymmdd(row.get("TRAFFIC DATE", "")), + start_date=normalize_yyyymmdd(row.get("START DATE", "")), + stop_date=normalize_yyyymmdd(row.get("STOP DATE", "")), + ) + ) + return result + + +def iter_rows(result: ParseResult) -> Iterable[SsiPlusV3Row]: + return iter(result.rows) diff --git a/apps/ssi-control-tower/app/services/validation.py b/apps/ssi-control-tower/app/services/validation.py new file mode 100644 index 0000000..b72810b --- /dev/null +++ b/apps/ssi-control-tower/app/services/validation.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.models import SsiRecord, ValidationResult +from app.rules.engine import evaluate_record +from app.rules.evaluators import ACTIVE_STATUSES +from app.services.audit import stable_id, write_audit +from app.services.exceptions import close_resolved_exceptions, upsert_exception + +EXCEPTION_SEVERITIES = {"medium", "high", "critical"} + + +def validate_record(session: Session, record: SsiRecord, actor: str = "system") -> list[ValidationResult]: + """Rerun rules for one SSI and synchronize unresolved exception cases.""" + session.query(ValidationResult).filter(ValidationResult.ssi_id == record.ssi_id).delete() + outcomes = evaluate_record(record, session) + now = utc_now() + results: list[ValidationResult] = [] + failed_keys: set[tuple[str, str | None, str | None]] = set() + for i, outcome in enumerate(outcomes): + result = ValidationResult( + validation_result_id=stable_id("validation", record.ssi_id, outcome.rule.rule_id, outcome.failed_field or "", outcome.context_key or "", now, i), + ssi_id=record.ssi_id, + rule_id=outcome.rule.rule_id, + rule_version=outcome.rule.version, + status=outcome.status, + severity=outcome.rule.severity, + failed_field=outcome.failed_field, + message=outcome.message, + suggested_fix=outcome.suggested_fix, + context_key=outcome.context_key, + created_at=now, + ) + session.add(result) + results.append(result) + if outcome.status == "fail": + failed_keys.add((outcome.rule.rule_id, outcome.failed_field, outcome.context_key)) + if outcome.rule.severity in EXCEPTION_SEVERITIES: + upsert_exception( + session, + ssi_id=record.ssi_id, + rule_id=outcome.rule.rule_id, + severity=outcome.rule.severity, + failed_field=outcome.failed_field, + description=outcome.message, + suggested_fix=outcome.suggested_fix, + context_key=outcome.context_key, + ) + close_resolved_exceptions(session, record.ssi_id, failed_keys) + if record.status in {"imported", "validation_failed", "validation_passed", "pending_owner"}: + has_failures = any(r.status == "fail" and r.severity in EXCEPTION_SEVERITIES for r in results) + record.status = "validation_failed" if has_failures else "validation_passed" + if not record.owner_user_email and record.status == "validation_failed": + record.status = "pending_owner" + record.updated_at = now + write_audit(session, entity_type="ssi", entity_id=record.ssi_id, action="ssi.validated", actor_user_email=actor, new_value={"failures": sum(1 for r in results if r.status == "fail")}) + return results + + +def validate_all(session: Session, actor: str = "system") -> None: + records = session.query(SsiRecord).all() + for record in records: + validate_record(session, record, actor=actor) + session.commit() + # T+1 critical-exception rule depends on exceptions created by earlier rules. + for record in session.query(SsiRecord).filter(SsiRecord.status.in_(ACTIVE_STATUSES)).all(): + validate_record(session, record, actor=actor) + session.commit() diff --git a/apps/ssi-control-tower/app/web/__init__.py b/apps/ssi-control-tower/app/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ssi-control-tower/app/web/routes.py b/apps/ssi-control-tower/app/web/routes.py new file mode 100644 index 0000000..ce24332 --- /dev/null +++ b/apps/ssi-control-tower/app/web/routes.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from app.api.dashboard import v2_source_controls +from app.deps import get_db +from app.models import ApprovalRequest, AuditEvent, ExceptionCase, ImportBatch, SsiRecord +from app.rules.loader import load_rules +from app.services.readiness import readiness_score + +router = APIRouter(tags=["web"]) +templates = Jinja2Templates(directory="app/web/templates") + + +@router.get("/") +def dashboard(request: Request, session: Session = Depends(get_db)): + score = readiness_score(session) + v2_controls = v2_source_controls(session) + return templates.TemplateResponse( + request, + "dashboard.html", + { + "score": score, + "exceptions": session.query(ExceptionCase).count(), + "ssis": session.query(SsiRecord).count(), + "v2_controls": v2_controls, + }, + ) + + +@router.get("/imports") +def imports_page(request: Request, session: Session = Depends(get_db)): + return templates.TemplateResponse(request, "imports.html", {"imports": session.query(ImportBatch).all()}) + + +@router.get("/ssis") +def ssis_page(request: Request, session: Session = Depends(get_db)): + return templates.TemplateResponse(request, "ssis_list.html", {"ssis": session.query(SsiRecord).order_by(SsiRecord.ssi_id).all()}) + + +@router.get("/ssis/{ssi_id}") +def ssi_detail_page(ssi_id: str, request: Request, session: Session = Depends(get_db)): + return templates.TemplateResponse(request, "ssi_detail.html", {"ssi": session.get(SsiRecord, ssi_id), "exceptions": session.query(ExceptionCase).filter(ExceptionCase.ssi_id == ssi_id).all()}) + + +@router.get("/exceptions") +def exceptions_page(request: Request, session: Session = Depends(get_db)): + return templates.TemplateResponse(request, "exceptions.html", {"exceptions": session.query(ExceptionCase).order_by(ExceptionCase.created_at.desc()).all()}) + + +@router.get("/approvals") +def approvals_page(request: Request, session: Session = Depends(get_db)): + return templates.TemplateResponse(request, "approvals.html", {"approvals": session.query(ApprovalRequest).order_by(ApprovalRequest.created_at.desc()).all()}) + + +@router.get("/audit") +def audit_page(request: Request, session: Session = Depends(get_db)): + return templates.TemplateResponse(request, "audit.html", {"events": session.query(AuditEvent).order_by(AuditEvent.created_at.desc()).limit(100).all()}) + + +@router.get("/rules") +def rules_page(request: Request): + return templates.TemplateResponse(request, "rules.html", {"rules": load_rules()}) diff --git a/apps/ssi-control-tower/app/web/static/app.js b/apps/ssi-control-tower/app/web/static/app.js new file mode 100644 index 0000000..83eaf79 --- /dev/null +++ b/apps/ssi-control-tower/app/web/static/app.js @@ -0,0 +1 @@ +document.addEventListener('DOMContentLoaded', () => { console.log('SSI Control Tower ready'); }); diff --git a/apps/ssi-control-tower/app/web/templates/approvals.html b/apps/ssi-control-tower/app/web/templates/approvals.html new file mode 100644 index 0000000..384c191 --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/approvals.html @@ -0,0 +1,22 @@ +{% extends "base.html" %}{% block content %} +
+
+
+

Four-eyes governance

+

Approvals

+

Change requests waiting for accountable review before they affect SSI readiness.

+
+ Approval ledger +
+
+ {% for a in approvals %} +
+
{{ a.status }}requested by {{ a.requested_by }}
+

{{ a.change_summary }}

+
+ {% else %} +
No approval requests are queued.
+ {% endfor %} +
+
+{% endblock %} diff --git a/apps/ssi-control-tower/app/web/templates/audit.html b/apps/ssi-control-tower/app/web/templates/audit.html new file mode 100644 index 0000000..0918211 --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/audit.html @@ -0,0 +1,22 @@ +{% extends "base.html" %}{% block content %} +
+
+
+

Evidence trail

+

Immutable Audit Events

+

Privacy-safe operational events for import, approval, rule, and export actions.

+
+ Latest 100 +
+
+ {% for e in events %} +
+
{{ e.action }}{{ e.entity_type }} {{ e.entity_id[:8] }}
+
{{ e.actor_user_email }} · {{ e.created_at }}
+
+ {% else %} +
No audit events have been recorded yet.
+ {% endfor %} +
+
+{% endblock %} diff --git a/apps/ssi-control-tower/app/web/templates/base.html b/apps/ssi-control-tower/app/web/templates/base.html new file mode 100644 index 0000000..2eff56b --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/base.html @@ -0,0 +1,47 @@ + + + + + + SSI Control Tower + + + + +
+ + +
+
+ {% block content %}{% endblock %} +
+ + + diff --git a/apps/ssi-control-tower/app/web/templates/dashboard.html b/apps/ssi-control-tower/app/web/templates/dashboard.html new file mode 100644 index 0000000..5ee50e8 --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/dashboard.html @@ -0,0 +1,70 @@ +{% extends "base.html" %}{% block content %} +
+
+
+ + SWIFTRef · SSI Plus · Control Tower +
+

+ Practice-grade SSI governance + for T+1 SSI readiness. +

+

+ A Raafet-style control room for source-truth lineage, four-eyes approvals, exception ownership, and privacy-safe SSI Plus evidence. +

+ +
+ + +
+ +
+
+
SSIs
+
{{ ssis }}
+
+
+
Critical exceptions
+
{{ score.critical_exceptions }}
+
+
+
Missing owner
+
{{ score.missing_owner }}
+
+
+
Readiness band
+
{{ score.band }}
+
+
+ +
+
+
+

Source truth controls

+

SSI Plus source truth

+

V2 control slice for synthetic data/sample_swiftref_ssiplus_v3_synthetic.tsv imports.

+
+ JSON controls +
+
+
Source files
{{ v2_controls.source_files.total }}
+
Domain instructions
{{ v2_controls.instructions.total }}
+
Source rejects
{{ v2_controls.source_files.rejects_total }}
+
Preferred conflict groups
{{ v2_controls.conflicts.preferred_conflict_groups }}
+
Overlapping active groups
{{ v2_controls.conflicts.overlapping_active_groups }}
+
+

Aggregates expose only counts and code-shaped categories. Raw account values, source record keys, BDP keys, EIDs, institution names, and city values stay out of the dashboard.

+
+{% endblock %} diff --git a/apps/ssi-control-tower/app/web/templates/exceptions.html b/apps/ssi-control-tower/app/web/templates/exceptions.html new file mode 100644 index 0000000..ab6e912 --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/exceptions.html @@ -0,0 +1,22 @@ +{% extends "base.html" %}{% block content %} +
+
+
+

Controls queue

+

Exceptions

+

Rule breaches grouped for ownership, remediation, and SLA visibility.

+
+ {{ exceptions|length }} open items +
+
+ {% for e in exceptions %} +
+
{{ e.severity }}{{ e.rule_id }}{{ e.status }}
+

{{ e.description }}

+
+ {% else %} +
No exceptions currently need attention.
+ {% endfor %} +
+
+{% endblock %} diff --git a/apps/ssi-control-tower/app/web/templates/imports.html b/apps/ssi-control-tower/app/web/templates/imports.html new file mode 100644 index 0000000..9fe8e71 --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/imports.html @@ -0,0 +1,22 @@ +{% extends "base.html" %}{% block content %} +
+
+
+

Data intake

+

Imports

+

Synthetic CSV and SSI Plus source files processed into the control tower.

+
+ SWIFTRef · SSI Plus +
+
+ {% for i in imports %} +
+
{{ i.file_name }}
+
{{ i.records_imported }}/{{ i.records_received }} imported · {{ i.status }}
+
+ {% else %} +
No imports yet. Start with the synthetic SSI Plus sample.
+ {% endfor %} +
+
+{% endblock %} diff --git a/apps/ssi-control-tower/app/web/templates/rules.html b/apps/ssi-control-tower/app/web/templates/rules.html new file mode 100644 index 0000000..8b537df --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/rules.html @@ -0,0 +1,20 @@ +{% extends "base.html" %}{% block content %} +
+
+
+

Policy catalogue

+

Rule Catalogue

+

Deterministic governance rules powering readiness scoring and exception generation.

+
+ {{ rules|length }} rules +
+
+ {% for r in rules %} +
+
{{ r.rule_id }}{{ r.severity }}{{ 'enabled' if r.enabled else 'disabled' }}
+

{{ r.message }}

+
+ {% endfor %} +
+
+{% endblock %} diff --git a/apps/ssi-control-tower/app/web/templates/ssi_detail.html b/apps/ssi-control-tower/app/web/templates/ssi_detail.html new file mode 100644 index 0000000..63979b7 --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/ssi_detail.html @@ -0,0 +1,38 @@ +{% extends "base.html" %}{% block content %} +
+
+
+

Instruction detail

+

SSI {{ ssi.ssi_id if ssi else 'not found' }}

+

Masked account-level view with linked control exceptions.

+
+ Back to records +
+ {% if ssi %} +
+
+
+
Market
{{ ssi.market }}
+
Currency
{{ ssi.currency_code }}
+
PSET
{{ ssi.place_of_settlement_bic }}
+
Local agent
{{ ssi.local_agent_bic }}
+
Securities account
{{ ssi.securities_account_masked }}
+
Cash account
{{ ssi.cash_account_masked }}
+
Status
{{ ssi.status }}
+
Approval
{{ ssi.approval_status }}
+
+
+
+

Exceptions

+
+ {% for e in exceptions %} +

{{ e.severity }} · {{ e.rule_id }} · {{ e.status }}

+ {% else %} +

No exceptions for this SSI.

+ {% endfor %} +
+
+
+ {% endif %} +
+{% endblock %} diff --git a/apps/ssi-control-tower/app/web/templates/ssis_list.html b/apps/ssi-control-tower/app/web/templates/ssis_list.html new file mode 100644 index 0000000..6242977 --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/ssis_list.html @@ -0,0 +1,24 @@ +{% extends "base.html" %}{% block content %} +
+
+
+

Operational book

+

SSI Records

+

Golden-record candidates with ownership, approval status, and settlement attributes.

+
+ {{ ssis|length }} records +
+
+ + + + + + {% for ssi in ssis %} + + {% endfor %} + +
IDMarketCurrencyStatusOwner
{{ ssi.ssi_id[:8] }}{{ ssi.market }}{{ ssi.currency_code }}{{ ssi.status }}{{ ssi.owner_user_email or 'missing' }}
+
+
+{% endblock %} diff --git a/apps/ssi-control-tower/config.example b/apps/ssi-control-tower/config.example new file mode 100644 index 0000000..5dd3d1c --- /dev/null +++ b/apps/ssi-control-tower/config.example @@ -0,0 +1,2 @@ +SSI_DB_PATH=data/ssi.db +SSI_EXPORT_DIR=data/exports diff --git a/apps/ssi-control-tower/data/country_codes.yaml b/apps/ssi-control-tower/data/country_codes.yaml new file mode 100644 index 0000000..f020427 --- /dev/null +++ b/apps/ssi-control-tower/data/country_codes.yaml @@ -0,0 +1,18 @@ +- FR +- DE +- GB +- IT +- ES +- US +- CH +- LU +- NL +- BE +- JP +- SG +- AU +- CA +- HK +- SE +- NO +- DK diff --git a/apps/ssi-control-tower/data/currency_codes.yaml b/apps/ssi-control-tower/data/currency_codes.yaml new file mode 100644 index 0000000..b1d6eaf --- /dev/null +++ b/apps/ssi-control-tower/data/currency_codes.yaml @@ -0,0 +1,12 @@ +- EUR +- GBP +- USD +- CHF +- JPY +- SGD +- AUD +- CAD +- HKD +- SEK +- NOK +- DKK diff --git a/apps/ssi-control-tower/data/field_aliases.yaml b/apps/ssi-control-tower/data/field_aliases.yaml new file mode 100644 index 0000000..c5c5fae --- /dev/null +++ b/apps/ssi-control-tower/data/field_aliases.yaml @@ -0,0 +1,32 @@ +ssi_reference: [SSI Reference] +legal_entity_name: [Entity Name, Legal Entity, Fund Name] +lei: [LEI] +legal_entity_bic: [BIC, Entity BIC] +jurisdiction: [Jurisdiction] +account_name: [Account Name] +account_number_masked: [Account Number] +fund_code: [Fund Code] +base_currency: [Base Currency] +asset_class: [Asset Class] +market: [Market] +country_code: [Country, Country Code] +currency_code: [Currency, Currency Code] +place_of_settlement_bic: [PSET BIC, Place of Settlement BIC] +depository: [Depository] +global_custodian_bic: [Global Custodian BIC] +local_agent_bic: [Local Agent BIC] +intermediary_bic: [Intermediary BIC] +securities_account_masked: [Securities Account] +cash_account_masked: [Cash Account] +payment_system: [Payment System] +settlement_method: [Settlement Method] +effective_from: [Effective From] +effective_to: [Effective To] +status: [Status] +owner_user_email: [Owner Email] +owner_team: [Owner Team] +last_confirmed_at: [Last Confirmed] +source_system: [Source System] +source_trust_level: [Source Trust Level] +approval_status: [Approval Status] +risk_rating: [Risk Rating] diff --git a/apps/ssi-control-tower/data/market_directory.yaml b/apps/ssi-control-tower/data/market_directory.yaml new file mode 100644 index 0000000..fb9764e --- /dev/null +++ b/apps/ssi-control-tower/data/market_directory.yaml @@ -0,0 +1,18 @@ +FR: {country_code: FR, name: France, t1_critical: true} +DE: {country_code: DE, name: Germany, t1_critical: true} +GB: {country_code: GB, name: United Kingdom, t1_critical: true} +IT: {country_code: IT, name: Italy, t1_critical: true} +ES: {country_code: ES, name: Spain, t1_critical: true} +US: {country_code: US, name: United States, t1_critical: false} +CH: {country_code: CH, name: Switzerland, t1_critical: false} +LU: {country_code: LU, name: Luxembourg, t1_critical: false} +NL: {country_code: NL, name: Netherlands, t1_critical: false} +BE: {country_code: BE, name: Belgium, t1_critical: false} +JP: {country_code: JP, name: Japan, t1_critical: false} +SG: {country_code: SG, name: Singapore, t1_critical: false} +AU: {country_code: AU, name: Australia, t1_critical: false} +CA: {country_code: CA, name: Canada, t1_critical: false} +HK: {country_code: HK, name: Hong Kong, t1_critical: false} +SE: {country_code: SE, name: Sweden, t1_critical: false} +NO: {country_code: NO, name: Norway, t1_critical: false} +DK: {country_code: DK, name: Denmark, t1_critical: false} diff --git a/apps/ssi-control-tower/data/sample_realistic_multi_market_ssi.csv b/apps/ssi-control-tower/data/sample_realistic_multi_market_ssi.csv new file mode 100644 index 0000000..4af6a22 --- /dev/null +++ b/apps/ssi-control-tower/data/sample_realistic_multi_market_ssi.csv @@ -0,0 +1,61 @@ +SSI Reference,Entity Name,LEI,BIC,Jurisdiction,Account Name,Account Number,Fund Code,Base Currency,Asset Class,Market,Country,Currency,PSET BIC,Depository,Global Custodian BIC,Local Agent BIC,Intermediary BIC,Securities Account,Cash Account,Payment System,Settlement Method,Effective From,Effective To,Status,Owner Email,Owner Team,Last Confirmed,Source System,Source Trust Level,Approval Status,Risk Rating +RSI-0001,Orion Synthetic Bank Securities Desk,529900SYNTHSSI000001,ORIOUS33XXX,US,ORION USD Securities Nostro,ACCT-****-7001,ORISECU01,USD,Securities,US,US,USD,PSETUS33XXX,DTC,GCUSUS33XXX,AGNTUS33XXX,,SEC-****-7001,CASH-****-8001,Fedwire,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-01-01,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,critical +RSI-0002,Atlas Synthetic Securities Commercial Payments Desk,529900SYNTHSSI000002,ATLAGB2LXXX,GB,ATLAS GBP Commercial Payments Nostro,ACCT-****-7002,ATLCOPA02,GBP,Commercial Payments,GB,GB,GBP,PSETGB2LXXX,CREST,GCUSGB2LXXX,AGNTGB2LXXX,,SEC-****-7002,CASH-****-8002,CHAPS,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-02-02,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0003,Nova Synthetic Treasury Bank-to-Bank Cash Desk,529900SYNTHSSI000003,NOVAFRPPXXX,FR,NOVA EUR Bank-to-Bank Cash Nostro,ACCT-****-7003,NOVCASH03,EUR,Bank-to-Bank Cash,FR,FR,EUR,PSETFRPPXXX,EOC,GCUSGB2LXXX,AGNTFRPPXXX,,SEC-****-7003,CASH-****-8003,TARGET2S,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-03-03,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0004,Helios Synthetic Capital Foreign Exchange Desk,529900SYNTHSSI000004,HELIDEFFXXX,DE,HELIO EUR Foreign Exchange Nostro,ACCT-****-7004,HELFOEX04,EUR,Foreign Exchange,DE,DE,EUR,PSETDEFFXXX,CBF,GCUSGB2LXXX,AGNTDEFFXXX,INTRGB2LXXX,SEC-****-7004,CASH-****-8004,TARGET2S,RVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-04-04,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,critical +RSI-0005,Meridian Synthetic Markets Money Market Desk,529900SYNTHSSI000005,MERICHZZXXX,CH,MERID CHF Money Market Nostro,ACCT-****-7005,MERMMKT05,CHF,Money Market,CH,CH,CHF,PSETCHZZXXX,SIXSIS,GCUSGB2LXXX,AGNTCHZZXXX,,SEC-****-7005,CASH-****-8005,SIC,DVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-05-05,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0006,Cirrus Synthetic Bank Derivatives Desk,529900SYNTHSSI000006,CIRRJPJTXXX,JP,CIRRU JPY Derivatives Nostro,ACCT-****-7006,CIRDERI06,JPY,Derivatives,JP,JP,JPY,PSETJPJTXXX,JASDEC,GCUSJPJTXXX,AGNTJPJTXXX,,SEC-****-7006,CASH-****-8006,BOJ-NET,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-06-06,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0007,Orion Synthetic Bank Trade Finance Desk,529900SYNTHSSI000007,ORIOSGSGXXX,SG,ORION SGD Trade Finance Nostro,ACCT-****-7007,ORITFIN07,SGD,Trade Finance,SG,SG,SGD,PSETSGSGXXX,CDP,GCUSSGSGXXX,AGNTSGSGXXX,,SEC-****-7007,CASH-****-8007,MEPS+,FOP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-07-07,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0008,Atlas Synthetic Securities Treasury Desk,529900SYNTHSSI000008,ATLAAU2SXXX,AU,ATLAS AUD Treasury Nostro,ACCT-****-7008,ATLTREA08,AUD,Treasury,AU,AU,AUD,PSETAU2SXXX,ASX,GCUSAU2SXXX,AGNTAU2SXXX,INTRGB2LXXX,SEC-****-7008,CASH-****-8008,RITS,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-08-08,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0009,Nova Synthetic Treasury Futures Desk,529900SYNTHSSI000009,NOVACA8VXXX,CA,NOVA CAD Futures Nostro,ACCT-****-7009,NOVFUTU09,CAD,Futures,CA,CA,CAD,PSETCA8VXXX,CDS,GCUSCA8VXXX,AGNTCA8VXXX,,SEC-****-7009,CASH-****-8009,LYNX,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-09-09,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0010,Helios Synthetic Capital Collections Desk,529900SYNTHSSI000010,HELIHKHHXXX,HK,HELIO HKD Collections Nostro,ACCT-****-7010,HELCOLL10,HKD,Collections,HK,HK,HKD,PSETHKHHXXX,HKMA-CMU,GCUSHKHHXXX,AGNTHKHHXXX,,SEC-****-7010,CASH-****-8010,CHATS,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-10-10,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0011,Meridian Synthetic Markets Securities Desk,529900SYNTHSSI000011,MERISESSXXX,SE,MERID SEK Securities Nostro,ACCT-****-7011,MERSECU11,SEK,Securities,SE,SE,SEK,PSETSESSXXX,EUROCLEAR-SE,GCUSGB2LXXX,AGNTSESSXXX,,SEC-****-7011,CASH-****-8011,RIX,DVP,2025-01-01,2026-12-31,imported,ops-owner@example.com,Reference Data,2026-11-11,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0012,Cirrus Synthetic Bank Commercial Payments Desk,529900SYNTHSSI000012,CIRRNOKKXXX,NO,CIRRU NOK Commercial Payments Nostro,ACCT-****-7012,CIRCOPA12,NOK,Commercial Payments,NO,NO,NOK,PSETNOKKXXX,VPS,GCUSGB2LXXX,AGNTNOKKXXX,INTRGB2LXXX,SEC-****-7012,CASH-****-8012,NICS,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-12-12,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0013,Orion Synthetic Bank Bank-to-Bank Cash Desk,529900SYNTHSSI000013,ORIODKKKXXX,DK,ORION DKK Bank-to-Bank Cash Nostro,ACCT-****-7013,ORICASH13,DKK,Bank-to-Bank Cash,DK,DK,DKK,PSETDKKKXXX,VP,GCUSGB2LXXX,AGNTDKKKXXX,,SEC-****-7013,CASH-****-8013,KRONOS2,cash_only,2025-01-01,,imported,approver@example.com,Payments Operations,2026-01-13,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0014,Atlas Synthetic Securities Foreign Exchange Desk,529900SYNTHSSI000014,ATLANL2AXXX,NL,ATLAS EUR Foreign Exchange Nostro,ACCT-****-7014,ATLFOEX14,EUR,Foreign Exchange,NL,NL,EUR,PSETNL2AXXX,EOC,GCUSGB2LXXX,AGNTNL2AXXX,,SEC-****-7014,CASH-****-8014,TARGET2S,RVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-02-14,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0015,Nova Synthetic Treasury Money Market Desk,529900SYNTHSSI000015,NOVABEBBXXX,BE,NOVA EUR Money Market Nostro,ACCT-****-7015,NOVMMKT15,EUR,Money Market,BE,BE,EUR,PSETBEBBXXX,NBB-SSS,GCUSGB2LXXX,AGNTBEBBXXX,,SEC-****-7015,CASH-****-8015,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-03-15,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0016,Helios Synthetic Capital Derivatives Desk,529900SYNTHSSI000016,HELIITMMXXX,IT,HELIO EUR Derivatives Nostro,ACCT-****-7016,HELDERI16,EUR,Derivatives,IT,IT,EUR,PSETITMMXXX,MONTE-TITOLI,GCUSGB2LXXX,AGNTITMMXXX,INTRGB2LXXX,SEC-****-7016,CASH-****-8016,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-04-16,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,critical +RSI-0017,Meridian Synthetic Markets Trade Finance Desk,529900SYNTHSSI000017,MERIESMMXXX,ES,MERID EUR Trade Finance Nostro,ACCT-****-7017,MERTFIN17,EUR,Trade Finance,ES,ES,EUR,PSETESMMXXX,IBERCLEAR,GCUSGB2LXXX,AGNTESMMXXX,,SEC-****-7017,CASH-****-8017,TARGET2S,FOP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-05-17,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0018,Cirrus Synthetic Bank Treasury Desk,529900SYNTHSSI000018,CIRRLULLXXX,LU,CIRRU EUR Treasury Nostro,ACCT-****-7018,CIRTREA18,EUR,Treasury,LU,LU,EUR,PSETLULLXXX,CLEARSTREAM,GCUSGB2LXXX,AGNTLULLXXX,,SEC-****-7018,CASH-****-8018,TARGET2S,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-06-18,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0019,Orion Synthetic Bank Futures Desk,529900SYNTHSSI000019,ORIOUS33XXX,US,ORION USD Futures Nostro,ACCT-****-7019,ORIFUTU19,USD,Futures,US,US,USD,PSETUS33XXX,DTC,GCUSUS33XXX,AGNTUS33XXX,,SEC-****-7019,CASH-****-8019,Fedwire,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-07-19,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0020,Atlas Synthetic Securities Collections Desk,529900SYNTHSSI000020,ATLAGB2LXXX,GB,ATLAS GBP Collections Nostro,ACCT-****-7020,ATLCOLL20,GBP,Collections,GB,GB,GBP,PSETGB2LXXX,CREST,GCUSGB2LXXX,AGNTGB2LXXX,INTRGB2LXXX,SEC-****-7020,CASH-****-8020,CHAPS,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-08-20,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0021,Nova Synthetic Treasury Securities Desk,529900SYNTHSSI000021,NOVAFRPPXXX,FR,NOVA EUR Securities Nostro,ACCT-****-7021,NOVSECU21,EUR,Securities,FR,FR,EUR,PSETFRPPXXX,EOC,GCUSGB2LXXX,AGNTFRPPXXX,,SEC-****-7021,CASH-****-8021,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-09-21,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,critical +RSI-0022,Helios Synthetic Capital Commercial Payments Desk,529900SYNTHSSI000022,HELIDEFFXXX,DE,HELIO EUR Commercial Payments Nostro,ACCT-****-7022,HELCOPA22,EUR,Commercial Payments,DE,DE,EUR,PSETDEFFXXX,CBF,GCUSGB2LXXX,AGNTDEFFXXX,,SEC-****-7022,CASH-****-8022,TARGET2S,cash_only,2025-01-01,2026-12-31,imported,ops-owner@example.com,Payments Operations,2026-10-22,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0023,Meridian Synthetic Markets Bank-to-Bank Cash Desk,529900SYNTHSSI000023,MERICHZZXXX,CH,MERID CHF Bank-to-Bank Cash Nostro,ACCT-****-7023,MERCASH23,CHF,Bank-to-Bank Cash,CH,CH,CHF,PSETCHZZXXX,SIXSIS,GCUSGB2LXXX,AGNTCHZZXXX,,SEC-****-7023,CASH-****-8023,SIC,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-11-23,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0024,Cirrus Synthetic Bank Foreign Exchange Desk,529900SYNTHSSI000024,CIRRJPJTXXX,JP,CIRRU JPY Foreign Exchange Nostro,ACCT-****-7024,CIRFOEX24,JPY,Foreign Exchange,JP,JP,JPY,PSETJPJTXXX,JASDEC,GCUSJPJTXXX,AGNTJPJTXXX,INTRGB2LXXX,SEC-****-7024,CASH-****-8024,BOJ-NET,RVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-12-24,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0025,Orion Synthetic Bank Money Market Desk,529900SYNTHSSI000025,ORIOSGSGXXX,SG,ORION SGD Money Market Nostro,ACCT-****-7025,ORIMMKT25,SGD,Money Market,SG,SG,SGD,PSETSGSGXXX,CDP,GCUSSGSGXXX,AGNTSGSGXXX,,SEC-****-7025,CASH-****-8025,MEPS+,DVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-01-01,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0026,Atlas Synthetic Securities Derivatives Desk,529900SYNTHSSI000026,ATLAAU2SXXX,AU,ATLAS AUD Derivatives Nostro,ACCT-****-7026,ATLDERI26,AUD,Derivatives,AU,AU,AUD,PSETAU2SXXX,ASX,GCUSAU2SXXX,AGNTAU2SXXX,,SEC-****-7026,CASH-****-8026,RITS,DVP,2025-01-01,,imported,approver@example.com,Reference Data,2026-02-02,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0027,Nova Synthetic Treasury Trade Finance Desk,529900SYNTHSSI000027,NOVACA8VXXX,CA,NOVA CAD Trade Finance Nostro,ACCT-****-7027,NOVTFIN27,CAD,Trade Finance,CA,CA,CAD,PSETCA8VXXX,CDS,GCUSCA8VXXX,AGNTCA8VXXX,,SEC-****-7027,CASH-****-8027,LYNX,FOP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-03-03,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0028,Helios Synthetic Capital Treasury Desk,529900SYNTHSSI000028,HELIHKHHXXX,HK,HELIO HKD Treasury Nostro,ACCT-****-7028,HELTREA28,HKD,Treasury,HK,HK,HKD,PSETHKHHXXX,HKMA-CMU,GCUSHKHHXXX,AGNTHKHHXXX,INTRGB2LXXX,SEC-****-7028,CASH-****-8028,CHATS,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-04-04,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0029,Meridian Synthetic Markets Futures Desk,529900SYNTHSSI000029,MERISESSXXX,SE,MERID SEK Futures Nostro,ACCT-****-7029,MERFUTU29,SEK,Futures,SE,SE,SEK,PSETSESSXXX,EUROCLEAR-SE,GCUSGB2LXXX,AGNTSESSXXX,,SEC-****-7029,CASH-****-8029,RIX,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-05-05,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0030,Cirrus Synthetic Bank Collections Desk,529900SYNTHSSI000030,CIRRNOKKXXX,NO,CIRRU NOK Collections Nostro,ACCT-****-7030,CIRCOLL30,NOK,Collections,NO,NO,NOK,PSETNOKKXXX,VPS,GCUSGB2LXXX,AGNTNOKKXXX,,SEC-****-7030,CASH-****-8030,NICS,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-06-06,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0031,Orion Synthetic Bank Securities Desk,529900SYNTHSSI000031,ORIODKKKXXX,DK,ORION DKK Securities Nostro,ACCT-****-7031,ORISECU31,DKK,Securities,DK,DK,DKK,PSETDKKKXXX,VP,GCUSGB2LXXX,AGNTDKKKXXX,,SEC-****-7031,CASH-****-8031,KRONOS2,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-07-07,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0032,Atlas Synthetic Securities Commercial Payments Desk,529900SYNTHSSI000032,ATLANL2AXXX,NL,ATLAS EUR Commercial Payments Nostro,ACCT-****-7032,ATLCOPA32,EUR,Commercial Payments,NL,NL,EUR,PSETNL2AXXX,EOC,GCUSGB2LXXX,AGNTNL2AXXX,INTRGB2LXXX,SEC-****-7032,CASH-****-8032,TARGET2S,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-08-08,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0033,Nova Synthetic Treasury Bank-to-Bank Cash Desk,529900SYNTHSSI000033,NOVABEBBXXX,BE,NOVA EUR Bank-to-Bank Cash Nostro,ACCT-****-7033,NOVCASH33,EUR,Bank-to-Bank Cash,BE,BE,EUR,PSETBEBBXXX,NBB-SSS,GCUSGB2LXXX,AGNTBEBBXXX,,SEC-****-7033,CASH-****-8033,TARGET2S,cash_only,2025-01-01,2026-12-31,imported,ops-owner@example.com,Payments Operations,2026-09-09,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0034,Helios Synthetic Capital Foreign Exchange Desk,529900SYNTHSSI000034,HELIITMMXXX,IT,HELIO EUR Foreign Exchange Nostro,ACCT-****-7034,HELFOEX34,EUR,Foreign Exchange,IT,IT,EUR,PSETITMMXXX,MONTE-TITOLI,GCUSGB2LXXX,AGNTITMMXXX,,SEC-****-7034,CASH-****-8034,TARGET2S,RVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-10-10,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,critical +RSI-0035,Meridian Synthetic Markets Money Market Desk,529900SYNTHSSI000035,MERIESMMXXX,ES,MERID EUR Money Market Nostro,ACCT-****-7035,MERMMKT35,EUR,Money Market,ES,ES,EUR,PSETESMMXXX,IBERCLEAR,GCUSGB2LXXX,AGNTESMMXXX,,SEC-****-7035,CASH-****-8035,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-11-11,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0036,Cirrus Synthetic Bank Derivatives Desk,529900SYNTHSSI000036,CIRRLULLXXX,LU,CIRRU EUR Derivatives Nostro,ACCT-****-7036,CIRDERI36,EUR,Derivatives,LU,LU,EUR,PSETLULLXXX,CLEARSTREAM,GCUSGB2LXXX,AGNTLULLXXX,INTRGB2LXXX,SEC-****-7036,CASH-****-8036,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-12-12,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0037,Orion Synthetic Bank Trade Finance Desk,529900SYNTHSSI000037,ORIOUS33XXX,US,ORION USD Trade Finance Nostro,ACCT-****-7037,ORITFIN37,USD,Trade Finance,US,US,USD,PSETUS33XXX,DTC,GCUSUS33XXX,AGNTUS33XXX,,SEC-****-7037,CASH-****-8037,Fedwire,FOP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-01-13,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0038,Atlas Synthetic Securities Treasury Desk,529900SYNTHSSI000038,ATLAGB2LXXX,GB,ATLAS GBP Treasury Nostro,ACCT-****-7038,ATLTREA38,GBP,Treasury,GB,GB,GBP,PSETGB2LXXX,CREST,GCUSGB2LXXX,AGNTGB2LXXX,,SEC-****-7038,CASH-****-8038,CHAPS,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-02-14,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0039,Nova Synthetic Treasury Futures Desk,529900SYNTHSSI000039,NOVAFRPPXXX,FR,NOVA EUR Futures Nostro,ACCT-****-7039,NOVFUTU39,EUR,Futures,FR,FR,EUR,PSETFRPPXXX,EOC,GCUSGB2LXXX,AGNTFRPPXXX,,SEC-****-7039,CASH-****-8039,TARGET2S,DVP,2025-01-01,,imported,approver@example.com,Reference Data,2026-03-15,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0040,Helios Synthetic Capital Collections Desk,529900SYNTHSSI000040,HELIDEFFXXX,DE,HELIO EUR Collections Nostro,ACCT-****-7040,HELCOLL40,EUR,Collections,DE,DE,EUR,PSETDEFFXXX,CBF,GCUSGB2LXXX,AGNTDEFFXXX,INTRGB2LXXX,SEC-****-7040,CASH-****-8040,TARGET2S,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-04-16,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0041,Meridian Synthetic Markets Securities Desk,529900SYNTHSSI000041,MERICHZZXXX,CH,MERID CHF Securities Nostro,ACCT-****-7041,MERSECU41,CHF,Securities,CH,CH,CHF,PSETCHZZXXX,SIXSIS,GCUSGB2LXXX,AGNTCHZZXXX,,SEC-****-7041,CASH-****-8041,SIC,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-05-17,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0042,Cirrus Synthetic Bank Commercial Payments Desk,529900SYNTHSSI000042,CIRRJPJTXXX,JP,CIRRU JPY Commercial Payments Nostro,ACCT-****-7042,CIRCOPA42,JPY,Commercial Payments,JP,JP,JPY,PSETJPJTXXX,JASDEC,GCUSJPJTXXX,AGNTJPJTXXX,,SEC-****-7042,CASH-****-8042,BOJ-NET,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-06-18,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0043,Orion Synthetic Bank Bank-to-Bank Cash Desk,529900SYNTHSSI000043,ORIOSGSGXXX,SG,ORION SGD Bank-to-Bank Cash Nostro,ACCT-****-7043,ORICASH43,SGD,Bank-to-Bank Cash,SG,SG,SGD,PSETSGSGXXX,CDP,GCUSSGSGXXX,AGNTSGSGXXX,,SEC-****-7043,CASH-****-8043,MEPS+,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-07-19,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0044,Atlas Synthetic Securities Foreign Exchange Desk,529900SYNTHSSI000044,ATLAAU2SXXX,AU,ATLAS AUD Foreign Exchange Nostro,ACCT-****-7044,ATLFOEX44,AUD,Foreign Exchange,AU,AU,AUD,PSETAU2SXXX,ASX,GCUSAU2SXXX,AGNTAU2SXXX,INTRGB2LXXX,SEC-****-7044,CASH-****-8044,RITS,RVP,2025-01-01,2026-12-31,imported,ops-owner@example.com,Payments Operations,2026-08-20,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0045,Nova Synthetic Treasury Money Market Desk,529900SYNTHSSI000045,NOVACA8VXXX,CA,NOVA CAD Money Market Nostro,ACCT-****-7045,NOVMMKT45,CAD,Money Market,CA,CA,CAD,PSETCA8VXXX,CDS,GCUSCA8VXXX,AGNTCA8VXXX,,SEC-****-7045,CASH-****-8045,LYNX,DVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-09-21,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0046,Helios Synthetic Capital Derivatives Desk,529900SYNTHSSI000046,HELIHKHHXXX,HK,HELIO HKD Derivatives Nostro,ACCT-****-7046,HELDERI46,HKD,Derivatives,HK,HK,HKD,PSETHKHHXXX,HKMA-CMU,GCUSHKHHXXX,AGNTHKHHXXX,,SEC-****-7046,CASH-****-8046,CHATS,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-10-22,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0047,Meridian Synthetic Markets Trade Finance Desk,529900SYNTHSSI000047,MERISESSXXX,SE,MERID SEK Trade Finance Nostro,ACCT-****-7047,MERTFIN47,SEK,Trade Finance,SE,SE,SEK,PSETSESSXXX,EUROCLEAR-SE,GCUSGB2LXXX,AGNTSESSXXX,,SEC-****-7047,CASH-****-8047,RIX,FOP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-11-23,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0048,Cirrus Synthetic Bank Treasury Desk,529900SYNTHSSI000048,CIRRNOKKXXX,NO,CIRRU NOK Treasury Nostro,ACCT-****-7048,CIRTREA48,NOK,Treasury,NO,NO,NOK,PSETNOKKXXX,VPS,GCUSGB2LXXX,AGNTNOKKXXX,INTRGB2LXXX,SEC-****-7048,CASH-****-8048,NICS,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-12-24,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0049,Orion Synthetic Bank Futures Desk,529900SYNTHSSI000049,ORIODKKKXXX,DK,ORION DKK Futures Nostro,ACCT-****-7049,ORIFUTU49,DKK,Futures,DK,DK,DKK,PSETDKKKXXX,VP,GCUSGB2LXXX,AGNTDKKKXXX,,SEC-****-7049,CASH-****-8049,KRONOS2,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-01-01,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0050,Atlas Synthetic Securities Collections Desk,529900SYNTHSSI000050,ATLANL2AXXX,NL,ATLAS EUR Collections Nostro,ACCT-****-7050,ATLCOLL50,EUR,Collections,NL,NL,EUR,PSETNL2AXXX,EOC,GCUSGB2LXXX,AGNTNL2AXXX,,SEC-****-7050,CASH-****-8050,TARGET2S,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-02-02,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0051,Nova Synthetic Treasury Securities Desk,529900SYNTHSSI000051,NOVABEBBXXX,BE,NOVA EUR Securities Nostro,ACCT-****-7051,NOVSECU51,EUR,Securities,BE,BE,EUR,PSETBEBBXXX,NBB-SSS,GCUSGB2LXXX,AGNTBEBBXXX,,SEC-****-7051,CASH-****-8051,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-03-03,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0052,Helios Synthetic Capital Commercial Payments Desk,529900SYNTHSSI000052,HELIITMMXXX,IT,HELIO EUR Commercial Payments Nostro,ACCT-****-7052,HELCOPA52,EUR,Commercial Payments,IT,IT,EUR,PSETITMMXXX,MONTE-TITOLI,GCUSGB2LXXX,AGNTITMMXXX,INTRGB2LXXX,SEC-****-7052,CASH-****-8052,TARGET2S,cash_only,2025-01-01,,imported,approver@example.com,Payments Operations,2026-04-04,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0053,Meridian Synthetic Markets Bank-to-Bank Cash Desk,529900SYNTHSSI000053,MERIESMMXXX,ES,MERID EUR Bank-to-Bank Cash Nostro,ACCT-****-7053,MERCASH53,EUR,Bank-to-Bank Cash,ES,ES,EUR,PSETESMMXXX,IBERCLEAR,GCUSGB2LXXX,AGNTESMMXXX,,SEC-****-7053,CASH-****-8053,TARGET2S,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-05-05,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0054,Cirrus Synthetic Bank Foreign Exchange Desk,529900SYNTHSSI000054,CIRRLULLXXX,LU,CIRRU EUR Foreign Exchange Nostro,ACCT-****-7054,CIRFOEX54,EUR,Foreign Exchange,LU,LU,EUR,PSETLULLXXX,CLEARSTREAM,GCUSGB2LXXX,AGNTLULLXXX,,SEC-****-7054,CASH-****-8054,TARGET2S,RVP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-06-06,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0055,Orion Synthetic Bank Money Market Desk,529900SYNTHSSI000055,ORIOUS33XXX,US,ORION USD Money Market Nostro,ACCT-****-7055,ORIMMKT55,USD,Money Market,US,US,USD,PSETUS33XXX,DTC,GCUSUS33XXX,AGNTUS33XXX,,SEC-****-7055,CASH-****-8055,Fedwire,DVP,2025-01-01,2026-12-31,imported,ops-owner@example.com,Payments Operations,2026-07-07,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0056,Atlas Synthetic Securities Derivatives Desk,529900SYNTHSSI000056,ATLAGB2LXXX,GB,ATLAS GBP Derivatives Nostro,ACCT-****-7056,ATLDERI56,GBP,Derivatives,GB,GB,GBP,PSETGB2LXXX,CREST,GCUSGB2LXXX,AGNTGB2LXXX,INTRGB2LXXX,SEC-****-7056,CASH-****-8056,CHAPS,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-08-08,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,critical +RSI-0057,Nova Synthetic Treasury Trade Finance Desk,529900SYNTHSSI000057,NOVAFRPPXXX,FR,NOVA EUR Trade Finance Nostro,ACCT-****-7057,NOVTFIN57,EUR,Trade Finance,FR,FR,EUR,PSETFRPPXXX,EOC,GCUSGB2LXXX,AGNTFRPPXXX,,SEC-****-7057,CASH-****-8057,TARGET2S,FOP,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-09-09,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium +RSI-0058,Helios Synthetic Capital Treasury Desk,529900SYNTHSSI000058,HELIDEFFXXX,DE,HELIO EUR Treasury Nostro,ACCT-****-7058,HELTREA58,EUR,Treasury,DE,DE,EUR,PSETDEFFXXX,CBF,GCUSGB2LXXX,AGNTDEFFXXX,,SEC-****-7058,CASH-****-8058,TARGET2S,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-10-10,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0059,Meridian Synthetic Markets Futures Desk,529900SYNTHSSI000059,MERICHZZXXX,CH,MERID CHF Futures Nostro,ACCT-****-7059,MERFUTU59,CHF,Futures,CH,CH,CHF,PSETCHZZXXX,SIXSIS,GCUSGB2LXXX,AGNTCHZZXXX,,SEC-****-7059,CASH-****-8059,SIC,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-11-11,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,high +RSI-0060,Cirrus Synthetic Bank Collections Desk,529900SYNTHSSI000060,CIRRJPJTXXX,JP,CIRRU JPY Collections Nostro,ACCT-****-7060,CIRCOLL60,JPY,Collections,JP,JP,JPY,PSETJPJTXXX,JASDEC,GCUSJPJTXXX,AGNTJPJTXXX,INTRGB2LXXX,SEC-****-7060,CASH-****-8060,BOJ-NET,cash_only,2025-01-01,,imported,ops-owner@example.com,Payments Operations,2026-12-12,CSV_SYNTHETIC_SWIFTREF_SHAPE,medium,not_required,medium diff --git a/apps/ssi-control-tower/data/sample_swiftref_ssiplus_v3_synthetic.tsv b/apps/ssi-control-tower/data/sample_swiftref_ssiplus_v3_synthetic.tsv new file mode 100644 index 0000000..60ea71a --- /dev/null +++ b/apps/ssi-control-tower/data/sample_swiftref_ssiplus_v3_synthetic.tsv @@ -0,0 +1,61 @@ +MODIFICATION FLAG RECORD KEY BIC OWNER INSTITUTION NAME OWNER CITY OWNER ISO COUNTRY CODE OWNER ISO CURRENCY CODE ASSET CATEGORY BIC ACCOUNT HOLDING INSTITUTION INSTITUTION NAME ACCOUNT HOLDING INSTITUTION ISO COUNTRY CODE ACCOUNT HOLDING INSTITUTION ACCOUNT NBR WITH ACCOUNT HOLDING INSTITUTION PREFERRED ACCOUNT HOLDING INSTITUTION ACCOUNT HOLDING INSTITUTION TYPE GROUP KEY OWNER RECORD KEY BDP OWNER EID OWNER RECORD KEY BDP ACCOUNT HOLDING INSTITUTION EID ACCOUNT HOLDING INSTITUTION UPDATE DATE TRAFFIC FLAG TRAFFIC DATE START DATE STOP DATE FIELD A FIELD B FIELD C FIELD D +A SSI000000001 ORIOUS33XXX Orion Synthetic Bank New York New York US USD SECU HOLDUS33XXX Synthetic New York Correspondent SECU US ACCT-****-9001 P CORRESPONDENT GRP000000002 BDP000000001 1000001 BDP000500001 2000001 20260101 Y 20260315 20250101 +A SSI000000002 ATLAGB2LXXX Atlas Synthetic Securities London London GB GBP COPA HOLDGB2LXXX Synthetic London Correspondent COPA GB ACCT-****-9002 CORRESPONDENT GRP000000003 BDP000000002 1000002 BDP000500002 2000002 20260202 Y 20260415 20250101 +A SSI000000003 NOVAFRPPXXX Nova Synthetic Treasury Paris Paris FR EUR CASH HOLDGB2LXXX Synthetic London Correspondent CASH GB ACCT-****-9003 LOCAL CORRESPONDENT GRP000000004 BDP000000003 1000003 BDP000500003 2000003 20260303 Y 20260515 20250101 +A SSI000000004 HELIDEFFXXX Helios Synthetic Capital Frankfurt Frankfurt DE EUR FOEX HOLDDEFFXXX Synthetic Frankfurt Correspondent FOEX DE ACCT-****-9004 CORRESPONDENT GRP000000005 BDP000000004 1000004 BDP000500004 2000004 20260404 N 20260615 20250101 +A SSI000000005 MERICHZZXXX Meridian Synthetic Markets Zurich Zurich CH CHF MMKT HOLDCHZZXXX Synthetic Zurich Correspondent MMKT CH ACCT-****-9005 P CORRESPONDENT GRP000000006 BDP000000005 1000005 BDP000500005 2000005 20260505 Y 20260715 20250101 +A SSI000000006 CIRRJPJTXXX Cirrus Synthetic Bank Tokyo Tokyo JP JPY DERI HOLDGB2LXXX Synthetic London Correspondent DERI GB ACCT-****-9006 P LOCAL CORRESPONDENT GRP000000007 BDP000000006 1000006 BDP000500006 2000006 20260606 Y 20260815 20250101 +A SSI000000007 ORIOSGSGXXX Orion Synthetic Bank Singapore Singapore SG SGD TFIN HOLDSGSGXXX Synthetic Singapore Correspondent TFIN SG ACCT-****-9007 CORRESPONDENT GRP000000008 BDP000000007 1000007 BDP000500007 2000007 20260707 Y 20260915 20250101 +A SSI000000008 ATLAAU2SXXX Atlas Synthetic Securities Sydney Sydney AU AUD TREA HOLDAU2SXXX Synthetic Sydney Correspondent TREA AU ACCT-****-9008 CORRESPONDENT GRP000000009 BDP000000008 1000008 BDP000500008 2000008 20260808 N 20261015 20250101 +A SSI000000009 NOVACA8VXXX Nova Synthetic Treasury Toronto Toronto CA CAD FUTU HOLDGB2LXXX Synthetic London Correspondent FUTU GB ACCT-****-9009 LOCAL CORRESPONDENT GRP000000010 BDP000000009 1000009 BDP000500009 2000009 20260909 Y 20261115 20250101 +A SSI000000010 HELIHKHHXXX Helios Synthetic Capital Hong Kong Hong Kong HK HKD COLL HOLDHKHHXXX Synthetic Hong Kong Correspondent COLL HK ACCT-****-9010 P CORRESPONDENT GRP000000011 BDP000000010 1000010 BDP000500010 2000010 20261010 Y 20261215 20250101 +A SSI000000011 MERISESSXXX Meridian Synthetic Markets Stockholm Stockholm SE SEK SECU HOLDSESSXXX Synthetic Stockholm Correspondent SECU SE ACCT-****-9011 P CORRESPONDENT GRP000000012 BDP000000011 1000011 BDP000500011 2000011 20261111 Y 20260115 20250101 20261231 +A SSI000000012 CIRRNOKKXXX Cirrus Synthetic Bank Oslo Oslo NO NOK COPA HOLDGB2LXXX Synthetic London Correspondent COPA GB ACCT-****-9012 LOCAL CORRESPONDENT GRP000000013 BDP000000012 1000012 BDP000500012 2000012 20261212 N 20260215 20250101 +A SSI000000013 ORIODKKKXXX Orion Synthetic Bank Copenhagen Copenhagen DK DKK CASH HOLDDKKKXXX Synthetic Copenhagen Correspondent CASH DK ACCT-****-9013 CORRESPONDENT GRP000000014 BDP000000013 1000013 BDP000500013 2000013 20260113 Y 20260315 20250101 +A SSI000000014 ATLANL2AXXX Atlas Synthetic Securities Amsterdam Amsterdam NL EUR FOEX HOLDNL2AXXX Synthetic Amsterdam Correspondent FOEX NL ACCT-****-9014 CORRESPONDENT GRP000000015 BDP000000014 1000014 BDP000500014 2000014 20260214 Y 20260415 20250101 +A SSI000000015 NOVABEBBXXX Nova Synthetic Treasury Brussels Brussels BE EUR MMKT HOLDGB2LXXX Synthetic London Correspondent MMKT GB ACCT-****-9015 P LOCAL CORRESPONDENT GRP000000016 BDP000000015 1000015 BDP000500015 2000015 20260315 Y 20260515 20250101 +A SSI000000016 HELIITMMXXX Helios Synthetic Capital Milan Milan IT EUR DERI HOLDITMMXXX Synthetic Milan Correspondent DERI IT ACCT-****-9016 P CORRESPONDENT GRP000000017 BDP000000016 1000016 BDP000500016 2000016 20260416 N 20260615 20250101 +A SSI000000017 MERIESMMXXX Meridian Synthetic Markets Madrid Madrid ES EUR TFIN HOLDESMMXXX Synthetic Madrid Correspondent TFIN ES ACCT-****-9017 CORRESPONDENT GRP000000018 BDP000000017 1000017 BDP000500017 2000017 20260517 Y 20260715 20250101 +A SSI000000018 CIRRLULLXXX Cirrus Synthetic Bank Luxembourg Luxembourg LU EUR TREA HOLDGB2LXXX Synthetic London Correspondent TREA GB ACCT-****-9018 LOCAL CORRESPONDENT GRP000000001 BDP000000018 1000018 BDP000500018 2000018 20260618 Y 20260815 20250101 +A SSI000000019 ORIOUS33XXX Orion Synthetic Bank New York New York US USD FUTU HOLDUS33XXX Synthetic New York Correspondent FUTU US ACCT-****-9019 CORRESPONDENT GRP000000002 BDP000000019 1000019 BDP000500019 2000019 20260719 Y 20260915 20250101 +A SSI000000020 ATLAGB2LXXX Atlas Synthetic Securities London London GB GBP COLL HOLDGB2LXXX Synthetic London Correspondent COLL GB ACCT-****-9020 P CORRESPONDENT GRP000000003 BDP000000020 1000020 BDP000500020 2000020 20260820 N 20261015 20250101 +A SSI000000021 NOVAFRPPXXX Nova Synthetic Treasury Paris Paris FR EUR SECU HOLDGB2LXXX Synthetic London Correspondent SECU GB ACCT-****-9021 P LOCAL CORRESPONDENT GRP000000004 BDP000000021 1000021 BDP000500021 2000021 20260921 Y 20261115 20250101 +A SSI000000022 HELIDEFFXXX Helios Synthetic Capital Frankfurt Frankfurt DE EUR COPA HOLDDEFFXXX Synthetic Frankfurt Correspondent COPA DE ACCT-****-9022 CORRESPONDENT GRP000000005 BDP000000022 1000022 BDP000500022 2000022 20261022 Y 20261215 20250101 20261231 +A SSI000000023 MERICHZZXXX Meridian Synthetic Markets Zurich Zurich CH CHF CASH HOLDCHZZXXX Synthetic Zurich Correspondent CASH CH ACCT-****-9023 CORRESPONDENT GRP000000006 BDP000000023 1000023 BDP000500023 2000023 20261123 Y 20260115 20250101 +A SSI000000024 CIRRJPJTXXX Cirrus Synthetic Bank Tokyo Tokyo JP JPY FOEX HOLDGB2LXXX Synthetic London Correspondent FOEX GB ACCT-****-9024 LOCAL CORRESPONDENT GRP000000007 BDP000000024 1000024 BDP000500024 2000024 20261224 N 20260215 20250101 +A SSI000000025 ORIOSGSGXXX Orion Synthetic Bank Singapore Singapore SG SGD MMKT HOLDSGSGXXX Synthetic Singapore Correspondent MMKT SG ACCT-****-9025 P CORRESPONDENT GRP000000008 BDP000000025 1000025 BDP000500025 2000025 20260101 Y 20260315 20250101 +A SSI000000026 ATLAAU2SXXX Atlas Synthetic Securities Sydney Sydney AU AUD DERI HOLDAU2SXXX Synthetic Sydney Correspondent DERI AU ACCT-****-9026 P CORRESPONDENT GRP000000009 BDP000000026 1000026 BDP000500026 2000026 20260202 Y 20260415 20250101 +A SSI000000027 NOVACA8VXXX Nova Synthetic Treasury Toronto Toronto CA CAD TFIN HOLDGB2LXXX Synthetic London Correspondent TFIN GB ACCT-****-9027 LOCAL CORRESPONDENT GRP000000010 BDP000000027 1000027 BDP000500027 2000027 20260303 Y 20260515 20250101 +A SSI000000028 HELIHKHHXXX Helios Synthetic Capital Hong Kong Hong Kong HK HKD TREA HOLDHKHHXXX Synthetic Hong Kong Correspondent TREA HK ACCT-****-9028 CORRESPONDENT GRP000000011 BDP000000028 1000028 BDP000500028 2000028 20260404 N 20260615 20250101 +A SSI000000029 MERISESSXXX Meridian Synthetic Markets Stockholm Stockholm SE SEK FUTU HOLDSESSXXX Synthetic Stockholm Correspondent FUTU SE ACCT-****-9029 CORRESPONDENT GRP000000012 BDP000000029 1000029 BDP000500029 2000029 20260505 Y 20260715 20250101 +A SSI000000030 CIRRNOKKXXX Cirrus Synthetic Bank Oslo Oslo NO NOK COLL HOLDGB2LXXX Synthetic London Correspondent COLL GB ACCT-****-9030 P LOCAL CORRESPONDENT GRP000000013 BDP000000030 1000030 BDP000500030 2000030 20260606 Y 20260815 20250101 +A SSI000000031 ORIODKKKXXX Orion Synthetic Bank Copenhagen Copenhagen DK DKK SECU HOLDDKKKXXX Synthetic Copenhagen Correspondent SECU DK ACCT-****-9031 P CORRESPONDENT GRP000000014 BDP000000031 1000031 BDP000500031 2000031 20260707 Y 20260915 20250101 +A SSI000000032 ATLANL2AXXX Atlas Synthetic Securities Amsterdam Amsterdam NL EUR COPA HOLDNL2AXXX Synthetic Amsterdam Correspondent COPA NL ACCT-****-9032 CORRESPONDENT GRP000000015 BDP000000032 1000032 BDP000500032 2000032 20260808 N 20261015 20250101 +A SSI000000033 NOVABEBBXXX Nova Synthetic Treasury Brussels Brussels BE EUR CASH HOLDGB2LXXX Synthetic London Correspondent CASH GB ACCT-****-9033 LOCAL CORRESPONDENT GRP000000016 BDP000000033 1000033 BDP000500033 2000033 20260909 Y 20261115 20250101 20261231 +A SSI000000034 HELIITMMXXX Helios Synthetic Capital Milan Milan IT EUR FOEX HOLDITMMXXX Synthetic Milan Correspondent FOEX IT ACCT-****-9034 CORRESPONDENT GRP000000017 BDP000000034 1000034 BDP000500034 2000034 20261010 Y 20261215 20250101 +A SSI000000035 MERIESMMXXX Meridian Synthetic Markets Madrid Madrid ES EUR MMKT HOLDESMMXXX Synthetic Madrid Correspondent MMKT ES ACCT-****-9035 P CORRESPONDENT GRP000000018 BDP000000035 1000035 BDP000500035 2000035 20261111 Y 20260115 20250101 +A SSI000000036 CIRRLULLXXX Cirrus Synthetic Bank Luxembourg Luxembourg LU EUR DERI HOLDGB2LXXX Synthetic London Correspondent DERI GB ACCT-****-9036 P LOCAL CORRESPONDENT GRP000000001 BDP000000036 1000036 BDP000500036 2000036 20261212 N 20260215 20250101 +A SSI000000037 ORIOUS33XXX Orion Synthetic Bank New York New York US USD TFIN HOLDUS33XXX Synthetic New York Correspondent TFIN US ACCT-****-9037 CORRESPONDENT GRP000000002 BDP000000037 1000037 BDP000500037 2000037 20260113 Y 20260315 20250101 +A SSI000000038 ATLAGB2LXXX Atlas Synthetic Securities London London GB GBP TREA HOLDGB2LXXX Synthetic London Correspondent TREA GB ACCT-****-9038 CORRESPONDENT GRP000000003 BDP000000038 1000038 BDP000500038 2000038 20260214 Y 20260415 20250101 +A SSI000000039 NOVAFRPPXXX Nova Synthetic Treasury Paris Paris FR EUR FUTU HOLDGB2LXXX Synthetic London Correspondent FUTU GB ACCT-****-9039 LOCAL CORRESPONDENT GRP000000004 BDP000000039 1000039 BDP000500039 2000039 20260315 Y 20260515 20250101 +A SSI000000040 HELIDEFFXXX Helios Synthetic Capital Frankfurt Frankfurt DE EUR COLL HOLDDEFFXXX Synthetic Frankfurt Correspondent COLL DE ACCT-****-9040 P CORRESPONDENT GRP000000005 BDP000000040 1000040 BDP000500040 2000040 20260416 N 20260615 20250101 +A SSI000000041 MERICHZZXXX Meridian Synthetic Markets Zurich Zurich CH CHF SECU HOLDCHZZXXX Synthetic Zurich Correspondent SECU CH ACCT-****-9041 P CORRESPONDENT GRP000000006 BDP000000041 1000041 BDP000500041 2000041 20260517 Y 20260715 20250101 +A SSI000000042 CIRRJPJTXXX Cirrus Synthetic Bank Tokyo Tokyo JP JPY COPA HOLDGB2LXXX Synthetic London Correspondent COPA GB ACCT-****-9042 LOCAL CORRESPONDENT GRP000000007 BDP000000042 1000042 BDP000500042 2000042 20260618 Y 20260815 20250101 +A SSI000000043 ORIOSGSGXXX Orion Synthetic Bank Singapore Singapore SG SGD CASH HOLDSGSGXXX Synthetic Singapore Correspondent CASH SG ACCT-****-9043 CORRESPONDENT GRP000000008 BDP000000043 1000043 BDP000500043 2000043 20260719 Y 20260915 20250101 +A SSI000000044 ATLAAU2SXXX Atlas Synthetic Securities Sydney Sydney AU AUD FOEX HOLDAU2SXXX Synthetic Sydney Correspondent FOEX AU ACCT-****-9044 CORRESPONDENT GRP000000009 BDP000000044 1000044 BDP000500044 2000044 20260820 N 20261015 20250101 20261231 +A SSI000000045 NOVACA8VXXX Nova Synthetic Treasury Toronto Toronto CA CAD MMKT HOLDGB2LXXX Synthetic London Correspondent MMKT GB ACCT-****-9045 P LOCAL CORRESPONDENT GRP000000010 BDP000000045 1000045 BDP000500045 2000045 20260921 Y 20261115 20250101 +A SSI000000046 HELIHKHHXXX Helios Synthetic Capital Hong Kong Hong Kong HK HKD DERI HOLDHKHHXXX Synthetic Hong Kong Correspondent DERI HK ACCT-****-9046 P CORRESPONDENT GRP000000011 BDP000000046 1000046 BDP000500046 2000046 20261022 Y 20261215 20250101 +A SSI000000047 MERISESSXXX Meridian Synthetic Markets Stockholm Stockholm SE SEK TFIN HOLDSESSXXX Synthetic Stockholm Correspondent TFIN SE ACCT-****-9047 CORRESPONDENT GRP000000012 BDP000000047 1000047 BDP000500047 2000047 20261123 Y 20260115 20250101 +A SSI000000048 CIRRNOKKXXX Cirrus Synthetic Bank Oslo Oslo NO NOK TREA HOLDGB2LXXX Synthetic London Correspondent TREA GB ACCT-****-9048 LOCAL CORRESPONDENT GRP000000013 BDP000000048 1000048 BDP000500048 2000048 20261224 N 20260215 20250101 +A SSI000000049 ORIODKKKXXX Orion Synthetic Bank Copenhagen Copenhagen DK DKK FUTU HOLDDKKKXXX Synthetic Copenhagen Correspondent FUTU DK ACCT-****-9049 CORRESPONDENT GRP000000014 BDP000000049 1000049 BDP000500049 2000049 20260101 Y 20260315 20250101 +A SSI000000050 ATLANL2AXXX Atlas Synthetic Securities Amsterdam Amsterdam NL EUR COLL HOLDNL2AXXX Synthetic Amsterdam Correspondent COLL NL ACCT-****-9050 P CORRESPONDENT GRP000000015 BDP000000050 1000050 BDP000500050 2000050 20260202 Y 20260415 20250101 +A SSI000000051 NOVABEBBXXX Nova Synthetic Treasury Brussels Brussels BE EUR SECU HOLDGB2LXXX Synthetic London Correspondent SECU GB ACCT-****-9051 P LOCAL CORRESPONDENT GRP000000016 BDP000000051 1000051 BDP000500051 2000051 20260303 Y 20260515 20250101 +A SSI000000052 HELIITMMXXX Helios Synthetic Capital Milan Milan IT EUR COPA HOLDITMMXXX Synthetic Milan Correspondent COPA IT ACCT-****-9052 CORRESPONDENT GRP000000017 BDP000000052 1000052 BDP000500052 2000052 20260404 N 20260615 20250101 +A SSI000000053 MERIESMMXXX Meridian Synthetic Markets Madrid Madrid ES EUR CASH HOLDESMMXXX Synthetic Madrid Correspondent CASH ES ACCT-****-9053 CORRESPONDENT GRP000000018 BDP000000053 1000053 BDP000500053 2000053 20260505 Y 20260715 20250101 +A SSI000000054 CIRRLULLXXX Cirrus Synthetic Bank Luxembourg Luxembourg LU EUR FOEX HOLDGB2LXXX Synthetic London Correspondent FOEX GB ACCT-****-9054 LOCAL CORRESPONDENT GRP000000001 BDP000000054 1000054 BDP000500054 2000054 20260606 Y 20260815 20250101 +A SSI000000055 ORIOUS33XXX Orion Synthetic Bank New York New York US USD MMKT HOLDUS33XXX Synthetic New York Correspondent MMKT US ACCT-****-9055 P CORRESPONDENT GRP000000002 BDP000000055 1000055 BDP000500055 2000055 20260707 Y 20260915 20250101 20261231 +A SSI000000056 ATLAGB2LXXX Atlas Synthetic Securities London London GB GBP DERI HOLDGB2LXXX Synthetic London Correspondent DERI GB ACCT-****-9056 P CORRESPONDENT GRP000000003 BDP000000056 1000056 BDP000500056 2000056 20260808 N 20261015 20250101 +A SSI000000057 NOVAFRPPXXX Nova Synthetic Treasury Paris Paris FR EUR TFIN HOLDGB2LXXX Synthetic London Correspondent TFIN GB ACCT-****-9057 LOCAL CORRESPONDENT GRP000000004 BDP000000057 1000057 BDP000500057 2000057 20260909 Y 20261115 20250101 +A SSI000000058 HELIDEFFXXX Helios Synthetic Capital Frankfurt Frankfurt DE EUR TREA HOLDDEFFXXX Synthetic Frankfurt Correspondent TREA DE ACCT-****-9058 CORRESPONDENT GRP000000005 BDP000000058 1000058 BDP000500058 2000058 20261010 Y 20261215 20250101 +A SSI000000059 MERICHZZXXX Meridian Synthetic Markets Zurich Zurich CH CHF FUTU HOLDCHZZXXX Synthetic Zurich Correspondent FUTU CH ACCT-****-9059 CORRESPONDENT GRP000000006 BDP000000059 1000059 BDP000500059 2000059 20261111 Y 20260115 20250101 +A SSI000000060 CIRRJPJTXXX Cirrus Synthetic Bank Tokyo Tokyo JP JPY COLL HOLDGB2LXXX Synthetic London Correspondent COLL GB ACCT-****-9060 P LOCAL CORRESPONDENT GRP000000007 BDP000000060 1000060 BDP000500060 2000060 20261212 N 20260215 20250101 diff --git a/apps/ssi-control-tower/data/seed_ssi.csv b/apps/ssi-control-tower/data/seed_ssi.csv new file mode 100644 index 0000000..f60def9 --- /dev/null +++ b/apps/ssi-control-tower/data/seed_ssi.csv @@ -0,0 +1,26 @@ +SSI Reference,Entity Name,LEI,BIC,Jurisdiction,Account Name,Account Number,Fund Code,Base Currency,Asset Class,Market,Country,Currency,PSET BIC,Depository,Global Custodian BIC,Local Agent BIC,Intermediary BIC,Securities Account,Cash Account,Payment System,Settlement Method,Effective From,Effective To,Status,Owner Email,Owner Team,Last Confirmed,Source System,Source Trust Level,Approval Status,Risk Rating +SSI-001,Northstar Global Fund,529900SYNTH000000001,NSTAGB2L,GB,Northstar Global Fund Main,SEC-****-1042,NSG,EUR,Equity,FR,FR,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2024-01-10,CSV_DEMO,medium,not_required,critical +SSI-002,Northstar Global Fund,529900SYNTH000000001,NSTAGB2L,GB,Northstar Global Fund Main,SEC-****-1042,NSG,EUR,Equity,FR,FR,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,BADBIC,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-01-15,CSV_DEMO,medium,not_required,critical +SSI-003,Atlas Income Fund,529900SYNTH000000002,NSTAGB2L,GB,Atlas Income Fund Main,SEC-****-1042,AIF,USD,Fixed Income,US,US,USD,NSTAGB2L,DTC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-01-20,CSV_DEMO,medium,not_required,medium +SSI-004,Atlas Income Fund,529900SYNTH000000002,NSTAGB2L,GB,Atlas Income Fund Main,SEC-****-1042,AIF,USD,Fixed Income,US,US,USD,NSTAGB2L,DTC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-01-20,CSV_DEMO,medium,not_required,medium +SSI-005,Atlas Income Fund,529900SYNTH000000002,NSTAGB2L,GB,Atlas Income Fund Main,SEC-****-1042,AIF,USD,Fixed Income,US,US,USD,NSTAGB2L,DTC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-01-20,CSV_DEMO,medium,not_required,medium +SSI-006,Orion Macro Fund,529900SYNTH000000003,NSTAGB2L,GB,Orion Macro Fund Main,SEC-****-1042,OMF,EUR,Equity,DE,DE,EUR,NSTAGB2L,CBF,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2024-03-01,CSV_DEMO,medium,not_required,medium +SSI-007,Helios Equity Trust,529900SYNTH000000004,NSTAGB2L,GB,Helios Equity Trust Main,SEC-****-1042,HET,USD,Equity,US,US,USD,NSTAGB2L,DTC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2024-04-01,CSV_DEMO,medium,not_required,medium +SSI-008,Ironwood Credit Partners,529900SYNTH000000005,NSTAGB2L,GB,Ironwood Credit Partners Main,SEC-****-1042,ICP,CHF,Equity,CH,CH,CHF,NSTAGB2L,SIXSIS,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2024-05-01,CSV_DEMO,medium,not_required,medium +SSI-009,Meridian Balanced Fund,529900SYNTH000000006,NSTAGB2L,GB,Meridian Balanced Fund Main,SEC-****-1042,MBF,EUR,Equity,IT,IT,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,,Reference Data,2026-01-15,CSV_DEMO,medium,not_required,medium +SSI-010,Solace Treasury Fund,529900SYNTH000000007,NSTAGB2L,GB,Solace Treasury Fund Main,SEC-****-1042,STF,EUR,Equity,ES,ES,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,,Reference Data,2026-01-15,CSV_DEMO,medium,not_required,medium +SSI-011,Orion Macro Fund,529900SYNTH000000003,NSTAGB2L,GB,Orion Macro Fund Main,SEC-****-1042,OMF,EUR,Equity,LU,LU,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,,Reference Data,2026-01-15,CSV_DEMO,medium,not_required,medium +SSI-012,Helios Equity Trust,529900SYNTH000000004,NSTAGB2L,GB,Helios Equity Trust Main,SEC-****-1042,HET,EUR,Equity,LU,ZZ,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,imported,ops-owner@example.com,Reference Data,2026-01-15,CSV_DEMO,medium,not_required,medium +SSI-013,Ironwood Credit Partners,529900SYNTH000000005,NSTAGB2L,GB,Ironwood Credit Partners Main,SEC-****-1042,ICP,GBP,Equity,GB,GB,GBP,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2026-02-01,2026-01-01,imported,ops-owner@example.com,Reference Data,2026-01-15,CSV_DEMO,medium,not_required,medium +SSI-014,Meridian Balanced Fund,529900SYNTH000000006,NSTAGB2L,GB,Meridian Balanced Fund Main,SEC-****-1042,MBF,EUR,Equity,FR,FR,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,pending_approval,ops-owner@example.com,Reference Data,2026-01-15,CSV_DEMO,medium,pending,medium +SSI-015,Solace Treasury Fund,529900SYNTH000000007,NSTAGB2L,GB,Solace Treasury Fund Main,SEC-****-1042,STF,USD,Equity,US,US,USD,SHORT,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,validation_failed,ops-owner@example.com,Reference Data,2026-01-15,CSV_DEMO,medium,not_required,medium +SSI-016,Northstar Global Fund,529900SYNTH000000001,NSTAGB2L,GB,Northstar Global Fund Clean SSI-016,SEC-****-1042,NSG,EUR,Equity,DE,DE,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,approved,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium +SSI-017,Atlas Income Fund,529900SYNTH000000002,NSTAGB2L,GB,Atlas Income Fund Clean SSI-017,SEC-****-1042,AIF,EUR,Equity,IT,IT,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,published,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium +SSI-018,Orion Macro Fund,529900SYNTH000000003,NSTAGB2L,GB,Orion Macro Fund Clean SSI-018,SEC-****-1042,OMF,EUR,Equity,ES,ES,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,approved,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium +SSI-019,Helios Equity Trust,529900SYNTH000000004,NSTAGB2L,GB,Helios Equity Trust Clean SSI-019,SEC-****-1042,HET,USD,Equity,US,US,USD,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,approved,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium +SSI-020,Ironwood Credit Partners,529900SYNTH000000005,NSTAGB2L,GB,Ironwood Credit Partners Clean SSI-020,SEC-****-1042,ICP,CHF,Equity,CH,CH,CHF,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,published,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium +SSI-021,Meridian Balanced Fund,529900SYNTH000000006,NSTAGB2L,GB,Meridian Balanced Fund Clean SSI-021,SEC-****-1042,MBF,EUR,Equity,LU,LU,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,approved,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium +SSI-022,Solace Treasury Fund,529900SYNTH000000007,NSTAGB2L,GB,Solace Treasury Fund Clean SSI-022,SEC-****-1042,STF,GBP,Equity,GB,GB,GBP,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,published,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium +SSI-023,Northstar Global Fund,529900SYNTH000000001,NSTAGB2L,GB,Northstar Global Fund Clean SSI-023,SEC-****-1042,NSG,EUR,Equity,FR,FR,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,approved,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium +SSI-024,Atlas Income Fund,529900SYNTH000000002,NSTAGB2L,GB,Atlas Income Fund Clean SSI-024,SEC-****-1042,AIF,EUR,Equity,DE,DE,EUR,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,published,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium +SSI-025,Orion Macro Fund,529900SYNTH000000003,NSTAGB2L,GB,Orion Macro Fund Clean SSI-025,SEC-****-1042,OMF,USD,Equity,US,US,USD,NSTAGB2L,EOC,CUSTGB2LXXX,AGNTFRPPXXX,,SEC-****-1042,CASH-****-8821,TARGET2S,DVP,2025-01-01,,approved,ops-owner@example.com,Reference Data,2026-02-01,CSV_DEMO,medium,approved,medium diff --git a/apps/ssi-control-tower/data/seed_users.yaml b/apps/ssi-control-tower/data/seed_users.yaml new file mode 100644 index 0000000..d16ea88 --- /dev/null +++ b/apps/ssi-control-tower/data/seed_users.yaml @@ -0,0 +1,18 @@ +- email: analyst@example.com + role: analyst + display_name: Analyst +- email: ops-owner@example.com + role: owner + display_name: Ops Owner +- email: refdata@example.com + role: owner + display_name: Reference Data Owner +- email: approver@example.com + role: approver + display_name: Approver +- email: risk@example.com + role: risk + display_name: Risk Reviewer +- email: admin@example.com + role: admin + display_name: Admin diff --git a/apps/ssi-control-tower/docker-compose.yml b/apps/ssi-control-tower/docker-compose.yml new file mode 100644 index 0000000..8b8c6d5 --- /dev/null +++ b/apps/ssi-control-tower/docker-compose.yml @@ -0,0 +1,10 @@ +services: + ssi-control-tower: + build: . + ports: + - "8000:8000" + environment: + SSI_DB_PATH: data/ssi.db + SSI_EXPORT_DIR: data/exports + volumes: + - ./data:/app/data diff --git a/apps/ssi-control-tower/docs/HANDOFF_2026-05-06_ssi_control_tower_v2_ui.md b/apps/ssi-control-tower/docs/HANDOFF_2026-05-06_ssi_control_tower_v2_ui.md new file mode 100644 index 0000000..0719b85 --- /dev/null +++ b/apps/ssi-control-tower/docs/HANDOFF_2026-05-06_ssi_control_tower_v2_ui.md @@ -0,0 +1,64 @@ +# Handoff — SSI Control Tower V2 source truth + Raafet-style UI + +Date: 2026-05-06 21:04 +08 +Repo: `/Users/Shared/AgentWork/repos/ssi-control-tower` +Branch: `feat/v2-ssiplus-source-truth` +Current HEAD before this handoff doc: `6ba5c9e` — `[verified] feat: align SSI UI with Raafet design` +Remote: none configured in this local checkout + +## What changed + +This branch now contains three verified commits on top of the baseline prototype: + +1. `4348bf0` — `[verified] feat: add SSI Plus V2 source truth slice` + - Adds a SwiftRef `SSIPLUS_V3`-shaped synthetic TSV parser/importer. + - Persists source lineage, source-file stats, domain `ssi_instructions`, source rejects, preferred-record conflict aggregates, and privacy-safe V2 dashboard controls. + - Keeps V1 synthetic CSV flow working. + +2. `12151c8` — `[verified] refactor: clean startup and lint debt` + - Removes obsolete startup/seed/lint debt after the V2 slice. + - Keeps the suite green. + +3. `6ba5c9e` — `[verified] feat: align SSI UI with Raafet design` + - Restyles the Jinja/Tailwind UI toward the `raafetchoukri.com` light fintech-consultant look. + - Adds off-white shell, dark navy headings, cyan accents, rounded white cards, subtle borders/shadows, polished desktop and mobile navigation. + - Covers home dashboard, Imports, SSIs, SSI detail, Exceptions, Approvals, Audit, and Rules. + - Adds design-contract tests in `tests/test_raafet_style_web.py`, including a mobile navigation regression test. + +## Verification completed + +Latest verification after the UI/mobile navigation fix: + +- `.venv/bin/python -m pytest tests/test_raafet_style_web.py -q` → `3 passed` +- `ruff check .` → `All checks passed!` +- `make test` → `44 passed` +- `git diff --check` → passed +- Static scan over tracked diff + untracked bundle → passed for obvious hardcoded secrets, shell injection, `eval`/`exec`, `pickle`, and SQL string-format injection patterns +- Browser visual QA: + - `/` home dashboard: matched target light/off-white fintech consultant style with navy headings, cyan accents, rounded white cards, subtle borders/shadows, and generous spacing + - `/imports`: shared light shell, no dark-dashboard remnants, no broken layout + - `/ssis`: shared light shell, readable table, no dark-dashboard remnants, no broken layout + - Browser console: no JS errors; only the expected Tailwind CDN production warning and app ready log +- Codex read-only implementation review after the mobile navigation fix: `APPROVE` + +## Important constraints + +- Public repo posture remains synthetic-only. +- No raw SSI/private values were added. +- No production SWIFTRef archive content was committed. +- No external API/LLM calls were introduced. +- UI remains Jinja/Tailwind CDN/HTMX prototype scope; no React/Next/build pipeline was added. +- There is no remote configured yet, so nothing was pushed or PR'd from this repo. + +## Fresh-session opener + +```text +Continue SSI Control Tower from `/Users/Shared/AgentWork/repos/ssi-control-tower` on branch `feat/v2-ssiplus-source-truth`. Read `docs/HANDOFF_2026-05-06_ssi_control_tower_v2_ui.md`, then verify `git status --short --branch` and `git log --oneline --decorate --max-count=6`. The branch contains V2 SSI Plus source-truth support plus a Raafet-style Jinja/Tailwind UI polish. Latest verified product commit before the handoff doc is `6ba5c9e`; run `make test` before new changes. No remote is configured yet, so next decision is whether to create/push a GitHub repo/branch/PR, or continue local prototype polish. +``` + +## Suggested next gates + +1. Decide repo publication target: create/push a GitHub repo or keep as local prototype. +2. If publishing, add remote, push `feat/v2-ssiplus-source-truth`, and open a PR from the feature branch once the branch has a public target. +3. Optional UI follow-up: replace Tailwind CDN with a proper build only if this graduates beyond prototype/demo. +4. Optional product follow-up: add deeper SSI source-file reconciliation and conflict drill-down views using the V2 aggregates already present. diff --git a/apps/ssi-control-tower/docs/hybrid_unified_ssi_instruction_flow_implementation_plan.md b/apps/ssi-control-tower/docs/hybrid_unified_ssi_instruction_flow_implementation_plan.md new file mode 100644 index 0000000..d01c33a --- /dev/null +++ b/apps/ssi-control-tower/docs/hybrid_unified_ssi_instruction_flow_implementation_plan.md @@ -0,0 +1,1237 @@ +# Hybrid Unified SSI Instruction Flow Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Converge SSI Control Tower from two parallel lanes into one hybrid source-to-governance flow where CSV demo files and SSI Plus V3-shaped files both create source lineage, canonical `SsiInstruction` records, validation outcomes, exceptions, approvals, audit evidence, readiness controls, and approved exports. + +**Architecture:** Keep source-specific adapters at the edge, then move all operational governance to the canonical `SsiInstruction` domain layer. Preserve V1 `SsiRecord` behavior as a compatibility/read-model path while adding additive instruction-level services, APIs, UI sections, and tests. Avoid destructive schema changes until the unified instruction flow is proven. + +**Tech Stack:** Python 3.11, FastAPI, Pydantic v2, SQLAlchemy 2.x, SQLite via `Base.metadata.create_all`, Jinja2 server rendering, Tailwind CDN, pytest, ruff. + +**Repository placement decision:** SSI Control Tower should not remain a separate product repo long-term. Fold it into the Payment Intelligence suite repo at `/Users/Shared/AgentWork/repos/payment-intelligence-modules` as its own self-contained module, recommended path `apps/ssi-control-tower/`. Keep its Python app, `pyproject.toml`, `Makefile`, tests, docs, and generated-data ignores scoped to that directory. Do not convert it into the existing browser-only React module, and do not merge Python dependencies into the root Vite app unless Raf explicitly approves that later. + +**Implementation-root convention:** Phase -1 decides the working root for all later tasks. If Phase -1 is executed first, every relative path shown below, for example `app/models.py` or `tests/test_instruction_validation.py`, means `payment-intelligence-modules/apps/ssi-control-tower/app/models.py` or `payment-intelligence-modules/apps/ssi-control-tower/tests/test_instruction_validation.py`, and all Python commands run from `/Users/Shared/AgentWork/repos/payment-intelligence-modules/apps/ssi-control-tower`. If Raf explicitly chooses to defer the fold, the same relative paths run from `/Users/Shared/AgentWork/repos/ssi-control-tower`. Recommended default is fold first, so later implementation should happen inside the Payment Intelligence repo. + +**Scope convention:** This plan contains the full roadmap, but each implementation branch must declare its selected phase range before coding. The recommended first branch is Phase -1 through Phase 2 only: fold the module, unify source ingestion, and add instruction validation/exceptions. Phases 3-5 are planned follow-up scope unless Raf explicitly selects full additive unified flow now. + +--- + +## Non-goals + +- Do not remove `SsiRecord` or existing V1 APIs in this branch. +- Do not introduce Alembic/PostgreSQL yet. +- Do not replace the Raafet-style Jinja/Tailwind UI with React/Next/build tooling. +- Do not keep SSI Control Tower as a separate standalone repo after the repository-fold step is approved. +- Do not absorb SSI Control Tower into the existing static browser-only React suite runtime; it remains its own backend app/module inside the Payment Intelligence repo. +- Do not ingest or commit private SSI data. +- Do not introduce external API calls or LLM calls inside the application. +- Do not expose raw account numbers, source record keys, BDP keys, EIDs, institution names, city values, or raw source rows in rejects, audit payloads, APIs, UI, docs, tests, or prompts. + +## Privacy invariants + +Every implementation task that touches ingestion, rejects, audit, APIs, UI, or exports must include tests proving: + +1. Account values are masked before persistence into canonical instruction outputs. +2. Source rejects expose row numbers, reject codes, and privacy-safe messages only. +3. API responses and dashboard payloads do not include raw source record keys, BDP keys, EIDs, institution names, city values, or raw private rows. +4. Audit payloads record aggregate counts and field names, not raw source values. +5. Public fixtures remain synthetic-only. +6. Any authorized private smoke test must use a throwaway DB and print aggregate counts only. + +## Acceptance criteria + +### Always required, regardless of selected scope + +- Existing V1 tests continue to pass unchanged. +- Existing V2 SSI Plus parser/ingestion/dashboard tests continue to pass unchanged. +- Repository-fold plan is explicit: SSI Control Tower moves into `payment-intelligence-modules/apps/ssi-control-tower/` as a self-contained app/module, not a git submodule and not a root React route conversion. +- No selected task uses destructive SQLite schema mutation. Because `Base.metadata.create_all` does not alter existing tables, required new fields must be represented by new additive tables unless a task proves the table is new-only. +- `make test`, `ruff check .`, `git diff --check`, static security scan, and Codex final diff review all pass before commit. + +### Recommended first branch acceptance: Phase -1 through Phase 2 + +- SSI Control Tower is folded into Payment Intelligence Modules under `apps/ssi-control-tower/` and remains runnable/testable there. +- CSV demo imports create both legacy `SsiRecord` records and canonical `SsiInstruction` records with source lineage. +- SSI Plus imports use the same canonical lineage/upsert path as CSV imports. +- Instruction-level validation creates instruction-level validation results and instruction-level exceptions. +- The API can expose a unified operational control queue across V1 compatibility records and canonical instructions without breaking old exception endpoints. +- Approvals, instruction export, and full UI polish remain explicitly out of scope for this first branch unless Raf promotes the scope. + +### Full-flow branch acceptance: Phase -1 through Phase 5, only if explicitly selected + +- Instruction-level critical changes require four-eyes approval and block self-approval. +- Readiness/dashboard/export have additive instruction-level endpoints while old V1 endpoints remain available. +- The UI can show one operational control queue across V1 compatibility records and canonical instructions without breaking old pages. + +--- + +## Phase -1: Repository fold into Payment Intelligence suite + +### Task -1.1: Plan and execute the repository fold as a self-contained module + +**Objective:** Move SSI Control Tower under the Payment Intelligence suite repo while keeping it operationally independent. + +**Files:** +- Source repo: `/Users/Shared/AgentWork/repos/ssi-control-tower/**` +- Target repo: `/Users/Shared/AgentWork/repos/payment-intelligence-modules` +- Target path: `apps/ssi-control-tower/` +- Modify target repo docs: `README.md`, `ROADMAP.md`, `HANDOFF.md`, `.gitignore` if needed. +- Do not modify root Vite `src/` route table for this task. + +**Step 1: Write fold checklist before copying** + +Create or update target-repo planning doc: + +- `docs/ssi-control-tower-fold-plan.md` if `docs/` exists, otherwise `apps/ssi-control-tower/HANDOFF.md` after copy. + +Checklist assertions: + +- SSI Control Tower is copied as normal files, not as a git submodule. +- Nested `.git/` is not copied. +- Generated DB/export/cache/venv files are not copied. +- Python app remains runnable from `apps/ssi-control-tower/` with its own `Makefile` and `pyproject.toml`. +- Root Payment Intelligence browser-only privacy audit remains scoped to `src/` and is not weakened. +- Root React app remains browser-only; SSI Control Tower is documented as a separate backend module inside the repo. + +**Step 2: Run pre-copy inventory** + +Run: + +```bash +cd /Users/Shared/AgentWork/repos/ssi-control-tower +git status --short --branch +git ls-files > /tmp/ssi_control_tower_tracked_files.txt +``` + +Expected: + +- Source worktree is clean except this plan doc if the plan is still uncommitted. +- Inventory excludes `.venv`, `data/ssi.db`, exports, caches, and `.git/`. + +**Step 3: Copy only tracked/source files into target path** + +From the target repo branch chosen for the fold, copy with a script that reads the tracked-file inventory and writes into `apps/ssi-control-tower/`. + +Expected: + +- `apps/ssi-control-tower/app/main.py` exists. +- `apps/ssi-control-tower/pyproject.toml` exists. +- `apps/ssi-control-tower/Makefile` exists. +- `apps/ssi-control-tower/.git` does not exist. + +**Step 4: Run target-repo scoped verification** + +Run: + +```bash +cd /Users/Shared/AgentWork/repos/payment-intelligence-modules +pnpm verify +cd apps/ssi-control-tower +make test +ruff check . +git diff --check +``` + +Expected: + +- Existing Payment Intelligence browser suite still passes. +- SSI Control Tower tests still pass from its nested app directory. +- Diff check passes. + +**Step 5: Commit fold in target repo** + +Commit message: + +```bash +git add apps/ssi-control-tower README.md ROADMAP.md HANDOFF.md .gitignore +git commit -m "feat: fold SSI Control Tower into payment intelligence suite" +``` + +**Step 6: Stop using the standalone repo for product work** + +After the target repo commit is verified, treat standalone `/Users/Shared/AgentWork/repos/ssi-control-tower` as historical source only unless Raf asks for archival cleanup. + +--- + +## Phase 0: Preflight and current-state guardrails + +### Task 0.1: Capture baseline and protect current behavior + +**Objective:** Prove the selected implementation root is clean and establish the safety gates for all later tasks. + +**Files:** +- Read from selected implementation root: `README.md` +- Read from selected implementation root if present: `docs/HANDOFF_2026-05-06_ssi_control_tower_v2_ui.md` +- Read: `app/services/ingestion.py` +- Read: `app/services/ssiplus_v3.py` +- Read: `app/models.py` +- Read: `tests/conftest.py` +- No production edits. + +**Step 1: Run baseline status and tests** + +Run from the selected implementation root. Recommended after Phase -1: + +```bash +cd /Users/Shared/AgentWork/repos/payment-intelligence-modules/apps/ssi-control-tower +git status --short --branch +make test +ruff check . +git diff --check +``` + +If Raf explicitly defers Phase -1, run the same commands from `/Users/Shared/AgentWork/repos/ssi-control-tower`. + +Expected: + +- If fold-first was selected, the enclosing repo branch is the Payment Intelligence feature branch for the fold/hybrid work. +- If fold is deferred, branch is `feat/v2-ssiplus-source-truth` in the standalone repo. +- Working tree is clean except intentional plan/docs changes before implementation. +- Test suite passes. +- Ruff passes. +- Diff check passes. + +**Step 2: Add a baseline note only if needed** + +If any pre-existing failure appears, stop and record it in the eventual handoff before changing code. + +**Commit:** none. + +--- + +### Task 0.2: Add schema compatibility gate for SQLite `create_all` + +**Objective:** Prevent implementation tasks from silently depending on SQLite column additions that `Base.metadata.create_all` cannot apply to existing tables. + +**Files:** +- Read: `app/models.py` +- Read: `app/db.py` +- Read: `tests/conftest.py` +- Optional test if modifying models in this task: `tests/test_schema_compatibility.py` +- No production edits unless the selected task already needs a new additive table. + +**Step 1: Audit model-change strategy before coding** + +For every later task that says `Modify: app/models.py`, classify the intended schema change as one of: + +- **New table only:** safe under `Base.metadata.create_all` for fresh and existing SQLite DBs. +- **New relationship on Python model only:** safe if it does not require a new column on an existing table. +- **New column on existing table:** blocked for this branch unless replaced by a new additive table or an explicit migration strategy is approved. +- **Changed type/nullability/default on existing column:** blocked without migrations. + +**Step 2: Add the gate to every model-touching task before implementation** + +Before Claude Code implements any task that touches `app/models.py`, require the implementer prompt to include: + +```text +Schema gate: this task may add new tables, relationships, and indexes only. It may not add columns to existing SQLite tables because create_all will not migrate live tables. If the task seems to need a new field on an existing model, stop and propose a new additive table instead. +``` + +**Step 3: Verify additive schema behavior when new tables are introduced** + +For each new-table task, add or run a regression that simulates an existing DB created before the new table and then calls `init_db()`/`Base.metadata.create_all(...)` to prove the new table appears without requiring column migration. + +Suggested command for tasks with new tables: + +```bash +.venv/bin/python -m pytest tests/test_schema_compatibility.py -q +``` + +Expected: + +- Existing-table schemas are not mutated. +- New additive tables are created. +- Existing V1/V2 seed and import tests still pass. + +**Commit:** none for the gate itself unless a schema compatibility test is added as part of the first model-touching task. + +--- + +## Phase 1: Unified source adapter and lineage path + +### Task 1.1: Add canonical source adapter contracts + +**Objective:** Define a source-neutral adapter contract that both CSV and SSI Plus parsers can produce. + +**Files:** +- Create: `app/services/source_adapters.py` +- Test: `tests/test_source_adapters_contract.py` + +**Step 1: Write failing tests** + +Test cases: + +- `CanonicalInstructionCandidate` requires source system, source schema, row number, source row fingerprint, owner BIC, currency, asset category, account-holder BIC, masked account number, preferred flag, lifecycle dates, and privacy-safe metadata. +- `CanonicalSourceReject` exposes only row number, reject code, and reject message. +- `assert_no_sensitive_instruction_payload(candidate)` helper fails if raw-looking account numbers appear. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_source_adapters_contract.py -q +``` + +Expected RED: + +- Fails because `app.services.source_adapters` does not exist. + +**Step 2: Implement minimal contract** + +Create frozen dataclasses or Pydantic-free service dataclasses: + +- `CanonicalInstructionCandidate` +- `CanonicalSourceReject` +- `SourceAdapterResult` +- `mask_or_reject_account_number(...)` wrapper if useful, reusing `mask_account_number(...)` from `ssiplus_v3.py` where possible. + +Do not add DB writes here. + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_source_adapters_contract.py -q +make test +ruff check . +``` + +Expected: + +- New tests pass. +- Existing suite passes. +- Ruff passes. + +**Commit:** + +```bash +git add app/services/source_adapters.py tests/test_source_adapters_contract.py +git commit -m "feat: add canonical SSI source adapter contract" +``` + +--- + +### Task 1.2: Implement CSV-to-canonical-instruction adapter + +**Objective:** Convert current V1 CSV rows into canonical instruction candidates while preserving existing CSV-to-`SsiRecord` import behavior. + +**Files:** +- Modify: `app/services/source_adapters.py` +- Test: `tests/test_csv_source_adapter.py` +- Read: `app/services/mapping.py` +- Read: `app/services/normalisation.py` + +**Step 1: Write failing tests** + +Test cases using synthetic in-memory rows only: + +- CSV adapter maps a normalized CSV row into a `CanonicalInstructionCandidate`. +- Missing required CSV fields produce `CanonicalSourceReject` with privacy-safe message. +- Raw account-like values are masked in candidate output. +- No raw source row is stored in the adapter result. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_csv_source_adapter.py -q +``` + +Expected RED: + +- Fails because CSV adapter function does not exist. + +**Step 2: Implement minimal CSV adapter** + +Add function: + +```python +adapt_csv_rows_to_instruction_candidates(rows: list[dict[str, str]], file_name: str) -> SourceAdapterResult +``` + +Use existing: + +- `map_source_row(...)` +- `normalize_row(...)` +- `stable_id(...)` only for deterministic source fingerprints if needed. + +Do not change `ingest_csv_bytes(...)` yet. + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_csv_source_adapter.py -q +make test +ruff check . +``` + +Expected: + +- CSV adapter tests pass. +- Legacy tests still pass. + +**Commit:** + +```bash +git add app/services/source_adapters.py tests/test_csv_source_adapter.py +git commit -m "feat: adapt CSV rows to canonical SSI instructions" +``` + +--- + +### Task 1.3: Refactor SSI Plus parser into the same canonical adapter result + +**Objective:** Make SSI Plus V3 parsing produce the same adapter result as CSV without changing current V2 import behavior. + +**Files:** +- Modify: `app/services/source_adapters.py` +- Modify: `app/services/ssiplus_v3.py` only if a small exported helper is needed. +- Test: `tests/test_ssiplus_source_adapter.py` +- Preserve: `tests/test_ssiplus_v3_parser.py` + +**Step 1: Write failing tests** + +Test cases: + +- SSI Plus synthetic bytes become `SourceAdapterResult` candidates. +- Header mismatch becomes one privacy-safe reject. +- Candidate account number is masked. +- Candidate date fields are normalized to `YYYY-MM-DD` or `None`. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_ssiplus_source_adapter.py -q +``` + +Expected RED: + +- Fails because SSI Plus adapter wrapper does not exist. + +**Step 2: Implement minimal wrapper** + +Add function: + +```python +adapt_ssiplus_v3_bytes_to_instruction_candidates(data: bytes) -> SourceAdapterResult +``` + +Reuse `parse_ssiplus_v3_rows(...)` and do not duplicate parsing logic. + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_ssiplus_source_adapter.py tests/test_ssiplus_v3_parser.py -q +make test +ruff check . +``` + +Expected: + +- Adapter and existing parser tests pass. + +**Commit:** + +```bash +git add app/services/source_adapters.py app/services/ssiplus_v3.py tests/test_ssiplus_source_adapter.py +git commit -m "feat: adapt SSI Plus rows to canonical SSI instructions" +``` + +--- + +### Task 1.4: Add shared source-lineage upsert service + +**Objective:** Extract the V2 lineage/instruction persistence path so both CSV and SSI Plus can use it. + +**Files:** +- Create: `app/services/instruction_ingestion.py` +- Modify: `app/services/ingestion.py` +- Test: `tests/test_instruction_ingestion_lineage.py` + +**Step 1: Write failing tests** + +Test cases: + +- Given one canonical candidate, service upserts `SourceFile`, `SourceRecord`, `Institution`, `SsiInstruction`, and `SsiSourceLink`. +- Re-importing identical content is idempotent. +- Rejects persist as `SourceReject` with privacy-safe content. +- No raw account values or raw source keys appear in audit payloads. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_instruction_ingestion_lineage.py -q +``` + +Expected RED: + +- Fails because shared service does not exist. + +**Step 2: Implement minimal shared service** + +Add function: + +```python +ingest_instruction_adapter_result( + session: Session, + *, + adapter_result: SourceAdapterResult, + file_name: str, + file_hash: str, + import_id: str, + uploaded_by: str, + now: str, +) -> InstructionIngestionStats +``` + +Move common logic currently embedded in `ingest_ssiplus_v3_bytes(...)` into this service. + +Keep deterministic IDs based on source system, source schema, file hash, row number, and privacy-safe source fingerprint. + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_instruction_ingestion_lineage.py tests/test_ssiplus_v3_ingestion.py -q +make test +ruff check . +``` + +Expected: + +- Shared lineage tests pass. +- Existing SSI Plus ingestion tests still pass. + +**Commit:** + +```bash +git add app/services/instruction_ingestion.py app/services/ingestion.py tests/test_instruction_ingestion_lineage.py +git commit -m "refactor: share SSI instruction lineage ingestion" +``` + +--- + +### Task 1.5: Wire CSV import to also create canonical instructions + +**Objective:** Make CSV imports hybrid: current V1 objects are still created, and the same import also creates source lineage plus `SsiInstruction` records. + +**Files:** +- Modify: `app/services/ingestion.py` +- Test: `tests/test_csv_hybrid_ingestion.py` +- Update if needed: `tests/test_imports.py` or existing import-flow tests. + +**Step 1: Write failing tests** + +Test cases: + +- Importing `data/seed_ssi.csv` still creates legacy `SsiRecord` rows. +- The same import also creates `SsiInstruction` rows. +- `ImportBatch` counts remain compatible with existing expectations. +- Source lineage links exist from CSV source records to instructions. +- V1 validation still runs and creates exceptions as before. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_csv_hybrid_ingestion.py -q +``` + +Expected RED: + +- Fails because CSV imports do not yet create `SsiInstruction` rows. + +**Step 2: Implement minimal hybrid write** + +Inside `ingest_csv_bytes(...)`, after existing V1 row import logic and before/around validation: + +- Build CSV adapter result. +- Call `ingest_instruction_adapter_result(...)`. +- Write privacy-safe audit aggregate. +- Do not remove current V1 validation. + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_csv_hybrid_ingestion.py tests/test_imports.py -q +make test +ruff check . +``` + +Expected: + +- Hybrid ingestion tests pass. +- Existing V1 tests pass. + +**Commit:** + +```bash +git add app/services/ingestion.py tests/test_csv_hybrid_ingestion.py +git commit -m "feat: create canonical instructions from CSV imports" +``` + +--- + +## Phase 2: Instruction-level validation and exceptions + +### Task 2.1: Add instruction validation models and service + +**Objective:** Validate `SsiInstruction` records without modifying old `ValidationResult` behavior. + +**Files:** +- Modify: `app/models.py` (schema gate applies: prefer new additive tables; do not add columns to existing SQLite tables without an approved migration strategy) +- Create: `app/services/instruction_validation.py` +- Test: `tests/test_instruction_validation.py` + +**Step 1: Write failing tests** + +Test cases: + +- Active `SsiInstruction` missing start date fails instruction lifecycle rule. +- Invalid preferred flag fails instruction preferred-flag rule. +- Duplicate preferred instructions for same owner/currency/asset/account-holder group fail conflict rule. +- Overlapping active instruction intervals fail overlap rule. +- Validation result does not expose raw source keys or unmasked account values. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_instruction_validation.py -q +``` + +Expected RED: + +- Fails because instruction validation service/models do not exist. + +**Step 2: Implement minimal additive model and service** + +Add model: + +- `InstructionValidationResult` + +Add service functions: + +- `validate_instruction(session, instruction, actor="system")` +- `validate_all_instructions(session, actor="system")` + +Keep first rule set small and product-aligned: + +- required currency +- required asset category +- required owner BIC +- required account-holder BIC +- required start date +- invalid date range +- invalid preferred flag +- duplicate preferred group +- overlapping active group + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_instruction_validation.py -q +make test +ruff check . +``` + +Expected: + +- Instruction validation tests pass. +- Existing validation tests pass. + +**Commit:** + +```bash +git add app/models.py app/services/instruction_validation.py tests/test_instruction_validation.py +git commit -m "feat: validate canonical SSI instructions" +``` + +--- + +### Task 2.2: Add instruction exceptions and unified queue view + +**Objective:** Create instruction-level exceptions and expose a combined operational queue while preserving `/api/v1/exceptions` behavior. + +**Files:** +- Modify: `app/models.py` (schema gate applies: prefer new additive tables; do not add columns to existing SQLite tables without an approved migration strategy) +- Create: `app/services/instruction_exceptions.py` +- Create: `app/api/instructions.py` or extend if already created in this phase. +- Modify: `app/main.py` to include the additive router. +- Modify: `app/web/routes.py` and `app/web/templates/exceptions.html` only if adding a combined UI section. +- Test: `tests/test_instruction_exceptions.py` +- Test: `tests/test_unified_exception_queue.py` + +**Step 1: Write failing tests** + +Test cases: + +- Failed instruction validation creates `InstructionExceptionCase`. +- Resolving instruction validation closes resolved instruction exceptions. +- `GET /api/v1/instruction-exceptions` returns instruction exceptions. +- Existing `GET /api/v1/exceptions` response remains unchanged for V1 tests. +- Combined queue endpoint returns both V1 and instruction exceptions using a normalized response shape. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_instruction_exceptions.py tests/test_unified_exception_queue.py -q +``` + +Expected RED: + +- Fails because instruction exception models/APIs do not exist. + +**Step 2: Implement minimal additive exception layer** + +Add model: + +- `InstructionExceptionCase` + +Add endpoint options: + +- `GET /api/v1/instruction-exceptions` +- `GET /api/v1/control-exceptions` as the combined queue. + +Do not remove or change old `/api/v1/exceptions` semantics. + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_instruction_exceptions.py tests/test_unified_exception_queue.py tests/test_exceptions.py -q +make test +ruff check . +``` + +Expected: + +- New instruction exception tests pass. +- Old exception API tests pass. + +**Commit:** + +```bash +git add app/models.py app/services/instruction_exceptions.py app/api/instructions.py app/main.py app/web/routes.py app/web/templates/exceptions.html tests/test_instruction_exceptions.py tests/test_unified_exception_queue.py +git commit -m "feat: add instruction exceptions and unified control queue" +``` + +--- + +## Phase 3: Instruction-level governance and approval + +### Task 3.1: Add instruction detail and patch APIs with critical-field guardrails + +**Objective:** Let operators inspect and patch canonical instructions while requiring approval for settlement-critical fields. + +**Files:** +- Create/Modify: `app/api/instructions.py` +- Create: `app/services/instruction_governance.py` +- Test: `tests/test_instruction_governance_api.py` + +**Step 1: Write failing tests** + +Test cases: + +- `GET /api/v1/instructions` lists instructions with masked account values. +- `GET /api/v1/instructions/{instruction_id}` returns one instruction. +- Non-critical metadata patch succeeds and writes audit. +- Critical field patch returns structured error with code `APPROVAL_REQUIRED`. +- Unknown instruction returns code `SSI_INSTRUCTION_NOT_FOUND`. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_instruction_governance_api.py -q +``` + +Expected RED: + +- Fails because instruction governance API does not exist. + +**Step 2: Implement minimal instruction API/service** + +Critical instruction fields should include at least: + +- `account_number_masked` +- `owner_bic` +- `currency_code` +- `asset_category` +- `account_holder_bic` +- `preferred_flag` +- `account_holder_type` +- `start_date` +- `stop_date` +- `status` + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_instruction_governance_api.py -q +make test +ruff check . +``` + +Expected: + +- Instruction patch tests pass. +- Old SSI patch tests pass. + +**Commit:** + +```bash +git add app/api/instructions.py app/services/instruction_governance.py tests/test_instruction_governance_api.py +git commit -m "feat: add canonical instruction governance API" +``` + +--- + +### Task 3.2: Add instruction four-eyes approval flow + +**Objective:** Mirror the existing maker/checker pattern for `SsiInstruction` changes. + +**Files:** +- Modify: `app/models.py` (schema gate applies: prefer new additive tables; do not add columns to existing SQLite tables without an approved migration strategy) +- Modify: `app/services/instruction_governance.py` +- Modify: `app/api/instructions.py` +- Test: `tests/test_instruction_approvals.py` + +**Step 1: Write failing tests** + +Test cases: + +- Submitting critical instruction change creates pending instruction change and approval request. +- Maker cannot self-approve. +- Different approver applies the pending change. +- Rejection leaves instruction unchanged. +- Approval and rejection audit payloads are privacy-safe. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_instruction_approvals.py -q +``` + +Expected RED: + +- Fails because instruction approval flow does not exist. + +**Step 2: Implement additive approval models** + +Use additive models to avoid breaking current `ApprovalRequest` assumptions: + +- `InstructionPendingChange` +- `InstructionApprovalRequest` + +Endpoints: + +- `POST /api/v1/instructions/{instruction_id}/submit-for-approval` +- `POST /api/v1/instruction-approvals/{approval_id}/approve` +- `POST /api/v1/instruction-approvals/{approval_id}/reject` + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_instruction_approvals.py tests/test_approvals.py -q +make test +ruff check . +``` + +Expected: + +- New instruction approval tests pass. +- Existing V1 approval tests pass. + +**Commit:** + +```bash +git add app/models.py app/services/instruction_governance.py app/api/instructions.py tests/test_instruction_approvals.py +git commit -m "feat: add four-eyes approval for SSI instructions" +``` + +--- + +## Phase 4: Instruction readiness, dashboard, and export + +### Task 4.1: Add instruction readiness service and endpoint + +**Objective:** Calculate readiness over canonical instructions, separate from legacy `SsiRecord` readiness during migration. + +**Files:** +- Create: `app/services/instruction_readiness.py` +- Modify: `app/api/dashboard.py` +- Test: `tests/test_instruction_readiness.py` + +**Step 1: Write failing tests** + +Test cases: + +- Readiness score counts active instructions. +- Score penalizes critical/high/medium instruction exceptions. +- Score penalizes missing start/update dates and preferred conflicts. +- Response contains no raw sensitive values. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_instruction_readiness.py -q +``` + +Expected RED: + +- Fails because instruction readiness endpoint does not exist. + +**Step 2: Implement additive endpoint** + +Endpoint: + +- `GET /api/v1/dashboard/instruction-readiness-score` + +Keep current: + +- `GET /api/v1/dashboard/readiness-score` +- `GET /api/v1/dashboard/v2/source-controls` + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_instruction_readiness.py tests/test_v2_dashboard.py -q +make test +ruff check . +``` + +Expected: + +- New readiness endpoint passes. +- Existing dashboard endpoints pass. + +**Commit:** + +```bash +git add app/services/instruction_readiness.py app/api/dashboard.py tests/test_instruction_readiness.py +git commit -m "feat: add readiness scoring for canonical instructions" +``` + +--- + +### Task 4.2: Add instruction export path + +**Objective:** Export approved canonical instructions without removing old `SsiRecord` export. + +**Files:** +- Create: `app/services/instruction_export.py` +- Modify: `app/api/exports.py` or create `app/api/instruction_exports.py` +- Modify: `app/main.py` if adding new router. +- Test: `tests/test_instruction_export.py` + +**Step 1: Write failing tests** + +Test cases: + +- Export includes only approved canonical instructions. +- Export masks account values. +- Export excludes raw source keys, BDP keys, EIDs, institution names, city values unless explicitly safe and synthetic. +- Existing export endpoint still works. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_instruction_export.py -q +``` + +Expected RED: + +- Fails because instruction export does not exist. + +**Step 2: Implement additive export** + +Endpoint option: + +- `POST /api/v1/instruction-exports` +- `GET /api/v1/instruction-exports/{export_id}/download` + +Use a separate export prefix to avoid changing current `ExportBatch` semantics unless a generic export model is explicitly chosen by Raf. + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_instruction_export.py tests/test_export.py -q +make test +ruff check . +``` + +Expected: + +- Instruction export works. +- Old export works. + +**Commit:** + +```bash +git add app/services/instruction_export.py app/api/exports.py app/main.py tests/test_instruction_export.py +git commit -m "feat: export approved canonical SSI instructions" +``` + +--- + +### Task 4.3: Update web UI with unified instruction sections + +**Objective:** Make the Raafet-style UI show the hybrid unified flow without breaking current pages. + +**Files:** +- Modify: `app/web/routes.py` +- Modify: `app/web/templates/dashboard.html` +- Modify: `app/web/templates/exceptions.html` +- Create: `app/web/templates/instructions.html` +- Create: `app/web/templates/instruction_detail.html` +- Test: `tests/test_hybrid_web_dashboard.py` +- Test: `tests/test_raafet_style_web.py` + +**Step 1: Write failing tests** + +Test cases: + +- Home dashboard contains `Unified instruction flow` section. +- Navigation exposes `Instructions` on desktop and mobile. +- `/instructions` returns canonical instruction list. +- `/instructions/{instruction_id}` returns masked detail page. +- Raafet style tokens still appear. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_hybrid_web_dashboard.py tests/test_raafet_style_web.py -q +``` + +Expected RED: + +- Fails because instruction pages/nav do not exist. + +**Step 2: Implement minimal UI** + +Keep the existing Raafet-style shell. Add only additive sections/pages. + +**Step 3: Run GREEN and browser smoke** + +```bash +.venv/bin/python -m pytest tests/test_hybrid_web_dashboard.py tests/test_raafet_style_web.py -q +make test +ruff check . +``` + +Then run local browser QA on: + +- `/` +- `/instructions` +- `/exceptions` +- `/imports` + +Expected: + +- Tests pass. +- No dark/prototype UI regression. +- No raw sensitive values visible. + +**Commit:** + +```bash +git add app/web/routes.py app/web/templates/*.html tests/test_hybrid_web_dashboard.py tests/test_raafet_style_web.py +git commit -m "feat: surface unified SSI instruction flow in UI" +``` + +--- + +## Phase 5: Docs, handoff, and compatibility bridge + +### Task 5.1: Document the hybrid flow and migration state + +**Objective:** Make the architecture understandable for future sessions and demos. + +**Files:** +- Modify: `README.md` +- Create: `docs/hybrid_unified_ssi_instruction_flow.md` +- Create: `docs/HANDOFF_YYYY-MM-DD_ssi_hybrid_flow.md` +- Test: docs link/path checks if available, otherwise no code tests. + +**Step 1: Write documentation checks** + +If no docs test helper exists, add a lightweight test: + +- `tests/test_docs_hybrid_flow.py` + +Assertions: + +- README references the hybrid flow doc. +- Hybrid flow doc contains the source-to-governance pipeline. +- Handoff mentions V1 compatibility and V2 canonical instruction state. +- Docs do not include forbidden raw/private terminology examples beyond generic labels. + +Run: + +```bash +.venv/bin/python -m pytest tests/test_docs_hybrid_flow.py -q +``` + +Expected RED: + +- Fails until docs exist. + +**Step 2: Write docs** + +Document: + +```text +CSV or SSI Plus -> source adapter -> source lineage -> SsiInstruction -> instruction validation -> instruction exceptions -> instruction approvals -> audit -> instruction readiness -> instruction export +``` + +Mention old `SsiRecord` remains compatibility/demo read-model until a future deprecation decision. + +**Step 3: Run GREEN** + +```bash +.venv/bin/python -m pytest tests/test_docs_hybrid_flow.py -q +make test +ruff check . +``` + +Expected: + +- Docs tests pass. +- Full suite passes. + +**Commit:** + +```bash +git add README.md docs/hybrid_unified_ssi_instruction_flow.md docs/HANDOFF_YYYY-MM-DD_ssi_hybrid_flow.md tests/test_docs_hybrid_flow.py +git commit -m "docs: record hybrid SSI instruction flow" +``` + +--- + +## Rollback strategy + +- Each task is committed independently. +- If a task fails review, revert only that task's commit with `git revert `. +- Because all changes are additive until Raf explicitly approves deprecation, rollback should not require data deletion. +- Do not run destructive DB cleanup. Generated SQLite DB can be removed with `make clean` and recreated from synthetic fixtures. +- If instruction-level APIs are not ready for demo, hide only the new UI links/sections while preserving backend tests for future work. + +--- + +## Verification matrix for every implementation phase + +Run before each phase commit from the selected SSI app root. If Phase -1 was executed, that root is `/Users/Shared/AgentWork/repos/payment-intelligence-modules/apps/ssi-control-tower`: + +```bash +cd "$SSI_APP_ROOT" +.venv/bin/python -m pytest -q +make test +ruff check . +git diff --check +``` + +If the implementation lives inside Payment Intelligence Modules, also run the root suite verification before the fold commit and before final review: + +```bash +cd /Users/Shared/AgentWork/repos/payment-intelligence-modules +pnpm verify +git diff --check +``` + +Run static scan before final Codex review from the git repo that contains the selected implementation root. For fold-first work, run this from `/Users/Shared/AgentWork/repos/payment-intelligence-modules` so the bundle includes `apps/ssi-control-tower/**`: + +```bash +REVIEW_BUNDLE=/tmp/ssi_hybrid_flow_review_bundle.diff +{ + git diff -- . ':(exclude).hermes/**' + git ls-files --others --exclude-standard -- . ':(exclude).hermes/**' | while IFS= read -r f; do + printf '\n--- UNTRACKED: %s ---\n' "$f" + sed -n '1,260p' "$f" + done +} > "$REVIEW_BUNDLE" + +! grep -nE "^\+.*(api_key|secret|password|token|passwd)\s*=\s*['\"][^'\"]{6,}['\"]" "$REVIEW_BUNDLE" +! grep -nE "^\+.*(os\.system\(|subprocess.*shell=True)" "$REVIEW_BUNDLE" +! grep -nE "^\+.*(\beval\(|\bexec\()" "$REVIEW_BUNDLE" +! grep -nE "^\+.*pickle\.loads?\(" "$REVIEW_BUNDLE" +! grep -nE "^\+.*(execute\(f\"|\.format\(.*SELECT|\.format\(.*INSERT)" "$REVIEW_BUNDLE" +``` + +For UI changes, also run bounded browser QA on representative pages and inspect console output. + +--- + +## Codex plan-review prompt + +Use read-only Codex before implementation: + +```text +You are Codex reviewing an implementation plan only. Do not edit files. + +Primary repo: /Users/Shared/AgentWork/repos/ssi-control-tower +Target repo to inspect read-only for fold fit: /Users/Shared/AgentWork/repos/payment-intelligence-modules +Plan: /Users/Shared/AgentWork/repos/ssi-control-tower/docs/hybrid_unified_ssi_instruction_flow_implementation_plan.md +Selected default scope: Phase -1 through Phase 2 only unless Raf explicitly promotes full-flow scope. + +Review for: +1. Does the plan preserve existing V1 behavior and tests? +2. Does it genuinely converge CSV and SSI Plus into a canonical SsiInstruction governance flow for the selected default scope? +3. Are phases additive and low-risk? +4. Are TDD tasks concrete enough for Claude Code implementation? +5. Does Phase -1 correctly fold SSI Control Tower into `payment-intelligence-modules/apps/ssi-control-tower/` as its own module without weakening the existing browser-only suite privacy posture? +6. Are all later implementation paths/commands correctly rooted under `apps/ssi-control-tower/` after a fold-first execution? +7. Are privacy invariants sufficient for SSI data: no raw accounts, source record keys, BDP keys, EIDs, institution names, city values, or raw private rows in rejects/audit/API/UI/docs? +8. Are API/model choices coherent with SQLAlchemy `create_all` + SQLite and no Alembic, including the explicit no-new-columns-on-existing-tables schema gate? +9. Are verification gates sufficient for both the Python app and root Payment Intelligence suite? +10. What scope choices must Raf decide before coding? + +Return exactly one of: +- APPROVE: plan is ready for implementation +- AMEND: plan needs specific amendments before implementation +- REJECT: plan is unsafe or architecturally wrong + +If AMEND or REJECT, list blockers only with exact plan sections to change. +``` + +--- + +## Codex final diff-review prompt + +Use read-only Codex after implementation and local verification: + +```text +You are Codex performing final implementation review. Do not edit files. + +Repo: /Users/Shared/AgentWork/repos/payment-intelligence-modules if Phase -1 was executed; otherwise /Users/Shared/AgentWork/repos/ssi-control-tower +Plan: /Users/Shared/AgentWork/repos/ssi-control-tower/docs/hybrid_unified_ssi_instruction_flow_implementation_plan.md, or the copied plan under apps/ssi-control-tower/docs/ after the fold +Selected implementation scope: state the exact completed phase range in the prompt before review. + +Review git status, tracked diff, and untracked files. Confirm: +1. Implementation follows the approved selected scope without scope creep. +2. SSI Control Tower has been folded into the Payment Intelligence repo as its own self-contained module if Phase -1 is in scope, with no nested `.git`, generated DBs, exports, venvs, or private data copied. +3. V1 CSV/SsiRecord behavior remains compatible. +4. CSV and SSI Plus both create canonical SsiInstruction records through shared source lineage if Phase 1 is in scope. +5. Instruction validation/exceptions are additive and privacy-safe if Phase 2 is in scope. +6. Instruction approvals/readiness/export/UI are reviewed only if their phases were explicitly selected. +7. APIs use structured FastAPI errors. +8. No raw/private SSI values, credentials, tokens, or secrets are exposed. +9. Tests prove privacy invariants and selected-scope hybrid behavior. +10. Verification matrix results are credible for both the SSI app and, after fold, the root Payment Intelligence suite. + +Return APPROVE or REQUEST_CHANGES. If REQUEST_CHANGES, list blockers only. +``` + +--- + +## Raf decision gate before implementation + +Before Claude Code implements, Raf has already decided the repository direction: + +- SSI Control Tower should be folded directly under Payment Intelligence Modules as its own module/component. +- It should not continue as a separate product repo. +- It should remain its own thing, recommended path `apps/ssi-control-tower/`, not be absorbed into the current browser-only React runtime. + +Remaining scope choices before implementation: + +1. **Fold first, then hybrid flow:** Move current verified SSI Control Tower into `payment-intelligence-modules/apps/ssi-control-tower/` first, commit that, then implement hybrid flow inside the suite repo. +2. **Hybrid flow first, then fold:** Finish the hybrid flow in the standalone repo, then copy the completed module into Payment Intelligence Modules. +3. **Hybrid ingestion only:** CSV and SSI Plus both create `SsiInstruction` + lineage, but governance remains V1 for now. +4. **Hybrid ingestion + instruction validation/exceptions:** Canonical instructions become controllable, but approvals/export stay future work. +5. **Full additive unified flow:** Build ingestion, instruction validation, instruction exceptions, instruction approvals, instruction readiness, instruction export, and UI sections in one branch. +6. **Model strategy:** Keep additive instruction-specific tables first, or introduce generic governance tables with `entity_type`/`entity_id` now. +7. **UI strategy:** Add minimal instruction pages now, or keep UI limited to dashboard aggregates until the backend flow is proven. + +Recommended default: **Option 1 + Option 4 first**, with additive instruction-specific tables. Fold the module into the Payment Intelligence repo before more product work, then prove canonical instruction ingestion/validation/exceptions without overloading the branch. Use a second branch for instruction approvals/export/UI polish. diff --git a/apps/ssi-control-tower/docs/private_data_lab_methodology.md b/apps/ssi-control-tower/docs/private_data_lab_methodology.md new file mode 100644 index 0000000..83affd6 --- /dev/null +++ b/apps/ssi-control-tower/docs/private_data_lab_methodology.md @@ -0,0 +1,43 @@ +# Private Data-Lab Methodology + +SSI Control Tower's public repository is synthetic-only. A private data lab, if used by an authorized institution, must run locally inside that institution's controlled environment and must not commit, publish, or transmit raw settlement instructions. + +## Authorized corpus use + +An authorized SSI corpus can be used to discover field aliases, value normalization patterns, rule candidates, and anomaly profiles. Access must be limited to personnel and systems explicitly approved for the source data. The public prototype does not implement private extraction and does not need private data to run. + +## Deterministic redaction + +Before analysis outputs leave the private lab, direct identifiers should be deterministically redacted: + +- account numbers become stable masked forms such as `SEC-****-1042` +- client names become synthetic fund names +- proprietary source-system names become generic labels +- row identifiers become UUIDv5 IDs with a private namespace +- rare or identifying combinations are generalized or suppressed + +The mapping table from real values to synthetic values must remain private and access-controlled. + +## Synthetic data generation + +The lab can generate public-safe test cases by preserving statistical shape, not raw content. Useful outputs include duplicate-context examples, stale confirmation distributions, invalid date ranges, masked-account patterns, and representative source headers. Synthetic samples should be reviewed before publication to confirm no real instruction can be reconstructed. + +## Field alias discovery + +Private data can reveal recurring source headers such as local-agent BIC aliases, PSET naming variants, and owner fields. Only alias patterns and confidence scores should be exported, never raw records. + +## Rule candidate mining + +Rules can be proposed from recurring control failures: missing owners, overlapping active instructions, inconsistent BIC syntax, old confirmation dates, and unsupported country or currency codes. Each rule should be documented with a safe synthetic example and an owner-facing remediation hint. + +## Anomaly profile creation + +The lab can create profiles for unusual but safe patterns, such as high duplicate density by market, stale records near cut-off dates, or repeated missing evidence. Public outputs should be aggregate profiles or synthetic test scenarios only. + +## What must never be committed + +Do not commit raw SSI records, account numbers, client data, proprietary workflows, credentials, connection strings, data extracts, screenshots from production tools, or reversible redaction maps. + +## External model prohibition for raw SSI records + +External LLM calls must not contain raw SSI records, client identifiers, account numbers, credentials, or proprietary post-trade workflows. If language models are used, they should receive only synthetic examples or redacted aggregate descriptions approved by the data owner. diff --git a/apps/ssi-control-tower/docs/product_positioning.md b/apps/ssi-control-tower/docs/product_positioning.md new file mode 100644 index 0000000..8776209 --- /dev/null +++ b/apps/ssi-control-tower/docs/product_positioning.md @@ -0,0 +1,19 @@ +# Product Positioning + +SSI Control Tower is a governance-grade T+1 readiness prototype. It demonstrates that SSI automation is not finished when fields are valid. It is finished when every active instruction is current, non-conflicting, owned, approved, evidenced, exportable, and safe to use under compressed settlement timelines. + +## Intended users + +- Operations owners who remediate SSI exceptions and provide evidence +- Reference-data teams who manage source templates, mappings, and clean exports +- Approvers who enforce four-eyes controls on settlement-critical changes +- Risk reviewers who monitor stale records, duplicate-active contexts, waivers, and audit evidence +- Technology teams evaluating simple control architecture using FastAPI, SQLite, Jinja2, HTMX, and YAML rules + +## Public prototype boundaries + +This project uses synthetic data only. It is not affiliated with DTCC, SSImple, Swift, FMSB, ISITC, any custodian, or any market infrastructure. It does not connect to production SSI utilities and does not contain real settlement instructions, client data, account numbers, or proprietary workflows. + +## Market caveat + +BIC checks in this prototype are syntax and synthetic-demo checks only. They are not a current production BIC directory or SwiftRef lookup. diff --git a/apps/ssi-control-tower/docs/swiftref_ssi_structure_notes.md b/apps/ssi-control-tower/docs/swiftref_ssi_structure_notes.md new file mode 100644 index 0000000..f0aac7f --- /dev/null +++ b/apps/ssi-control-tower/docs/swiftref_ssi_structure_notes.md @@ -0,0 +1,46 @@ +# SWIFTRef SSI structure notes for synthetic samples + +This note records the read-only source structure used to create the synthetic SSI sample files. + +## Source material inspected + +- SWIFTRef Portfolio Evolution: `SSI Directory for SwiftRef Files - Evolution Portfolio - User Guide`. +- SWIFTRef Legacy Portfolio: `SSI Plus for SwiftRef Files - Legacy Portfolio - Technical Specifications - For File Version 3`. +- Curated KB registries under `knowledge/swift-payments/swiftref/` for `ssi_directory_pe` and `ssi_plus`. + +No raw SSI records were copied into the app repository. The raw SWIFT archive was read-only. The generated samples below are synthetic. + +## Practical SSI structure lessons used + +- Portfolio Evolution SSI Directory is centered on `RELATIONSHIPS-SSI`; `STRUCTURES-SSID` describes the fields and record structures; `CODES-RELD` and calendar/code files support interpretation. +- The PE guide describes SSI lookup as: identifier + currency + asset category -> SSI/correspondent. +- Key business dimensions are account owner, currency, asset category, account holder/correspondent, validity dates, preferred flag, institution identifiers, country and city/town metadata. +- Legacy `SSIPLUS_V3` is a tab-delimited 28-field file. Important fields include owner BIC/name/country, ISO currency, asset category, account-holding institution BIC/name/country, masked/controlled account number, preferred flag, account holding institution type, update/traffic/start/stop dates, and cross-reference keys. +- Documented SSI Plus asset category codes include `SECU` securities, `COPA` commercial payments, `CASH` bank-to-bank cash, `FOEX` foreign exchange, `MMKT` money market, `DERI` derivatives, `TFIN` trade finance, `TREA` treasury, `FUTU` futures, and `COLL` collections. + +## Generated files + +- `data/sample_swiftref_ssiplus_v3_synthetic.tsv` + - SwiftRef `SSIPLUS_V3`-shaped tab-delimited synthetic sample. + - 60 synthetic rows. + - 18 countries, 12 currencies, 10 SSI asset-category codes. + - Uses masked account values only. + +- `data/sample_realistic_multi_market_ssi.csv` + - SSI Control Tower app-compatible import CSV. + - 60 synthetic rows. + - Mirrors the same market/currency/asset diversity in the app's canonical CSV structure. + +## Field mapping into SSI Control Tower + +- `BIC OWNER` -> legal-entity BIC / SSI owner institution. +- `ISO CURRENCY CODE` -> `Currency` / `Base Currency`. +- `ASSET CATEGORY` -> `Asset Class` after normalization, for example `SECU`/Securities. +- `BIC ACCOUNT HOLDING INSTITUTION` -> local/global correspondent or custodian BIC fields. +- `ACCOUNT NBR WITH ACCOUNT HOLDING INSTITUTION` -> masked account fields only. +- `START DATE` / `STOP DATE` -> `Effective From` / `Effective To`. +- `PREFERRED ACCOUNT HOLDING INSTITUTION` and `ACCOUNT HOLDING INSTITUTION TYPE` are preserved conceptually in the sample via correspondent/depository/payment-system choices, but the current app schema does not yet have dedicated columns for preferred flag and holder type. + +## Caveats + +These are realistic synthetic samples, not production directory extracts. BIC values are syntactically valid synthetic examples and are not current production BIC-directory assertions. Account values are masked and non-reversible. diff --git a/apps/ssi-control-tower/docs/v2_business_logic_gap_analysis.md b/apps/ssi-control-tower/docs/v2_business_logic_gap_analysis.md new file mode 100644 index 0000000..9c20aaf --- /dev/null +++ b/apps/ssi-control-tower/docs/v2_business_logic_gap_analysis.md @@ -0,0 +1,241 @@ +# SSI Control Tower V2 Business Logic Gap Analysis + +Generated: 2026-05-06 + +## Executive verdict + +The current repository is a solid runnable V1 governance prototype. It proves the shape of an operating model: ingest, normalize, validate, create exceptions, assign owners, enforce four-eyes approval, write audit evidence, score readiness, show a dashboard, and export approved records. + +It does not yet deliver the real SSI Control Tower vision. The gap is structural: V1 models a synthetic buy-side settlement instruction record, while real SWIFTRef SSI / SSI Plus data is centered on owner institution, currency, asset category, account-holding institution, account with that holder, preferred flag, holder type, source lifecycle dates, and reference cross-links. + +## Real-profile evidence used + +A read-only local profile of `SSIPLUS_V3_MONTHLY_FULL_20251226.txt` was produced under: + +`/Users/Shared/AgentWork/knowledge/swift-payments/swiftref/verification/ssi-control-tower-real-sample-2026-05-06/` + +Safety boundary: + +- Raw archive was read only. +- Raw SSI identifiers, institution names, account values, source keys, BDP keys, EIDs, city names, and exact source dates were not printed into assistant context. +- The derived sample preserves only limited profiling dimensions: currency, asset category, owner/holder country, preferred flag presence, holder type, and traffic flag shape. +- BICs, names, accounts, record keys, BDP keys, EIDs, cities, and exact source dates were redacted or synthesized in derived files. +- Derived artifacts are private verification artifacts, not public repo fixtures. + +High-level profile: + +- Source structure: 28 fields. +- Source rows profiled locally: 425,617. +- Distinct currencies: 152. +- Distinct SSI asset categories: 18. +- Distinct owner countries: 223. +- Distinct account-holder countries: 199. +- Account-holding institution types: 2. + +A 100-row redacted real-profile-shaped TSV was created: + +`ssiplus_v3_real_profile_redacted_sample_100.tsv` + +It includes EUR and USD. + +## Immediate failed-fit check + +Uploading the redacted `SSIPLUS_V3`-shaped TSV to the current V1 importer produced: + +- HTTP status: 201 +- records_received: 100 +- records_imported: 0 +- records_rejected: 100 +- import_status: completed + +This is expected because V1 only handles app-shaped CSV aliases and has no SSI Plus V3 adapter. It is also a useful proof that the current system is not yet a real SSI Plus ingestion/governance tool. + +## What V1 got right + +- End-to-end control skeleton exists. +- Validation failures become exception cases with severity, owner, SLA, status, evidence, and audit trail. +- Four-eyes approval exists for critical settlement fields. +- Self-approval is blocked in the service layer. +- Audit events are insert-only, backed by SQLite triggers. +- UI gives operational pages for dashboard, imports, SSIs, exceptions, approvals, audit, and rules. +- Tests cover the demo workflow. + +## Main business-logic gaps + +### 1. Source model gap + +V1 lacks a first-class `SSIPLUS_V3` source adapter. + +Needed: + +- tab-delimited 28-field parser +- exact header and version validation +- source file lineage with hash/member/date +- row-number lineage +- idempotent upsert by source record key +- row-level reject ledger +- controlled account masking/tokenization + +### 2. Canonical model gap + +V1 `SsiRecord` has fields such as `market`, `place_of_settlement_bic`, `global_custodian_bic`, `local_agent_bic`, and `settlement_method`. + +Real SSI Plus needs dedicated concepts for: + +- modification flag +- source record key +- owner BIC/name/city/country +- ISO currency code +- asset category code +- account-holding institution BIC/name/country +- account number with account-holding institution +- preferred account-holding institution flag +- account-holding institution type +- group key owner +- BDP/EID owner and account-holder links +- update date +- traffic flag/date +- start/stop date + +V1 can keep a governance view, but it should not be treated as source truth. + +### 3. Rules gap + +V1 rules are useful demo controls, but not real SSI lifecycle controls. + +Needed rule families: + +- SSI Plus header/version/schema validation +- mandatory fields by source type and asset category +- currency/country/code validation from full code sets +- asset category code validation for all 18 categories +- active interval and source lifecycle validation +- preferred-instruction conflicts +- overlapping active instruction conflicts +- owner/currency/asset-category/correspondent uniqueness checks +- account-holder type checks +- BDP/EID cross-reference checks where reference data is available +- update-date and traffic-date freshness rules +- source trust and source drift rules +- waiver expiry and evidence completeness rules + +### 4. Governance gap + +V1 proves maker/checker, but governance is not yet production-like. + +Needed: + +- rule-family ownership matrix +- owner routing by institution/country/currency/asset/source +- SLA breach calculation against calendars +- escalation queues +- separate waiver approver from requester +- waiver expiry enforcement +- evidence objects, not free-text evidence strings +- role enforcement beyond `X-User-Email` +- policy-driven criticality, not a fixed field list + +### 5. Readiness score gap + +V1 readiness score is an arbitrary demo formula. + +Needed: + +- score decomposed into drillable controls +- coverage by owner, holder country, currency, asset category +- active/preferred conflict rate +- expiry/stale/update-date/traffic aging +- exception backlog and SLA breach aging +- source trust and source freshness +- export eligibility and blocked-record reasons + +The dashboard should be control-first; the executive score should be the summary, not the product. + +### 6. Scale gap + +V1 is not built for 425k+ source rows. + +Needed: + +- PostgreSQL or equivalent relational store +- Alembic migrations +- streaming ingestion +- indexed source/canonical keys +- batch validation runs +- materialized exception state +- pagination everywhere +- performance tests with synthetic 425k-style profiles + +## Proposed V2 architecture + +### Source truth layer + +- `source_files` +- `source_records` +- `source_record_values` +- `source_lineage` +- `source_rejects` + +Purpose: preserve provenance and parse/validate licensed data locally without leaking raw data. + +### SSI domain layer + +- `institutions` +- `institution_identifiers` +- `ssi_instructions` +- `ssi_parties` +- `ssi_accounts` +- `ssi_validity_periods` +- `ssi_source_links` + +Purpose: model the real owner/currency/asset/account-holder relationship. + +### Control layer + +- `validation_runs` +- `validation_results` +- `control_rules` +- `exception_cases` +- `case_assignments` +- `case_sla_events` +- `waivers` +- `evidence_objects` +- `approval_requests` +- `audit_events` + +Purpose: make governance the product. + +### Consumption layer + +- `governance_views` +- `readiness_snapshots` +- `export_manifests` +- `export_records` + +Purpose: dashboards, filters, export eligibility, and downstream extracts. + +## Recommended implementation tracks + +### Track A: Real source adapter + +Build an SSI Plus V3 adapter around the 28-field TSV source. Verify with the private 100-row redacted sample first, then a local raw-only smoke that prints only aggregates. + +### Track B: Canonical domain remodel + +Introduce real owner/currency/asset/holder/account/validity tables. Keep existing `SsiRecord` only as a compatibility view until the UI is migrated. + +### Track C: Control rules rewrite + +Replace demo rules with SSI lifecycle rule packs based on real source semantics, preferred conflicts, lifecycle dates, reference cross-links, ownership routing, and waiver/evidence policy. + +### Track D: Dashboard rethink + +Dashboard should show coverage, conflicts, stale/update/traffic aging, SLA breaches, owner queues, and export-blocked reasons by currency/country/asset category. + +### Track E: Performance and privacy + +Keep raw SSI local and private. Use deterministic redaction/tokenization for test artifacts. Add high-volume synthetic and redacted-profile fixtures. Do not commit raw source data. + +## Decision + +Do not keep extending V1 by adding more fields one by one. The right move is a V2 domain pivot: source-truth layer + real SSI domain model + governance/control layer. diff --git a/apps/ssi-control-tower/docs/v2_ssiplus_source_truth_implementation_plan.md b/apps/ssi-control-tower/docs/v2_ssiplus_source_truth_implementation_plan.md new file mode 100644 index 0000000..767d03d --- /dev/null +++ b/apps/ssi-control-tower/docs/v2_ssiplus_source_truth_implementation_plan.md @@ -0,0 +1,236 @@ +# SSI Control Tower V2 SSI Plus Source Truth Implementation Plan + +> **For Hermes:** Use subagent-driven-development discipline and TDD gates. Claude/Codex external workflow: Codex reviews this plan read-only before coding; implementation follows the approved plan; Codex reviews the final diff before commit. + +**Goal:** Build the first real V2 foundation: ingest 28-field `SSIPLUS_V3` TSV files into a source-truth layer and real SSI domain layer, while keeping the V1 synthetic CSV flow green. + +**Architecture:** Keep SQLite/FastAPI for this prototype slice. Add new SQLAlchemy tables for source lineage and real SSI instructions, add a dedicated SSI Plus V3 adapter/service, auto-route uploads by exact header, and add control-first dashboard aggregates. V1 `SsiRecord` remains untouched for compatibility. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2, SQLite, pytest. + +--- + +## Scope boundaries + +- Do not commit or print raw private SSI data. +- Use only the public synthetic `data/sample_swiftref_ssiplus_v3_synthetic.tsv` and small inline synthetic test fixtures. +- Do not replace V1 ingestion, rules, approvals, exports, or UI pages in this slice. +- Do not add PostgreSQL/Alembic yet. This is the V2 domain pivot inside the current prototype. + +## Acceptance criteria + +- Existing `make test` remains green. +- Uploading the synthetic `SSIPLUS_V3` TSV to `/api/v1/imports` imports rows instead of rejecting all of them. +- Exact 28-field header validation exists and malformed SSI Plus TSV uploads create source/header rejects in the V2 source layer instead of falling through to V1 CSV ingestion. +- Source lineage persists file hash, row number, source record key, parser version, and import batch link. +- Domain instructions persist owner BIC/country, currency, asset category, account holder BIC/country/type, masked account, preferred flag, lifecycle dates, traffic dates, update dates, BDP/EID cross-links. +- Re-importing the same file is idempotent for source/domain records. +- Parser rejects, source rejects, audit payloads, and dashboard responses never include raw account values, institution names, record keys, BDP keys, EIDs, or city values. +- Dashboard exposes V2 coverage/conflict/freshness aggregates by currency, asset category, owner country, and account-holder country, including preferred conflicts and overlapping active instruction conflicts by owner/currency/asset/holder. + +--- + +### Task 1: Add V2 SQLAlchemy models + +**Objective:** Add source truth and SSI domain tables without changing V1 tables. + +**Files:** +- Modify: `app/models.py` +- Test: `tests/test_v2_models.py` + +**Step 1: Write failing tests** + +Create `tests/test_v2_models.py` with assertions that a fresh seeded test DB has the new tables: + +- `source_files` +- `source_records` +- `source_rejects` +- `institutions` +- `ssi_instructions` +- `ssi_source_links` + +Run: `.venv/bin/python -m pytest tests/test_v2_models.py -q` +Expected: FAIL because models/tables do not exist. + +**Step 2: Implement models** + +Add SQLAlchemy models with string primary keys and simple indexed fields where useful: + +- `SourceFile`: source_file_id, import_id, file_name, file_hash, source_system, source_schema, parser_version, uploaded_by, records_received, records_accepted, records_rejected, created_at. +- `SourceRecord`: source_record_id, source_file_id, import_id, row_number, source_record_key, source_hash, status, created_at. +- `SourceReject`: source_reject_id, source_file_id, import_id, row_number, reject_code, reject_message, created_at. +- `Institution`: institution_id, bic, name, city, country_code, source_system, created_at, updated_at. +- `SsiInstruction`: ssi_instruction_id, source_record_key, owner_institution_id, owner_bic, owner_name, owner_city, owner_country_code, currency_code, asset_category, account_holder_institution_id, account_holder_bic, account_holder_name, account_holder_country_code, account_number_masked, preferred_flag, account_holder_type, group_key_owner, record_key_bdp_owner, eid_owner, record_key_bdp_account_holder, eid_account_holder, update_date, traffic_flag, traffic_date, start_date, stop_date, status, created_at, updated_at. +- `SsiSourceLink`: ssi_source_link_id, ssi_instruction_id, source_record_id, source_file_id, import_id, created_at. + +**Step 3: Verify** + +Run `.venv/bin/python -m pytest tests/test_v2_models.py -q` then `make test`. + +--- + +### Task 2: Add SSI Plus V3 parser and redaction helpers + +**Objective:** Parse exact 28-field tab-delimited SSI Plus rows into a typed intermediate record. + +**Files:** +- Create: `app/services/ssiplus_v3.py` +- Test: `tests/test_ssiplus_v3_parser.py` + +**Step 1: Write failing tests** + +Test these behaviors: + +- `is_ssiplus_v3_bytes()` returns true for `data/sample_swiftref_ssiplus_v3_synthetic.tsv`. +- `parse_ssiplus_v3_rows()` returns parsed rows and no rejects for the synthetic sample. +- A missing/renamed header returns a schema/header reject without echoing raw row values. +- `looks_like_ssiplus_v3_bytes()` identifies malformed SSI Plus TSV candidates so API routing can keep them in the V2 reject ledger instead of V1 CSV fallback. +- `mask_account_number()` always returns a masked value containing `****` and does not preserve full numeric account strings. +- Date normalization converts `YYYYMMDD` to `YYYY-MM-DD` and blank to `None`. + +Run: `.venv/bin/python -m pytest tests/test_ssiplus_v3_parser.py -q` +Expected: FAIL because module does not exist. + +**Step 2: Implement** + +Implement: + +- `EXPECTED_SSIPLUS_V3_FIELDS`: the exact 28 headers from the sample/spec. +- `PARSER_VERSION = "ssiplus_v3_v1"`. +- `is_ssiplus_v3_bytes(data: bytes) -> bool` that checks the first line fields exactly. +- `parse_ssiplus_v3_rows(data: bytes) -> ParseResult` using `csv.DictReader(..., delimiter="\t")`. +- `mask_account_number(value: str | None) -> str` that returns `ACCT-****-NNNN` when a last-four-like suffix exists, else `ACCT-****`. +- `normalize_yyyymmdd(value: str | None) -> str | None`. +- Minimal row validation for required fields: record key, owner BIC, currency, asset category, holder BIC, holder account. + +**Step 3: Verify** + +Run parser tests. + +--- + +### Task 3: Add V2 ingestion service + +**Objective:** Persist parsed SSI Plus V3 rows into the source-truth and domain layers idempotently. + +**Files:** +- Modify: `app/services/ingestion.py` +- Test: `tests/test_ssiplus_v3_ingestion.py` + +**Step 1: Write failing tests** + +Test these behaviors against `data/sample_swiftref_ssiplus_v3_synthetic.tsv`: + +- `ingest_ssiplus_v3_bytes()` creates one `ImportBatch` with source_system `SWIFTREF_SSIPLUS` and source_template_id `SSIPLUS_V3`. +- It creates one `SourceFile`, accepted `SourceRecord` rows, zero `SourceReject` rows, and matching `SsiInstruction` rows. +- It masks account numbers and does not store unmasked account values in `SsiInstruction.account_number_masked`. +- It preserves lineage via `SsiSourceLink` to row number and source file. +- Reimporting the same file does not increase `SourceRecord` or `SsiInstruction` counts. +- A bad row produces a `SourceReject` and does not create a domain instruction for that row. +- Source reject messages and audit payloads contain aggregate/error codes only; they must not contain raw account values, institution names, record keys, BDP keys, EIDs, or city values. + +Run: `.venv/bin/python -m pytest tests/test_ssiplus_v3_ingestion.py -q` +Expected: FAIL because service does not exist. + +**Step 2: Implement** + +Add: + +- `SSIPLUS_TEMPLATE_ID = "SSIPLUS_V3"`. +- `ingest_ssiplus_v3_bytes(session, data, file_name, uploaded_by, run_validation=False) -> ImportBatch`. +- Internal helpers for stable IDs and institution upsert. +- `ImportBatch.records_received/imported/rejected` aligned to parser accepted/rejected counts. +- Audit event `import.ssiplus_v3.completed` with aggregate counts only. + +Keep V1 `ingest_csv_bytes()` behavior unchanged. + +**Step 3: Verify** + +Run V2 ingestion tests and existing ingestion tests. + +--- + +### Task 4: Auto-route API uploads by source shape + +**Objective:** Let `/api/v1/imports` accept real-shaped SSI Plus TSV files while preserving V1 CSV uploads. + +**Files:** +- Modify: `app/api/imports.py` +- Test: `tests/test_import_ssiplus_v3_api.py` + +**Step 1: Write failing tests** + +Test: + +- Posting the synthetic SSI Plus TSV to `/api/v1/imports` returns `201`, `records_imported > 0`, `records_rejected == 0`, and `source_system == "SWIFTREF_SSIPLUS"`. +- Posting a malformed SSI Plus-like TSV header returns `201`, `source_system == "SWIFTREF_SSIPLUS"`, `records_imported == 0`, `records_rejected > 0`, and creates a V2 `SourceReject`. +- Existing mapped CSV upload test still passes. + +Run: `.venv/bin/python -m pytest tests/test_import_ssiplus_v3_api.py tests/test_ingestion.py -q` +Expected: FAIL for the new API test before routing exists. + +**Step 2: Implement** + +In `app/api/imports.py`, import `is_ssiplus_v3_bytes`, `looks_like_ssiplus_v3_bytes`, and `ingest_ssiplus_v3_bytes`; choose V2 when either the header matches exactly or the upload looks like an SSI Plus TSV candidate, so malformed SSI Plus-like files create V2 source/header rejects. Use V1 CSV ingestion only when the upload is not SSI Plus-like. + +**Step 3: Verify** + +Run targeted tests and `make test`. + +--- + +### Task 5: Add V2 dashboard control aggregates + +**Objective:** Add control-first read endpoints over the new V2 domain layer. + +**Files:** +- Modify: `app/api/dashboard.py` +- Test: `tests/test_v2_dashboard.py` + +**Step 1: Write failing tests** + +After importing the synthetic SSI Plus TSV, call `/api/v1/dashboard/v2/source-controls` and assert it returns: + +- `source_files.total` +- `instructions.total` +- `coverage.by_currency` +- `coverage.by_asset_category` +- `coverage.by_owner_country` +- `coverage.by_account_holder_country` +- `preferred.by_flag` +- `conflicts.preferred_conflict_groups` +- `conflicts.overlapping_active_groups` +- `lifecycle.missing_start_date` +- `freshness.missing_update_date` +- no raw account values, institution names, record keys, BDP keys, EIDs, or city values appear anywhere in the JSON response + +Run: `.venv/bin/python -m pytest tests/test_v2_dashboard.py -q` +Expected: FAIL because endpoint does not exist. + +**Step 2: Implement** + +Add `/api/v1/dashboard/v2/source-controls` that computes counts from `SourceFile`, `SourceReject`, and `SsiInstruction` using SQLAlchemy queries. Include conflict counts based on grouped active instructions sharing owner BIC, currency, asset category, and account-holder BIC where more than one preferred instruction exists or overlapping active date intervals exist. Avoid exposing raw accounts, source keys, BDP/EID values, city names, or institution names. + +**Step 3: Verify** + +Run V2 dashboard tests and `make test`. + +--- + +### Task 6: Update docs and final verification + +**Objective:** Document the V2 slice and verify with local tests plus private redacted-sample smoke if available. + +**Files:** +- Modify: `README.md` +- Modify: `docs/v2_business_logic_gap_analysis.md` or add `docs/v2_source_truth_slice.md` + +**Steps:** + +1. Add README notes for SSI Plus V3 upload support and the V2 dashboard endpoint. +2. Add a short V2 slice doc explaining what is implemented now vs deferred. +3. Run: + - `.venv/bin/python -m pytest tests/test_ssiplus_v3_parser.py tests/test_ssiplus_v3_ingestion.py tests/test_import_ssiplus_v3_api.py tests/test_v2_dashboard.py -q` + - `make test` + - mandatory-if-present private smoke against `/Users/Shared/AgentWork/knowledge/swift-payments/swiftref/verification/ssi-control-tower-real-sample-2026-05-06/ssiplus_v3_real_profile_redacted_sample_100.tsv`, printing only aggregate counts and no raw rows/identifiers. +4. Run Codex final diff review read-only and fix any blockers. diff --git a/apps/ssi-control-tower/docs/v2_ssiplus_source_truth_slice.md b/apps/ssi-control-tower/docs/v2_ssiplus_source_truth_slice.md new file mode 100644 index 0000000..6afa536 --- /dev/null +++ b/apps/ssi-control-tower/docs/v2_ssiplus_source_truth_slice.md @@ -0,0 +1,120 @@ +# V2 SSI Plus source-truth slice + +This V2 slice pivots SSI Control Tower from the original synthetic canonical CSV demo toward a real SSI Plus-shaped source-truth and lifecycle-governance foundation. + +## Implemented now + +- Exact `SSIPLUS_V3` parser for the synthetic 28-field tab-delimited sample in `data/sample_swiftref_ssiplus_v3_synthetic.tsv`. +- Upload auto-routing in `/api/v1/imports`: + - exact SSI Plus V3 header goes to the V2 importer; + - malformed SSI Plus-like TSV goes to the V2 source reject ledger; + - non-SSI Plus CSV stays on the V1 importer. +- Source-truth tables: + - `source_files` + - `source_records` + - `source_rejects` +- Domain tables: + - `institutions` + - `ssi_instructions` + - `ssi_source_links` +- Source lineage for imported rows: + - file hash + - row number + - source record key + - parser version + - import batch link +- Real SSI Plus-oriented domain fields: + - owner BIC/country + - ISO currency + - asset category + - account-holding institution BIC/country/type + - masked account + - preferred flag + - start/stop/update/traffic dates + - BDP/EID cross-links +- Idempotent re-import for the same file content. +- Control-first dashboard API: + - `GET /api/v1/dashboard/v2/source-controls` +- Home dashboard cards for the V2 source-truth slice. + +## Privacy guardrails + +The V2 parser and dashboard are intentionally privacy-conservative. + +Responses, audit payloads, source rejects, and dashboard aggregates must not expose: + +- raw account values +- source record keys +- BDP keys +- EIDs +- institution names +- city values + +The dashboard exposes only counts and code-shaped categories such as currency codes, asset categories, country codes, and preferred-flag buckets. + +## Synthetic sample + +The public sample file is synthetic and has: + +- path: `data/sample_swiftref_ssiplus_v3_synthetic.tsv` +- header fields: `28` +- data rows: `60` + +No private SSI records or unmasked account values are included. + +## Demo commands + +Run the app: + +```bash +make run +``` + +Import the synthetic SSI Plus sample: + +```bash +curl -s -X POST http://localhost:8000/api/v1/imports \ + -H 'X-User-Email: ops-owner@example.com' \ + -F 'file=@data/sample_swiftref_ssiplus_v3_synthetic.tsv;type=text/tab-separated-values' | jq +``` + +Read V2 source controls: + +```bash +curl -s http://localhost:8000/api/v1/dashboard/v2/source-controls | jq +``` + +Open the dashboard: + +```text +http://localhost:8000 +``` + +## Deferred beyond this slice + +- Full migration from V1 `SsiRecord` readiness scoring to V2 `SsiInstruction` lifecycle scoring. +- Real SwiftRef/current-directory validation. +- Real external SSI utility integration. +- Alembic/PostgreSQL production migrations. +- Full V2 exception workflow on `ssi_instructions` instead of V1 `ssi_records`. +- Rule-pack rewrite around SSI Plus lifecycle controls. + +## Test coverage + +V2 tests cover: + +- table creation and expected columns +- parser/header detection and redaction +- source-truth/domain ingestion +- idempotent re-import +- malformed SSI Plus-like reject routing +- API upload routing +- V2 source-control dashboard aggregates +- web dashboard visibility +- no leakage of raw sensitive sample values in audit/dashboard/reject outputs + +Run: + +```bash +make test +``` diff --git a/apps/ssi-control-tower/pyproject.toml b/apps/ssi-control-tower/pyproject.toml new file mode 100644 index 0000000..ebe8b4b --- /dev/null +++ b/apps/ssi-control-tower/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "ssi-control-tower" +version = "0.1.0" +description = "Synthetic SSI readiness and governance control tower" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.111", + "uvicorn[standard]>=0.29", + "pydantic>=2.7", + "sqlalchemy>=2.0", + "pyyaml>=6.0", + "jinja2>=3.1", + "python-multipart>=0.0.9", + "httpx>=0.27", +] + +[project.optional-dependencies] +test = ["pytest>=8.2"] + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/apps/ssi-control-tower/rules/duplicate_active.yaml b/apps/ssi-control-tower/rules/duplicate_active.yaml new file mode 100644 index 0000000..89f7988 --- /dev/null +++ b/apps/ssi-control-tower/rules/duplicate_active.yaml @@ -0,0 +1,9 @@ +- rule_id: SSI.DUPLICATE.ACTIVE + name: Duplicate active SSI context + severity: critical + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: duplicate_active + message: More than one active-like SSI overlaps in the same legal entity, account, asset, market, currency, and method context. + suggested_fix: Retire or correct the obsolete duplicate instruction and keep one current owner-approved instruction. diff --git a/apps/ssi-control-tower/rules/formats.yaml b/apps/ssi-control-tower/rules/formats.yaml new file mode 100644 index 0000000..b9ec1ce --- /dev/null +++ b/apps/ssi-control-tower/rules/formats.yaml @@ -0,0 +1,27 @@ +- rule_id: SSI.FORMAT.BIC + name: BIC syntax check + severity: high + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: bic_format + message: Present BIC fields must be 8 or 11 uppercase alphanumeric characters. + suggested_fix: Correct the BIC syntax using authorized SSI evidence. +- rule_id: SSI.FORMAT.COUNTRY_CODE + name: ISO country code check + severity: medium + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: country_code + message: Country code must be two letters and present in the synthetic country directory. + suggested_fix: Normalize country aliases to a valid two-letter code. +- rule_id: SSI.FORMAT.CURRENCY_CODE + name: ISO currency code check + severity: medium + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: currency_code + message: Currency code must be three letters and present in the synthetic currency directory. + suggested_fix: Normalize currency aliases to a valid three-letter code. diff --git a/apps/ssi-control-tower/rules/governance.yaml b/apps/ssi-control-tower/rules/governance.yaml new file mode 100644 index 0000000..3cbe1d6 --- /dev/null +++ b/apps/ssi-control-tower/rules/governance.yaml @@ -0,0 +1,18 @@ +- rule_id: SSI.GOVERNANCE.OWNER_REQUIRED + name: Owner required for active SSI + severity: high + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: owner_required + message: Active-like SSI has no accountable owner. + suggested_fix: Assign the SSI to an operations or reference-data owner. +- rule_id: SSI.DATE.INVALID_RANGE + name: Effective date range is valid + severity: high + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: invalid_date_range + message: effective_to must be greater than or equal to effective_from. + suggested_fix: Correct the effective date range from approved change evidence. diff --git a/apps/ssi-control-tower/rules/required_fields.yaml b/apps/ssi-control-tower/rules/required_fields.yaml new file mode 100644 index 0000000..05e3e26 --- /dev/null +++ b/apps/ssi-control-tower/rules/required_fields.yaml @@ -0,0 +1,18 @@ +- rule_id: SSI.REQUIRED.MARKET_FIELDS + name: Active SSIs require market context fields + severity: critical + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: required_market_fields + message: Active-like SSIs need market, country_code, asset_class, and settlement_method. + suggested_fix: Populate missing market-context fields from an approved source template. +- rule_id: SSI.REQUIRED.EFFECTIVE_FROM + name: Active SSIs require effective_from + severity: high + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: required_effective_from + message: Active-like SSIs need an effective_from date. + suggested_fix: Confirm the date from owner evidence and update effective_from. diff --git a/apps/ssi-control-tower/rules/stale.yaml b/apps/ssi-control-tower/rules/stale.yaml new file mode 100644 index 0000000..2b93104 --- /dev/null +++ b/apps/ssi-control-tower/rules/stale.yaml @@ -0,0 +1,18 @@ +- rule_id: SSI.STALE.12M + name: Confirmation older than 12 months + severity: high + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: stale_12m + message: Last confirmation is older than 365 days. + suggested_fix: Reconfirm the SSI with the accountable owner and attach evidence. +- rule_id: SSI.STALE.CRITICAL_MARKET + name: Stale instruction in T+1 critical market + severity: critical + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: stale_critical_market + message: Stale SSI exists in a T+1 critical market. + suggested_fix: Prioritize owner reconfirmation before T+1 settlement cut-off risk materializes. diff --git a/apps/ssi-control-tower/rules/t1_readiness.yaml b/apps/ssi-control-tower/rules/t1_readiness.yaml new file mode 100644 index 0000000..848ee8b --- /dev/null +++ b/apps/ssi-control-tower/rules/t1_readiness.yaml @@ -0,0 +1,18 @@ +- rule_id: SSI.T1.RECENT_CONFIRMATION + name: T+1 market recent confirmation + severity: high + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: t1_recent_confirmation + message: T+1 market SSI confirmation is older than 180 days. + suggested_fix: Reconfirm the instruction before compressed settlement timelines apply. +- rule_id: SSI.T1.NO_CRITICAL_EXCEPTIONS + name: T+1 market has no unresolved critical exceptions + severity: critical + version: "1.0" + enabled: true + applies_to: ssi_records + evaluator: t1_no_critical_exceptions + message: T+1 market SSI has unresolved critical exceptions. + suggested_fix: Resolve or waive critical exceptions before export or publication. diff --git a/apps/ssi-control-tower/tests/conftest.py b/apps/ssi-control-tower/tests/conftest.py new file mode 100644 index 0000000..5bbee3f --- /dev/null +++ b/apps/ssi-control-tower/tests/conftest.py @@ -0,0 +1,27 @@ +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + db_path = tmp_path / "ssi.db" + monkeypatch.setenv("SSI_DB_PATH", str(db_path)) + monkeypatch.setenv("SSI_EXPORT_DIR", str(tmp_path / "exports")) + import app.db as db + db.reset_engine() + from app.main import create_app + app = create_app() + with TestClient(app) as test_client: + yield test_client + db.reset_engine() + + +@pytest.fixture() +def db_session(client): + from app.db import SessionLocal + with SessionLocal() as session: + yield session + + +def actor(email: str) -> dict[str, str]: + return {"X-User-Email": email} diff --git a/apps/ssi-control-tower/tests/test_app_lifespan.py b/apps/ssi-control-tower/tests/test_app_lifespan.py new file mode 100644 index 0000000..8626085 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_app_lifespan.py @@ -0,0 +1,17 @@ +import importlib +import warnings + + +def test_main_import_does_not_use_deprecated_startup_events(): + import app.main + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + importlib.reload(app.main) + + +def test_lifespan_initializes_seeded_application(client): + response = client.get("/api/v1/dashboard/readiness-score") + + assert response.status_code == 200 + assert response.json()["total_ssis"] > 0 diff --git a/apps/ssi-control-tower/tests/test_approvals_four_eyes.py b/apps/ssi-control-tower/tests/test_approvals_four_eyes.py new file mode 100644 index 0000000..7948745 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_approvals_four_eyes.py @@ -0,0 +1,81 @@ +from tests.conftest import actor + + +def _first_pair_member(client): + exceptions = client.get("/api/v1/exceptions", params={"rule_id": "SSI.DUPLICATE.ACTIVE"}).json() + pair_context = next(ctx for ctx in {item["context_key"] for item in exceptions} if sum(1 for item in exceptions if item["context_key"] == ctx) == 2) + return next(item["ssi_id"] for item in exceptions if item["context_key"] == pair_context) + + +def test_direct_critical_edit_rejected_and_noncritical_edit_allowed(client): + ssi_id = _first_pair_member(client) + blocked = client.patch( + f"/api/v1/ssis/{ssi_id}", + json={"local_agent_bic": "AGNTFRPPXXX"}, + headers=actor("ops-owner@example.com"), + ) + assert blocked.status_code == 409 + assert blocked.json()["code"] == "APPROVAL_REQUIRED" + + allowed = client.patch( + f"/api/v1/ssis/{ssi_id}", + json={"owner_team": "Reference Data"}, + headers=actor("ops-owner@example.com"), + ) + assert allowed.status_code == 200, allowed.text + assert allowed.json()["owner_team"] == "Reference Data" + + bypass = client.patch( + f"/api/v1/ssis/{ssi_id}", + json={"status": "published"}, + headers=actor("ops-owner@example.com"), + ) + assert bypass.status_code == 400 + assert bypass.json()["code"] == "UNSUPPORTED_SSI_UPDATE_FIELD" + + +def test_approval_submission_rejects_unmasked_account_payload_without_audit_leak(client): + ssi_id = _first_pair_member(client) + raw_value = "RAW-UNMASKED-SYNTHETIC-ACCOUNT" + + submitted = client.post( + f"/api/v1/ssis/{ssi_id}/submit-for-approval", + json={ + "change_payload": {"securities_account_masked": raw_value}, + "change_summary": "Attempt unsafe account update", + "evidence_reference": "EV-UNSAFE", + }, + headers=actor("ops-owner@example.com"), + ) + + assert submitted.status_code == 400 + assert submitted.json()["code"] == "UNMASKED_ACCOUNT_FIELD" + audit_events = client.get("/api/v1/audit-events", params={"entity_id": ssi_id}).text + assert raw_value not in audit_events + + +def test_four_eyes_approval_blocks_self_approval_and_applies_change(client): + ssi_id = _first_pair_member(client) + submitted = client.post( + f"/api/v1/ssis/{ssi_id}/submit-for-approval", + json={ + "change_payload": {"local_agent_bic": "AGNTFRPPXXX"}, + "change_summary": "Correct local agent BIC after owner review", + "evidence_reference": "EV-001", + }, + headers=actor("ops-owner@example.com"), + ) + assert submitted.status_code == 201, submitted.text + approval_id = submitted.json()["approval_id"] + + self_approval = client.post(f"/api/v1/approvals/{approval_id}/approve", headers=actor("ops-owner@example.com")) + assert self_approval.status_code == 403 + assert self_approval.json()["code"] == "SELF_APPROVAL_REJECTED" + + approved = client.post(f"/api/v1/approvals/{approval_id}/approve", headers=actor("approver@example.com")) + assert approved.status_code == 200, approved.text + assert approved.json()["status"] == "approved" + ssi = client.get(f"/api/v1/ssis/{ssi_id}").json() + assert ssi["local_agent_bic"] == "AGNTFRPPXXX" + assert ssi["status"] == "approved" + assert ssi["approval_status"] == "approved" diff --git a/apps/ssi-control-tower/tests/test_audit_immutability.py b/apps/ssi-control-tower/tests/test_audit_immutability.py new file mode 100644 index 0000000..8634e9a --- /dev/null +++ b/apps/ssi-control-tower/tests/test_audit_immutability.py @@ -0,0 +1,17 @@ +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from app.models import AuditEvent + + +def test_audit_events_are_insert_only_with_sqlite_triggers(db_session): + event = db_session.query(AuditEvent).first() + assert event is not None + with pytest.raises(DBAPIError): + db_session.execute(text("UPDATE audit_events SET action='tamper' WHERE audit_event_id=:event_id"), {"event_id": event.audit_event_id}) + db_session.commit() + db_session.rollback() + with pytest.raises(DBAPIError): + db_session.execute(text("DELETE FROM audit_events WHERE audit_event_id=:event_id"), {"event_id": event.audit_event_id}) + db_session.commit() diff --git a/apps/ssi-control-tower/tests/test_csv_hybrid_ingestion.py b/apps/ssi-control-tower/tests/test_csv_hybrid_ingestion.py new file mode 100644 index 0000000..a634ca1 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_csv_hybrid_ingestion.py @@ -0,0 +1,51 @@ +from __future__ import annotations + + +def _csv_bytes() -> bytes: + return ( + "Entity Name,LEI,BIC,Account Name,Account Number,Fund Code,Base Currency,Asset Class,Market,Country,Currency,PSET BIC,Global Custodian BIC,Securities Account,Cash Account,Settlement Method,Effective From,Owner Email,Source System\n" + "Synthetic Demo Fund,529900SYNTHSAFE0001,OWNRUS33XXX,Synthetic Main,ACCT-****-4242,SAFE,USD,Equity,US,US,USD,PSETUS33XXX,HOLDUS33XXX,ACCT-****-4242,CASH-****-4242,DVP,2026-01-01,ops-owner@example.com,CSV_DEMO\n" + ).encode("utf-8") + + +def test_csv_import_creates_legacy_records_and_canonical_instructions(db_session): + from app.models import SourceRecord, SsiInstruction, SsiRecord, SsiSourceLink, ValidationResult + from app.services.ingestion import ingest_csv_bytes + + before_legacy = db_session.query(SsiRecord).count() + before_instructions = db_session.query(SsiInstruction).count() + + batch = ingest_csv_bytes( + db_session, + _csv_bytes(), + file_name="hybrid.csv", + uploaded_by="analyst@example.com", + run_validation=True, + ) + + assert batch.records_imported == 1 + assert db_session.query(SsiRecord).count() == before_legacy + 1 + assert db_session.query(SsiInstruction).count() == before_instructions + 1 + assert db_session.query(SourceRecord).filter_by(import_id=batch.import_id).count() == 1 + instruction = db_session.query(SsiInstruction).filter_by(source_record_key="csv:hybrid.csv:2").one() + assert instruction.account_number_masked == "ACCT-****-4242" + assert db_session.query(SsiSourceLink).filter_by(ssi_instruction_id=instruction.ssi_instruction_id).count() == 1 + assert db_session.query(ValidationResult).count() > 0 + + +def test_csv_hybrid_import_keeps_import_batch_counts_compatible(db_session): + from app.services.ingestion import ingest_csv_bytes + + batch = ingest_csv_bytes( + db_session, + _csv_bytes(), + file_name="hybrid-counts.csv", + uploaded_by="analyst@example.com", + run_validation=False, + ) + + assert batch.source_system == "CSV_DEMO" + assert batch.records_received == 1 + assert batch.records_imported == 1 + assert batch.records_rejected == 0 + assert batch.status == "completed" diff --git a/apps/ssi-control-tower/tests/test_csv_source_adapter.py b/apps/ssi-control-tower/tests/test_csv_source_adapter.py new file mode 100644 index 0000000..d6e995d --- /dev/null +++ b/apps/ssi-control-tower/tests/test_csv_source_adapter.py @@ -0,0 +1,135 @@ +from __future__ import annotations + + +def _csv_row(**overrides: str) -> dict[str, str]: + row = { + "Entity Name": "Synthetic Demo Fund", + "LEI": "529900SYNTHSAFE0001", + "BIC": "ownrus33xxx", + "Account Name": "Synthetic Main", + "Account Number": "ACCT-****-4242", + "Asset Class": "Equities", + "Market": "United States", + "Country": "United States", + "Currency": "usd", + "Global Custodian BIC": "holdus33xxx", + "Securities Account": "ACCT-****-4242", + "Cash Account": "CASH-****-4242", + "Effective From": "2026-01-01", + "Source System": "CSV_DEMO", + } + row.update(overrides) + return row + + +def test_csv_adapter_maps_rows_to_canonical_instruction_candidates(): + from app.services.source_adapters import adapt_csv_rows_to_instruction_candidates + + result = adapt_csv_rows_to_instruction_candidates([_csv_row()], file_name="demo.csv") + + assert result.source_system == "CSV_DEMO" + assert result.source_schema == "CSV_DEMO_V1" + assert result.parser_version + assert result.rejects == [] + assert len(result.candidates) == 1 + candidate = result.candidates[0] + assert candidate.owner_bic == "OWNRUS33XXX" + assert candidate.currency_code == "USD" + assert candidate.asset_category == "equity" + assert candidate.account_holder_bic == "HOLDUS33XXX" + assert candidate.account_number_masked == "ACCT-****-4242" + assert candidate.start_date == "2026-01-01" + assert candidate.source_row_fingerprint.startswith("csv:") + + +def test_csv_adapter_rejects_missing_required_fields_without_raw_row(): + from app.services.source_adapters import adapt_csv_rows_to_instruction_candidates + + result = adapt_csv_rows_to_instruction_candidates( + [_csv_row(**{"Currency": "", "Global Custodian BIC": ""})], + file_name="demo.csv", + ) + + assert result.candidates == [] + assert len(result.rejects) == 1 + reject = result.rejects[0] + assert reject.row_number == 2 + assert reject.reject_code == "row.missing_required_fields" + assert "currency_code" in reject.reject_message + assert "Global Custodian" not in reject.reject_message + assert not hasattr(reject, "raw_row") + + +def test_csv_adapter_rejects_missing_account_fields_without_creating_placeholder_candidate(): + from app.services.source_adapters import adapt_csv_rows_to_instruction_candidates + + result = adapt_csv_rows_to_instruction_candidates( + [_csv_row(**{"Account Number": "", "Securities Account": ""})], + file_name="demo.csv", + ) + + assert result.candidates == [] + assert len(result.rejects) == 1 + assert result.rejects[0].reject_code == "row.invalid_account_fields" + assert "account_number_masked" in result.rejects[0].reject_message + assert "securities_account_masked" in result.rejects[0].reject_message + assert "ACCT-****" not in result.rejects[0].reject_message + + +def test_csv_adapter_rejects_blank_securities_account_even_when_account_number_exists(): + from app.services.source_adapters import adapt_csv_rows_to_instruction_candidates + + result = adapt_csv_rows_to_instruction_candidates( + [_csv_row(**{"Securities Account": "", "Account Number": "ACCT-****-4242"})], + file_name="demo.csv", + ) + + assert result.candidates == [] + assert len(result.rejects) == 1 + assert result.rejects[0].reject_code == "row.invalid_account_fields" + assert "securities_account_masked" in result.rejects[0].reject_message + + +def test_csv_adapter_rejects_blank_account_number_even_when_securities_account_exists(): + from app.services.source_adapters import adapt_csv_rows_to_instruction_candidates + + result = adapt_csv_rows_to_instruction_candidates( + [_csv_row(**{"Account Number": "", "Securities Account": "ACCT-****-4242"})], + file_name="demo.csv", + ) + + assert result.candidates == [] + assert len(result.rejects) == 1 + assert result.rejects[0].reject_code == "row.invalid_account_fields" + assert "account_number_masked" in result.rejects[0].reject_message + + +def test_csv_adapter_rejects_raw_unmasked_account_values(): + from app.services.source_adapters import adapt_csv_rows_to_instruction_candidates + + result = adapt_csv_rows_to_instruction_candidates( + [ + _csv_row( + **{ + "Account Number": "1234567890", + "Securities Account": "SEC1234567890", + "Cash Account": "CASH1234567890", + } + ) + ], + file_name="demo.csv", + ) + + assert result.candidates == [] + assert len(result.rejects) == 1 + assert result.rejects[0].reject_code == "row.invalid_account_fields" + assert "1234567890" not in result.rejects[0].reject_message + + +def test_csv_adapter_never_returns_unmasked_account_candidates(): + from app.services.source_adapters import adapt_csv_rows_to_instruction_candidates + + result = adapt_csv_rows_to_instruction_candidates([_csv_row()], file_name="demo.csv") + + assert result.candidates + assert all("****" in candidate.account_number_masked for candidate in result.candidates) diff --git a/apps/ssi-control-tower/tests/test_e2e_demo_flow.py b/apps/ssi-control-tower/tests/test_e2e_demo_flow.py new file mode 100644 index 0000000..b3435ca --- /dev/null +++ b/apps/ssi-control-tower/tests/test_e2e_demo_flow.py @@ -0,0 +1,75 @@ +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from app.models import AuditEvent +from tests.conftest import actor + + +def test_e2e_demo_flow(client, db_session): + readiness = client.get("/api/v1/dashboard/readiness-score").json() + assert readiness["band"] == "Red" + assert 50 <= readiness["score"] <= 74 + + critical_duplicates = client.get("/api/v1/exceptions", params={"severity": "critical", "rule_id": "SSI.DUPLICATE.ACTIVE"}).json() + assert len(critical_duplicates) >= 3 + pair_context = next(ctx for ctx in {item["context_key"] for item in critical_duplicates} if sum(1 for item in critical_duplicates if item["context_key"] == ctx) == 2) + pair = [item for item in critical_duplicates if item["context_key"] == pair_context] + stale_member = next(item for item in pair if client.get(f"/api/v1/ssis/{item['ssi_id']}").json()["last_confirmed_at"] < "2025-01-01") + remaining_member = next(item for item in pair if item["ssi_id"] != stale_member["ssi_id"]) + + assigned = client.patch( + f"/api/v1/exceptions/{stale_member['exception_id']}/assign", + json={"owner_user_email": "ops-owner@example.com"}, + headers=actor("analyst@example.com"), + ) + assert assigned.status_code == 200, assigned.text + assert assigned.json()["status"] == "assigned" + assert any(event["action"] == "exception.assigned" for event in client.get("/api/v1/audit-events", params={"entity_id": stale_member["exception_id"]}).json()) + + retired = client.post(f"/api/v1/ssis/{stale_member['ssi_id']}/retire", headers=actor("ops-owner@example.com")) + assert retired.status_code == 200, retired.text + assert retired.json()["status"] == "retired" + + submitted = client.post( + f"/api/v1/ssis/{remaining_member['ssi_id']}/submit-for-approval", + json={ + "change_payload": {"local_agent_bic": "AGNTFRPPXXX"}, + "change_summary": "Correct local agent BIC after owner review", + "evidence_reference": "EV-001", + }, + headers=actor("ops-owner@example.com"), + ) + assert submitted.status_code == 201, submitted.text + approval_id = submitted.json()["approval_id"] + + self_approval = client.post(f"/api/v1/approvals/{approval_id}/approve", headers=actor("ops-owner@example.com")) + assert self_approval.status_code == 403 + + approval = client.post(f"/api/v1/approvals/{approval_id}/approve", headers=actor("approver@example.com")) + assert approval.status_code == 200, approval.text + assert approval.json()["evidence_reference"] == "EV-001" + + ssi = client.get(f"/api/v1/ssis/{remaining_member['ssi_id']}").json() + assert ssi["status"] == "approved" + pair_exceptions_after = client.get("/api/v1/exceptions", params={"rule_id": "SSI.DUPLICATE.ACTIVE", "status": "open"}).json() + assert all(item["context_key"] != pair_context for item in pair_exceptions_after) + + readiness_after = client.get("/api/v1/dashboard/readiness-score").json() + assert readiness_after["band"] == "Amber" + assert 75 <= readiness_after["score"] <= 89 + + export = client.post("/api/v1/exports", json={"format": "csv"}, headers=actor("approver@example.com")).json() + csv_text = client.get(f"/api/v1/exports/{export['export_id']}/download").text + assert "approved" in csv_text or "published" in csv_text + assert "validation_failed" not in csv_text + assert "retired" not in csv_text + + event = db_session.query(AuditEvent).first() + assert event is not None + try: + db_session.execute(text("UPDATE audit_events SET action='tamper' WHERE audit_event_id=:event_id"), {"event_id": event.audit_event_id}) + db_session.commit() + except DBAPIError: + db_session.rollback() + else: + raise AssertionError("audit_events update unexpectedly succeeded") diff --git a/apps/ssi-control-tower/tests/test_exceptions.py b/apps/ssi-control-tower/tests/test_exceptions.py new file mode 100644 index 0000000..966d011 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_exceptions.py @@ -0,0 +1,36 @@ +from tests.conftest import actor + + +def test_exception_assignment_sets_owner_status_and_audit(client): + exception = client.get("/api/v1/exceptions", params={"rule_id": "SSI.DUPLICATE.ACTIVE"}).json()[0] + response = client.patch( + f"/api/v1/exceptions/{exception['exception_id']}/assign", + json={"owner_user_email": "ops-owner@example.com"}, + headers=actor("analyst@example.com"), + ) + assert response.status_code == 200, response.text + assigned = response.json() + assert assigned["status"] == "assigned" + assert assigned["owner_user_email"] == "ops-owner@example.com" + audit = client.get("/api/v1/audit-events", params={"entity_id": exception["exception_id"]}).json() + assert any(event["action"] == "exception.assigned" for event in audit) + + +def test_exception_resolve_and_waive_endpoints(client): + exception = client.get("/api/v1/exceptions", params={"severity": "medium"}).json()[0] + resolved = client.patch( + f"/api/v1/exceptions/{exception['exception_id']}/resolve", + json={"resolution_evidence": "EV-RESOLVE-001"}, + headers=actor("ops-owner@example.com"), + ) + assert resolved.status_code == 200, resolved.text + assert resolved.json()["status"] == "closed" + + exception = client.get("/api/v1/exceptions", params={"severity": "high"}).json()[0] + waived = client.patch( + f"/api/v1/exceptions/{exception['exception_id']}/waive", + json={"reason": "Accepted synthetic demonstration risk", "expiry_date": "2026-12-31"}, + headers=actor("risk@example.com"), + ) + assert waived.status_code == 200, waived.text + assert waived.json()["status"] == "waived" diff --git a/apps/ssi-control-tower/tests/test_export.py b/apps/ssi-control-tower/tests/test_export.py new file mode 100644 index 0000000..222b84a --- /dev/null +++ b/apps/ssi-control-tower/tests/test_export.py @@ -0,0 +1,25 @@ +from tests.conftest import actor + + +def test_export_contains_only_approved_or_published_records(client): + response = client.post("/api/v1/exports", json={"format": "csv"}, headers=actor("approver@example.com")) + assert response.status_code == 201, response.text + export = response.json() + assert export["format"] == "csv" + assert export["record_count"] > 0 + downloaded = client.get(f"/api/v1/exports/{export['export_id']}/download") + assert downloaded.status_code == 200 + lines = downloaded.text.strip().splitlines() + assert len(lines) == export["record_count"] + 1 + assert "status" in lines[0] + for line in lines[1:]: + assert ",approved," in line or ",published," in line + + +def test_repeated_exports_do_not_collide_on_identifier(client): + first = client.post("/api/v1/exports", json={"format": "json"}, headers=actor("approver@example.com")) + second = client.post("/api/v1/exports", json={"format": "json"}, headers=actor("approver@example.com")) + + assert first.status_code == 201, first.text + assert second.status_code == 201, second.text + assert first.json()["export_id"] != second.json()["export_id"] diff --git a/apps/ssi-control-tower/tests/test_import_ssiplus_v3_api.py b/apps/ssi-control-tower/tests/test_import_ssiplus_v3_api.py new file mode 100644 index 0000000..82289fb --- /dev/null +++ b/apps/ssi-control-tower/tests/test_import_ssiplus_v3_api.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path + +SAMPLE = Path(__file__).resolve().parent.parent / "data" / "sample_swiftref_ssiplus_v3_synthetic.tsv" + + +def test_post_ssiplus_v3_routes_to_v2(client): + with SAMPLE.open("rb") as handle: + response = client.post( + "/api/v1/imports", + files={"file": ("ssiplus.tsv", handle, "text/tab-separated-values")}, + headers={"X-User-Email": "ops-owner@example.com"}, + ) + assert response.status_code == 201, response.text + payload = response.json() + assert payload["source_system"] == "SWIFTREF_SSIPLUS" + assert payload["source_template_id"] == "SSIPLUS_V3" + assert payload["records_imported"] > 0 + assert payload["records_rejected"] == 0 + + +def test_post_malformed_ssiplus_v3_creates_v2_source_reject(client, db_session): + from app.models import SourceFile, SourceReject + + bad_payload = ( + b"MODIFICATION FLAG\tRECORD KEY\tBIC OWNER\tINSTITUTION NAME OWNER\n" + b"A\tSSI000000001\tORIOUS33XXX\tOrion Synthetic\n" + ) + response = client.post( + "/api/v1/imports", + files={"file": ("malformed.tsv", bad_payload, "text/tab-separated-values")}, + headers={"X-User-Email": "ops-owner@example.com"}, + ) + assert response.status_code == 201, response.text + payload = response.json() + assert payload["source_system"] == "SWIFTREF_SSIPLUS" + assert payload["records_imported"] == 0 + assert payload["records_rejected"] >= 1 + + rejects = db_session.query(SourceReject).all() + assert rejects, "expected V2 source reject rows" + files = db_session.query(SourceFile).filter(SourceFile.file_name == "malformed.tsv").all() + assert len(files) == 1 diff --git a/apps/ssi-control-tower/tests/test_ingestion.py b/apps/ssi-control-tower/tests/test_ingestion.py new file mode 100644 index 0000000..8d9ee75 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_ingestion.py @@ -0,0 +1,121 @@ +from app.models import Account, ImportBatch, SsiRecord, User + + +def test_seed_database_creates_exactly_25_ssis_and_6_users(db_session): + assert db_session.query(SsiRecord).count() == 25 + assert db_session.query(User).count() == 6 + assert db_session.query(ImportBatch).count() == 1 + + +def test_ingestion_preserves_only_masked_account_numbers(db_session): + records = db_session.query(SsiRecord).all() + assert records + for record in records: + assert "****" in record.securities_account_masked + assert "****" in record.cash_account_masked + assert not any(ch.isdigit() for ch in record.securities_account_masked.replace("1042", "")) + accounts = db_session.query(Account).all() + assert accounts + assert all("****" in account.account_number_masked for account in accounts) + + +def test_import_endpoint_accepts_mapped_csv(client, tmp_path): + csv_path = tmp_path / "mini.csv" + csv_path.write_text( + "Entity Name,LEI,BIC,Jurisdiction,Account Name,Account Number,Fund Code,Base Currency,Asset Class,Market,Country,Currency,PSET BIC,Depository,Global Custodian BIC,Local Agent BIC,Securities Account,Cash Account,Settlement Method,Effective From,Owner Email,Last Confirmed,Source System\n" + "Vega Demo Fund,529900SYNTHVEGA00001,VEGAGB2L,GB,Vega Main,SEC-****-9090,VEGA,GBP,Equity,GB,GB,GBP,NSTAGB2L,CREST,CUSTGB2LXXX,AGNTFRPPXXX,SEC-****-9090,CASH-****-9090,DVP,2026-01-01,ops-owner@example.com,2026-01-15,CSV_DEMO\n" + ) + with csv_path.open("rb") as handle: + response = client.post( + "/api/v1/imports", + files={"file": ("mini.csv", handle, "text/csv")}, + headers={"X-User-Email": "analyst@example.com"}, + ) + assert response.status_code == 201, response.text + payload = response.json() + assert payload["records_received"] == 1 + assert payload["records_imported"] == 1 + assert payload["records_rejected"] == 0 + + +def test_import_endpoint_rejects_csv_rows_with_blank_account_fields(client, db_session, tmp_path): + from app.models import LegalEntity, SsiRecord + + csv_path = tmp_path / "blank-account.csv" + csv_path.write_text( + "Entity Name,LEI,BIC,Jurisdiction,Account Name,Account Number,Fund Code,Base Currency,Asset Class,Market,Country,Currency,PSET BIC,Depository,Global Custodian BIC,Local Agent BIC,Securities Account,Cash Account,Settlement Method,Effective From,Owner Email,Last Confirmed,Source System\n" + "Blank Account Fund,529900SYNTHBLANK0001,BLNKGB2L,GB,Blank Main,,BLANK,GBP,Equity,GB,GB,GBP,NSTAGB2L,CREST,CUSTGB2LXXX,AGNTFRPPXXX,,,DVP,2026-01-01,ops-owner@example.com,2026-01-15,CSV_DEMO\n" + ) + assert db_session.query(LegalEntity).filter(LegalEntity.lei == "529900SYNTHBLANK0001").count() == 0 + + with csv_path.open("rb") as handle: + response = client.post( + "/api/v1/imports", + files={"file": ("blank-account.csv", handle, "text/csv")}, + headers={"X-User-Email": "analyst@example.com"}, + ) + + assert response.status_code == 201, response.text + payload = response.json() + assert payload["records_received"] == 1 + assert payload["records_imported"] == 0 + assert payload["records_rejected"] == 1 + db_session.expire_all() + assert db_session.query(LegalEntity).filter(LegalEntity.lei == "529900SYNTHBLANK0001").count() == 0 + assert ( + db_session.query(SsiRecord) + .join(LegalEntity, SsiRecord.legal_entity_id == LegalEntity.legal_entity_id) + .filter(LegalEntity.lei == "529900SYNTHBLANK0001") + .count() + == 0 + ) + + +def test_import_endpoint_rejects_csv_rows_with_blank_account_number_only(client, db_session, tmp_path): + from app.models import LegalEntity + + csv_path = tmp_path / "blank-account-number.csv" + csv_path.write_text( + "Entity Name,LEI,BIC,Jurisdiction,Account Name,Account Number,Fund Code,Base Currency,Asset Class,Market,Country,Currency,PSET BIC,Depository,Global Custodian BIC,Local Agent BIC,Securities Account,Cash Account,Settlement Method,Effective From,Owner Email,Last Confirmed,Source System\n" + "Blank Account Number Fund,529900SYNTHBLANK0002,BLN2GB2L,GB,Blank Number Main,,BLN2,GBP,Equity,GB,GB,GBP,NSTAGB2L,CREST,CUSTGB2LXXX,AGNTFRPPXXX,SEC-****-9091,CASH-****-9091,DVP,2026-01-01,ops-owner@example.com,2026-01-15,CSV_DEMO\n" + ) + + with csv_path.open("rb") as handle: + response = client.post( + "/api/v1/imports", + files={"file": ("blank-account-number.csv", handle, "text/csv")}, + headers={"X-User-Email": "analyst@example.com"}, + ) + + assert response.status_code == 201, response.text + payload = response.json() + assert payload["records_received"] == 1 + assert payload["records_imported"] == 0 + assert payload["records_rejected"] == 1 + db_session.expire_all() + assert db_session.query(LegalEntity).filter(LegalEntity.lei == "529900SYNTHBLANK0002").count() == 0 + + +def test_import_endpoint_rejects_csv_rows_with_unmasked_account_values(client, db_session, tmp_path): + from app.models import LegalEntity + + csv_path = tmp_path / "unmasked-account.csv" + csv_path.write_text( + "Entity Name,LEI,BIC,Jurisdiction,Account Name,Account Number,Fund Code,Base Currency,Asset Class,Market,Country,Currency,PSET BIC,Depository,Global Custodian BIC,Local Agent BIC,Securities Account,Cash Account,Settlement Method,Effective From,Owner Email,Last Confirmed,Source System\n" + "Raw Account Fund,529900SYNTHRAW000001,RAWGGB2L,GB,Raw Main,1234567890,RAW1,GBP,Equity,GB,GB,GBP,NSTAGB2L,CREST,CUSTGB2LXXX,AGNTFRPPXXX,SEC1234567890,CASH1234567890,DVP,2026-01-01,ops-owner@example.com,2026-01-15,CSV_DEMO\n" + ) + + with csv_path.open("rb") as handle: + response = client.post( + "/api/v1/imports", + files={"file": ("unmasked-account.csv", handle, "text/csv")}, + headers={"X-User-Email": "analyst@example.com"}, + ) + + assert response.status_code == 201, response.text + payload = response.json() + assert payload["records_received"] == 1 + assert payload["records_imported"] == 0 + assert payload["records_rejected"] == 1 + db_session.expire_all() + assert db_session.query(LegalEntity).filter(LegalEntity.lei == "529900SYNTHRAW000001").count() == 0 diff --git a/apps/ssi-control-tower/tests/test_instruction_exceptions.py b/apps/ssi-control-tower/tests/test_instruction_exceptions.py new file mode 100644 index 0000000..eb77566 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_instruction_exceptions.py @@ -0,0 +1,92 @@ +from __future__ import annotations + + +def _add_invalid_instruction(session): + from app.models import SsiInstruction + from app.services.audit import stable_id + + instruction = SsiInstruction( + ssi_instruction_id=stable_id("instruction-exception", "safe"), + source_record_key="fp-instruction-exception", + owner_institution_id="inst-owner", + owner_bic="OWNRUS33XXX", + owner_name=None, + owner_city=None, + owner_country_code="US", + currency_code="USD", + asset_category="equity", + account_holder_institution_id="inst-holder", + account_holder_bic="HOLDUS33XXX", + account_holder_name=None, + account_holder_country_code="US", + account_number_masked="ACCT-****-4242", + preferred_flag="Y", + account_holder_type=None, + group_key_owner=None, + record_key_bdp_owner=None, + eid_owner=None, + record_key_bdp_account_holder=None, + eid_account_holder=None, + update_date=None, + traffic_flag=None, + traffic_date=None, + start_date=None, + stop_date=None, + status="active", + created_at="2026-05-07T00:00:00Z", + updated_at="2026-05-07T00:00:00Z", + ) + session.add(instruction) + session.commit() + return instruction + + +def test_failed_instruction_validation_creates_instruction_exception(db_session): + from app.models import InstructionExceptionCase + from app.services.instruction_validation import validate_instruction + + instruction = _add_invalid_instruction(db_session) + + validate_instruction(db_session, instruction, actor="tester@example.com") + + case = db_session.query(InstructionExceptionCase).filter_by(ssi_instruction_id=instruction.ssi_instruction_id).one() + assert case.rule_id == "SSI.INSTRUCTION.START_DATE_REQUIRED" + assert case.status == "open" + assert case.description + + +def test_resolving_instruction_validation_closes_instruction_exception(db_session): + from app.models import InstructionExceptionCase + from app.services.instruction_validation import validate_instruction + + instruction = _add_invalid_instruction(db_session) + validate_instruction(db_session, instruction, actor="tester@example.com") + instruction.start_date = "2026-01-01" + db_session.commit() + + validate_instruction(db_session, instruction, actor="tester@example.com") + + case = db_session.query(InstructionExceptionCase).filter_by(ssi_instruction_id=instruction.ssi_instruction_id).one() + assert case.status == "closed" + assert case.resolved_at is not None + + +def test_instruction_exception_api_is_allowlisted_and_privacy_safe(client, db_session): + from app.services.instruction_validation import validate_instruction + + instruction = _add_invalid_instruction(db_session) + validate_instruction(db_session, instruction, actor="tester@example.com") + + response = client.get("/api/v1/instruction-exceptions") + + assert response.status_code == 200, response.text + payload = response.json() + assert payload + item = payload[0] + assert item["record_type"] == "ssi_instruction" + assert item["record_id"] == instruction.ssi_instruction_id + assert "source_record_key" not in item + assert "account_number_masked" not in item + text = str(payload) + for forbidden in ("fp-instruction-exception", "ACCT-****-4242", "OWNRUS33XXX", "HOLDUS33XXX"): + assert forbidden not in text diff --git a/apps/ssi-control-tower/tests/test_instruction_ingestion_lineage.py b/apps/ssi-control-tower/tests/test_instruction_ingestion_lineage.py new file mode 100644 index 0000000..bcd0874 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_instruction_ingestion_lineage.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import json + + +def _adapter_result(*, rejects=False): + from app.services.source_adapters import ( + CanonicalInstructionCandidate, + CanonicalSourceReject, + SourceAdapterResult, + ) + + return SourceAdapterResult( + source_system="CSV_DEMO", + source_schema="CSV_DEMO_V1", + parser_version="csv_demo_v1", + candidates=[] + if rejects + else [ + CanonicalInstructionCandidate( + source_system="CSV_DEMO", + source_schema="CSV_DEMO_V1", + row_number=2, + source_row_fingerprint="csv:lineage-safe-001", + owner_bic="OWNRUS33XXX", + currency_code="USD", + asset_category="equity", + account_holder_bic="HOLDUS33XXX", + account_number_masked="ACCT-****-4242", + preferred_flag="Y", + start_date="2026-01-01", + stop_date=None, + privacy_metadata={"source_fields": ["currency_code"]}, + ) + ], + rejects=[] + if not rejects + else [ + CanonicalSourceReject( + row_number=3, + reject_code="row.missing_required_fields", + reject_message="row missing required field(s): currency_code", + ) + ], + ) + + +def _audit_payload_text(session) -> str: + from app.models import AuditEvent + + payloads = [] + for event in session.query(AuditEvent).all(): + if event.new_value: + payloads.append(json.loads(event.new_value)) + if event.previous_value: + payloads.append(json.loads(event.previous_value)) + return json.dumps(payloads, sort_keys=True) + + +def test_shared_lineage_service_upserts_source_and_instruction_rows(db_session): + from app.models import Institution, SourceFile, SourceRecord, SsiInstruction, SsiSourceLink + from app.services.instruction_ingestion import ingest_instruction_adapter_result + + stats = ingest_instruction_adapter_result( + db_session, + adapter_result=_adapter_result(), + file_name="lineage.csv", + file_hash="hash-lineage", + import_id="import-lineage", + uploaded_by="ops@example.com", + now="2026-05-07T00:00:00Z", + ) + + assert stats.records_received == 1 + assert stats.records_accepted == 1 + assert stats.records_rejected == 0 + assert db_session.query(SourceFile).filter_by(import_id="import-lineage").count() == 1 + assert db_session.query(SourceRecord).filter_by(import_id="import-lineage").count() == 1 + assert db_session.query(Institution).filter(Institution.bic.in_(["OWNRUS33XXX", "HOLDUS33XXX"])).count() == 2 + instruction = db_session.query(SsiInstruction).filter_by(source_record_key="csv:lineage-safe-001").one() + assert instruction.account_number_masked == "ACCT-****-4242" + assert db_session.query(SsiSourceLink).filter_by(ssi_instruction_id=instruction.ssi_instruction_id).count() == 1 + + +def test_shared_lineage_service_is_idempotent_for_same_adapter_result(db_session): + from app.models import SourceRecord, SsiInstruction + from app.services.instruction_ingestion import ingest_instruction_adapter_result + + kwargs = { + "adapter_result": _adapter_result(), + "file_name": "lineage.csv", + "file_hash": "hash-lineage", + "import_id": "import-lineage", + "uploaded_by": "ops@example.com", + "now": "2026-05-07T00:00:00Z", + } + ingest_instruction_adapter_result(db_session, **kwargs) + first_records = db_session.query(SourceRecord).filter_by(import_id="import-lineage").count() + first_instructions = db_session.query(SsiInstruction).count() + + ingest_instruction_adapter_result(db_session, **kwargs) + + assert db_session.query(SourceRecord).filter_by(import_id="import-lineage").count() == first_records + assert db_session.query(SsiInstruction).count() == first_instructions + + +def test_shared_lineage_service_persists_privacy_safe_rejects_and_audit(db_session): + from app.models import SourceReject + from app.services.instruction_ingestion import ingest_instruction_adapter_result + + ingest_instruction_adapter_result( + db_session, + adapter_result=_adapter_result(rejects=True), + file_name="rejects.csv", + file_hash="hash-rejects", + import_id="import-rejects", + uploaded_by="ops@example.com", + now="2026-05-07T00:00:00Z", + ) + + reject = db_session.query(SourceReject).filter_by(import_id="import-rejects").one() + assert reject.reject_message == "row missing required field(s): currency_code" + audit_text = _audit_payload_text(db_session) + for forbidden in ("OWNRUS33XXX", "HOLDUS33XXX", "ACCT-****-4242", "lineage-safe"): + assert forbidden not in audit_text diff --git a/apps/ssi-control-tower/tests/test_instruction_validation.py b/apps/ssi-control-tower/tests/test_instruction_validation.py new file mode 100644 index 0000000..6dc32ae --- /dev/null +++ b/apps/ssi-control-tower/tests/test_instruction_validation.py @@ -0,0 +1,111 @@ +from __future__ import annotations + + +def _add_instruction(session, **overrides): + from app.models import SsiInstruction + from app.services.audit import stable_id + + values = { + "ssi_instruction_id": stable_id("test-instruction", overrides.get("source_record_key", "fp-validation")), + "source_record_key": overrides.get("source_record_key", "fp-validation"), + "owner_institution_id": "inst-owner", + "owner_bic": "OWNRUS33XXX", + "owner_name": None, + "owner_city": None, + "owner_country_code": "US", + "currency_code": "USD", + "asset_category": "equity", + "account_holder_institution_id": "inst-holder", + "account_holder_bic": "HOLDUS33XXX", + "account_holder_name": None, + "account_holder_country_code": "US", + "account_number_masked": "ACCT-****-4242", + "preferred_flag": "N", + "account_holder_type": None, + "group_key_owner": None, + "record_key_bdp_owner": None, + "eid_owner": None, + "record_key_bdp_account_holder": None, + "eid_account_holder": None, + "update_date": None, + "traffic_flag": None, + "traffic_date": None, + "start_date": "2026-01-01", + "stop_date": None, + "status": "active", + "created_at": "2026-05-07T00:00:00Z", + "updated_at": "2026-05-07T00:00:00Z", + } + values.update(overrides) + instruction = SsiInstruction(**values) + session.add(instruction) + session.commit() + return instruction + + +def test_active_instruction_missing_start_date_fails_lifecycle_rule(db_session): + from app.services.instruction_validation import validate_instruction + + instruction = _add_instruction(db_session, source_record_key="fp-missing-start", start_date=None) + + results = validate_instruction(db_session, instruction, actor="tester@example.com") + + failed = [result for result in results if result.status == "fail"] + assert any(result.rule_id == "SSI.INSTRUCTION.START_DATE_REQUIRED" for result in failed) + result_text = " ".join(result.message for result in failed) + assert "fp-missing-start" not in result_text + assert "ACCT-****-4242" not in result_text + + +def test_invalid_preferred_flag_fails_rule(db_session): + from app.services.instruction_validation import validate_instruction + + instruction = _add_instruction(db_session, source_record_key="fp-bad-preferred", preferred_flag="maybe") + + results = validate_instruction(db_session, instruction, actor="tester@example.com") + + assert any(result.rule_id == "SSI.INSTRUCTION.PREFERRED_FLAG" and result.status == "fail" for result in results) + + +def test_duplicate_preferred_instruction_group_fails_conflict_rule(db_session): + from app.models import InstructionValidationResult + from app.services.instruction_validation import validate_all_instructions + + _add_instruction(db_session, source_record_key="fp-preferred-a", preferred_flag="Y") + _add_instruction(db_session, source_record_key="fp-preferred-b", preferred_flag="Y") + + validate_all_instructions(db_session, actor="tester@example.com") + + assert ( + db_session.query(InstructionValidationResult) + .filter_by(rule_id="SSI.INSTRUCTION.PREFERRED_UNIQUE", status="fail") + .count() + >= 2 + ) + + +def test_overlapping_active_instruction_intervals_fail_rule(db_session): + from app.models import InstructionValidationResult + from app.services.instruction_validation import validate_all_instructions + + _add_instruction( + db_session, + source_record_key="fp-overlap-a", + start_date="2026-01-01", + stop_date="2026-06-30", + ) + _add_instruction( + db_session, + source_record_key="fp-overlap-b", + start_date="2026-05-01", + stop_date="2026-12-31", + ) + + validate_all_instructions(db_session, actor="tester@example.com") + + assert ( + db_session.query(InstructionValidationResult) + .filter_by(rule_id="SSI.INSTRUCTION.ACTIVE_INTERVAL_OVERLAP", status="fail") + .count() + >= 2 + ) diff --git a/apps/ssi-control-tower/tests/test_normalisation.py b/apps/ssi-control-tower/tests/test_normalisation.py new file mode 100644 index 0000000..007e410 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_normalisation.py @@ -0,0 +1,46 @@ +from app.services.mapping import map_source_row +from app.services.normalisation import normalize_row + + +def test_mapping_uses_seed_aliases_for_source_headers(): + row = { + "Entity Name": " Northstar Global Fund ", + "Country": "france", + "Currency": "eur", + "PSET BIC": "nstagb2l", + "Owner Email": "ops-owner@example.com", + } + mapped = map_source_row(row) + assert mapped["legal_entity_name"] == " Northstar Global Fund " + assert mapped["country_code"] == "france" + assert mapped["currency_code"] == "eur" + assert mapped["place_of_settlement_bic"] == "nstagb2l" + assert mapped["owner_user_email"] == "ops-owner@example.com" + + +def test_normalisation_uppercases_codes_maps_aliases_and_dates(): + normalized = normalize_row( + { + "legal_entity_name": " Northstar Global Fund ", + "asset_class": "Equities", + "settlement_method": "delivery versus payment", + "country_code": "france", + "currency_code": "eur", + "place_of_settlement_bic": "nstagb2l", + "global_custodian_bic": " custgb2lxxx ", + "local_agent_bic": "agntfrppxxx", + "effective_from": "01/02/2026", + "last_confirmed_at": "2026-01-15", + "securities_account_masked": "SEC-****-1042", + "cash_account_masked": "CASH-****-8821", + } + ) + assert normalized["legal_entity_name"] == "Northstar Global Fund" + assert normalized["asset_class"] == "equity" + assert normalized["settlement_method"] == "DVP" + assert normalized["country_code"] == "FR" + assert normalized["currency_code"] == "EUR" + assert normalized["place_of_settlement_bic"] == "NSTAGB2L" + assert normalized["global_custodian_bic"] == "CUSTGB2LXXX" + assert normalized["local_agent_bic"] == "AGNTFRPPXXX" + assert normalized["effective_from"] == "2026-02-01" diff --git a/apps/ssi-control-tower/tests/test_raafet_style_web.py b/apps/ssi-control-tower/tests/test_raafet_style_web.py new file mode 100644 index 0000000..5d6e84e --- /dev/null +++ b/apps/ssi-control-tower/tests/test_raafet_style_web.py @@ -0,0 +1,60 @@ +from tests.conftest import actor + + +RAAFET_STYLE_TOKENS = [ + "raafet-shell", + "bg-[#F8FAFC]", + "text-[#0F172A]", + "text-[#0EA5E9]", + "rounded-2xl border border-slate-200 bg-white shadow-sm", +] + + +def test_home_dashboard_uses_raafet_design_system(client): + response = client.get("/") + + assert response.status_code == 200 + html = response.text + for token in RAAFET_STYLE_TOKENS: + assert token in html + assert "SWIFTRef · SSI Plus · Control Tower" in html + assert "Practice-grade SSI governance" in html + assert "T+1 SSI readiness" in html + assert "bg-slate-950" not in html + assert "bg-slate-900" not in html + + +def test_primary_web_pages_share_light_raafet_shell(client): + client.post( + "/api/v1/exports", + json={"format": "csv"}, + headers=actor("approver@example.com"), + ) + for path in ["/imports", "/ssis", "/exceptions", "/approvals", "/audit", "/rules"]: + response = client.get(path) + assert response.status_code == 200, path + html = response.text + assert "raafet-shell" in html, path + assert "bg-[#F8FAFC]" in html, path + assert "rounded-2xl border border-slate-200 bg-white shadow-sm" in html, path + assert "bg-slate-950" not in html, path + assert "bg-slate-900" not in html, path + + +def test_light_shell_keeps_mobile_navigation_available(client): + response = client.get("/") + + assert response.status_code == 200 + html = response.text + assert 'aria-label="Mobile primary navigation"' in html + assert "md:hidden" in html + for link in [ + 'href="/imports"', + 'href="/ssis"', + 'href="/exceptions"', + 'href="/approvals"', + 'href="/audit"', + 'href="/rules"', + 'href="/api/v1/dashboard/v2/source-controls"', + ]: + assert link in html diff --git a/apps/ssi-control-tower/tests/test_readiness_score.py b/apps/ssi-control-tower/tests/test_readiness_score.py new file mode 100644 index 0000000..a9e1b63 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_readiness_score.py @@ -0,0 +1,8 @@ +def test_initial_readiness_score_is_red_between_50_and_74(client): + response = client.get("/api/v1/dashboard/readiness-score") + assert response.status_code == 200 + payload = response.json() + assert payload["band"] == "Red" + assert 50 <= payload["score"] <= 74 + assert payload["total_ssis"] == 25 + assert payload["missing_owner"] == 3 diff --git a/apps/ssi-control-tower/tests/test_rules_duplicate.py b/apps/ssi-control-tower/tests/test_rules_duplicate.py new file mode 100644 index 0000000..0f3b334 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_rules_duplicate.py @@ -0,0 +1,9 @@ +def test_duplicate_active_rule_finds_pair_and_trio(client): + exceptions = client.get("/api/v1/exceptions", params={"severity": "critical", "rule_id": "SSI.DUPLICATE.ACTIVE"}).json() + assert len(exceptions) >= 5 + contexts = {} + for item in exceptions: + contexts.setdefault(item["context_key"], 0) + contexts[item["context_key"]] += 1 + assert 2 in contexts.values() + assert 3 in contexts.values() diff --git a/apps/ssi-control-tower/tests/test_rules_formats.py b/apps/ssi-control-tower/tests/test_rules_formats.py new file mode 100644 index 0000000..4835f7d --- /dev/null +++ b/apps/ssi-control-tower/tests/test_rules_formats.py @@ -0,0 +1,11 @@ +def test_bic_validation_and_country_currency_rules(client): + bic_results = client.get("/api/v1/validation-results", params={"rule_id": "SSI.FORMAT.BIC", "status": "fail"}).json() + assert len(bic_results) == 2 + assert all(result["failed_field"] in {"local_agent_bic", "place_of_settlement_bic", "global_custodian_bic", "intermediary_bic"} for result in bic_results) + + country_results = client.get("/api/v1/validation-results", params={"rule_id": "SSI.FORMAT.COUNTRY_CODE", "status": "fail"}).json() + assert len(country_results) == 1 + assert country_results[0]["severity"] == "medium" + + currency_results = client.get("/api/v1/validation-results", params={"rule_id": "SSI.FORMAT.CURRENCY_CODE", "status": "fail"}).json() + assert currency_results == [] diff --git a/apps/ssi-control-tower/tests/test_rules_governance.py b/apps/ssi-control-tower/tests/test_rules_governance.py new file mode 100644 index 0000000..fa1456b --- /dev/null +++ b/apps/ssi-control-tower/tests/test_rules_governance.py @@ -0,0 +1,5 @@ +def test_governance_rules_include_pending_approval_and_date_range(client): + approvals = client.get("/api/v1/approvals").json() + assert any(item["status"] == "pending" for item in approvals) + date_results = client.get("/api/v1/validation-results", params={"rule_id": "SSI.DATE.INVALID_RANGE", "status": "fail"}).json() + assert len(date_results) == 1 diff --git a/apps/ssi-control-tower/tests/test_rules_required.py b/apps/ssi-control-tower/tests/test_rules_required.py new file mode 100644 index 0000000..aa45d19 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_rules_required.py @@ -0,0 +1,6 @@ +def test_required_owner_rule_creates_high_exceptions(client): + response = client.get("/api/v1/exceptions", params={"rule_id": "SSI.GOVERNANCE.OWNER_REQUIRED"}) + assert response.status_code == 200 + exceptions = response.json() + assert len(exceptions) == 3 + assert {item["severity"] for item in exceptions} == {"high"} diff --git a/apps/ssi-control-tower/tests/test_rules_stale.py b/apps/ssi-control-tower/tests/test_rules_stale.py new file mode 100644 index 0000000..e5cf0e4 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_rules_stale.py @@ -0,0 +1,6 @@ +def test_stale_rules_find_12m_and_critical_market_records(client): + stale = client.get("/api/v1/validation-results", params={"rule_id": "SSI.STALE.12M", "status": "fail"}).json() + critical = client.get("/api/v1/validation-results", params={"rule_id": "SSI.STALE.CRITICAL_MARKET", "status": "fail"}).json() + assert len(stale) == 4 + assert len(critical) == 2 + assert {result["severity"] for result in critical} == {"critical"} diff --git a/apps/ssi-control-tower/tests/test_schema_compatibility.py b/apps/ssi-control-tower/tests/test_schema_compatibility.py new file mode 100644 index 0000000..568d5fc --- /dev/null +++ b/apps/ssi-control-tower/tests/test_schema_compatibility.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import sqlite3 + + +def test_create_all_adds_instruction_tables_without_mutating_existing_tables(tmp_path, monkeypatch): + db_path = tmp_path / "existing.db" + with sqlite3.connect(db_path) as conn: + conn.execute("CREATE TABLE ssi_records (ssi_id VARCHAR PRIMARY KEY, status VARCHAR NOT NULL)") + conn.commit() + + monkeypatch.setenv("SSI_DB_PATH", str(db_path)) + import app.db as db + + db.reset_engine() + db.init_db() + db.reset_engine() + + with sqlite3.connect(db_path) as conn: + table_names = { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + } + ssi_columns = [row[1] for row in conn.execute("PRAGMA table_info(ssi_records)").fetchall()] + + assert "instruction_validation_results" in table_names + assert "instruction_exception_cases" in table_names + assert ssi_columns == ["ssi_id", "status"] diff --git a/apps/ssi-control-tower/tests/test_source_adapters_contract.py b/apps/ssi-control-tower/tests/test_source_adapters_contract.py new file mode 100644 index 0000000..9c04194 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_source_adapters_contract.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import dataclasses + +import pytest + + +def _candidate(**overrides): + from app.services.source_adapters import CanonicalInstructionCandidate + + values = { + "source_system": "CSV_DEMO", + "source_schema": "CSV_DEMO_V1", + "row_number": 2, + "source_row_fingerprint": "fp-safe-001", + "owner_bic": "OWNRUS33XXX", + "currency_code": "USD", + "asset_category": "equity", + "account_holder_bic": "HOLDUS33XXX", + "account_number_masked": "ACCT-****-4242", + "preferred_flag": "Y", + "start_date": "2026-01-01", + "stop_date": None, + "privacy_metadata": {"source_fields": ["currency_code"]}, + } + values.update(overrides) + return CanonicalInstructionCandidate(**values) + + +def test_canonical_instruction_candidate_contract_is_privacy_safe(): + from app.services.source_adapters import assert_no_sensitive_instruction_payload + + candidate = _candidate() + + assert candidate.source_system == "CSV_DEMO" + assert candidate.source_schema == "CSV_DEMO_V1" + assert candidate.row_number == 2 + assert candidate.source_row_fingerprint == "fp-safe-001" + assert candidate.account_number_masked == "ACCT-****-4242" + assert dataclasses.is_dataclass(candidate) + assert_no_sensitive_instruction_payload(candidate) + + +def test_canonical_reject_exposes_only_safe_fields(): + from app.services.source_adapters import CanonicalSourceReject + + reject = CanonicalSourceReject( + row_number=3, + reject_code="row.missing_required_fields", + reject_message="row missing required field(s): currency_code", + ) + + assert dataclasses.asdict(reject) == { + "row_number": 3, + "reject_code": "row.missing_required_fields", + "reject_message": "row missing required field(s): currency_code", + } + + +def test_sensitive_payload_guard_rejects_unmasked_account_labels(): + from app.services.source_adapters import assert_no_sensitive_instruction_payload + + with pytest.raises(ValueError, match="masked account"): + assert_no_sensitive_instruction_payload(_candidate(account_number_masked="ACCTVISIBLE")) diff --git a/apps/ssi-control-tower/tests/test_ssiplus_source_adapter.py b/apps/ssi-control-tower/tests/test_ssiplus_source_adapter.py new file mode 100644 index 0000000..92615b1 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_ssiplus_source_adapter.py @@ -0,0 +1,74 @@ +from __future__ import annotations + + +def _ssiplus_payload(*, header: bool = True) -> bytes: + from app.services.ssiplus_v3 import EXPECTED_SSIPLUS_V3_FIELDS + + fields = EXPECTED_SSIPLUS_V3_FIELDS if header else ("MODIFICATION FLAG", "RECORD KEY") + row = { + "MODIFICATION FLAG": "A", + "RECORD KEY": "SAFE-FP-001", + "BIC OWNER": "OWNRUS33XXX", + "INSTITUTION NAME OWNER": "Synthetic Owner", + "CITY OWNER": "Synthetic City", + "ISO COUNTRY CODE OWNER": "US", + "ISO CURRENCY CODE": "USD", + "ASSET CATEGORY": "SECU", + "BIC ACCOUNT HOLDING INSTITUTION": "HOLDUS33XXX", + "INSTITUTION NAME ACCOUNT HOLDING INSTITUTION": "Synthetic Holder", + "ISO COUNTRY CODE ACCOUNT HOLDING INSTITUTION": "US", + "ACCOUNT NBR WITH ACCOUNT HOLDING INSTITUTION": "ACCT-****-4242", + "PREFERRED ACCOUNT HOLDING INSTITUTION": "Y", + "ACCOUNT HOLDING INSTITUTION TYPE": "CUST", + "GROUP KEY OWNER": "SAFE-GROUP", + "RECORD KEY BDP OWNER": "SAFE-BDP-OWNER", + "EID OWNER": "SAFE-EID-OWNER", + "RECORD KEY BDP ACCOUNT HOLDING INSTITUTION": "SAFE-BDP-HOLDER", + "EID ACCOUNT HOLDING INSTITUTION": "SAFE-EID-HOLDER", + "UPDATE DATE": "20260507", + "TRAFFIC FLAG": "Y", + "TRAFFIC DATE": "20260508", + "START DATE": "20260509", + "STOP DATE": "20260510", + "FIELD A": "", + "FIELD B": "", + "FIELD C": "", + "FIELD D": "", + } + lines = [" ".join(fields), " ".join(row.get(field, "") for field in fields)] + return ("\n".join(lines) + "\n").encode("utf-8") + + +def test_ssiplus_adapter_wraps_parser_output_as_canonical_candidates(): + from app.services.source_adapters import adapt_ssiplus_v3_bytes_to_instruction_candidates + + result = adapt_ssiplus_v3_bytes_to_instruction_candidates(_ssiplus_payload()) + + assert result.source_system == "SWIFTREF_SSIPLUS" + assert result.source_schema == "SSIPLUS_V3" + assert result.rejects == [] + assert len(result.candidates) == 1 + candidate = result.candidates[0] + assert candidate.row_number == 2 + assert candidate.owner_bic == "OWNRUS33XXX" + assert candidate.currency_code == "USD" + assert candidate.asset_category == "SECU" + assert candidate.account_holder_bic == "HOLDUS33XXX" + assert candidate.account_number_masked == "ACCT-****-4242" + assert candidate.start_date == "2026-05-09" + assert candidate.stop_date == "2026-05-10" + assert candidate.source_row_fingerprint.startswith("ssiplus:") + + +def test_ssiplus_adapter_converts_header_mismatch_to_privacy_safe_reject(): + from app.services.source_adapters import adapt_ssiplus_v3_bytes_to_instruction_candidates + + result = adapt_ssiplus_v3_bytes_to_instruction_candidates(_ssiplus_payload(header=False)) + + assert result.candidates == [] + assert len(result.rejects) == 1 + reject = result.rejects[0] + assert reject.row_number == 1 + assert reject.reject_code == "schema.header_mismatch" + assert "expected" in reject.reject_message + assert "Synthetic" not in reject.reject_message diff --git a/apps/ssi-control-tower/tests/test_ssiplus_v3_ingestion.py b/apps/ssi-control-tower/tests/test_ssiplus_v3_ingestion.py new file mode 100644 index 0000000..e08766a --- /dev/null +++ b/apps/ssi-control-tower/tests/test_ssiplus_v3_ingestion.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +from pathlib import Path + +SAMPLE = Path(__file__).resolve().parent.parent / "data" / "sample_swiftref_ssiplus_v3_synthetic.tsv" + + +def _audit_payloads(session) -> list[dict]: + from app.models import AuditEvent + + out: list[dict] = [] + for ev in session.query(AuditEvent).all(): + if ev.new_value: + out.append(json.loads(ev.new_value)) + if ev.previous_value: + out.append(json.loads(ev.previous_value)) + return out + + +def test_ingest_creates_source_file_and_domain_rows(db_session): + from app.services.ingestion import ingest_ssiplus_v3_bytes + from app.models import ( + SourceFile, + SourceRecord, + SourceReject, + SsiInstruction, + SsiSourceLink, + ) + + data = SAMPLE.read_bytes() + batch = ingest_ssiplus_v3_bytes( + db_session, data, file_name="ssiplus.tsv", uploaded_by="ops-owner@example.com" + ) + + assert batch.source_system == "SWIFTREF_SSIPLUS" + assert batch.source_template_id == "SSIPLUS_V3" + assert batch.records_imported > 0 + assert batch.records_rejected == 0 + + source_files = db_session.query(SourceFile).all() + assert len(source_files) == 1 + sf = source_files[0] + assert sf.parser_version == "ssiplus_v3_v1" + assert sf.source_system == "SWIFTREF_SSIPLUS" + assert sf.source_schema == "SSIPLUS_V3" + assert sf.records_accepted == batch.records_imported + assert sf.records_rejected == 0 + + records = db_session.query(SourceRecord).all() + rejects = db_session.query(SourceReject).all() + instructions = db_session.query(SsiInstruction).all() + links = db_session.query(SsiSourceLink).all() + + assert len(records) == batch.records_imported + assert len(rejects) == 0 + assert len(instructions) == batch.records_imported + assert len(links) == len(instructions) + + for record in records: + assert record.source_file_id == sf.source_file_id + assert record.import_id == batch.import_id + assert record.row_number >= 2 + assert record.status == "accepted" + + +def test_ingest_masks_account_numbers(db_session): + from app.services.ingestion import ingest_ssiplus_v3_bytes + from app.models import SsiInstruction + + ingest_ssiplus_v3_bytes( + db_session, SAMPLE.read_bytes(), file_name="ssiplus.tsv", uploaded_by="ops@example.com" + ) + + for inst in db_session.query(SsiInstruction).all(): + assert "****" in inst.account_number_masked + + +def test_ingest_is_idempotent(db_session): + from app.services.ingestion import ingest_ssiplus_v3_bytes + from app.models import SourceRecord, SsiInstruction + + data = SAMPLE.read_bytes() + ingest_ssiplus_v3_bytes(db_session, data, file_name="ssiplus.tsv", uploaded_by="ops@example.com") + first_records = db_session.query(SourceRecord).count() + first_instructions = db_session.query(SsiInstruction).count() + + ingest_ssiplus_v3_bytes(db_session, data, file_name="ssiplus.tsv", uploaded_by="ops@example.com") + assert db_session.query(SourceRecord).count() == first_records + assert db_session.query(SsiInstruction).count() == first_instructions + + +def test_bad_row_creates_source_reject_without_domain_instruction(db_session): + from app.services.ingestion import ingest_ssiplus_v3_bytes + from app.services.ssiplus_v3 import EXPECTED_SSIPLUS_V3_FIELDS + from app.models import SourceReject, SsiInstruction + + header = "\t".join(EXPECTED_SSIPLUS_V3_FIELDS) + blank_row = "A" + ("\t" * (len(EXPECTED_SSIPLUS_V3_FIELDS) - 1)) + payload = (header + "\n" + blank_row + "\n").encode("utf-8") + + batch = ingest_ssiplus_v3_bytes( + db_session, payload, file_name="bad.tsv", uploaded_by="ops@example.com" + ) + + assert batch.records_imported == 0 + assert batch.records_rejected >= 1 + rejects = db_session.query(SourceReject).all() + assert rejects + for reject in rejects: + assert reject.reject_code + assert "ACCT" not in reject.reject_message + assert db_session.query(SsiInstruction).count() == 0 + + +def test_audit_and_rejects_redact_sensitive_values(db_session): + from app.services.ingestion import ingest_ssiplus_v3_bytes + from app.models import SourceReject + + ingest_ssiplus_v3_bytes( + db_session, SAMPLE.read_bytes(), file_name="ssiplus.tsv", uploaded_by="ops@example.com" + ) + + forbidden = ( + "ORIOUS33XXX", + "Orion Synthetic Bank", + "SSI000000001", + "BDP000000001", + "1000001", + "New York", + "ACCT-****-9001", + ) + + payloads = _audit_payloads(db_session) + payload_text = json.dumps(payloads) + for needle in forbidden: + assert needle not in payload_text, f"audit payload leaked {needle}" + + for reject in db_session.query(SourceReject).all(): + for needle in forbidden: + assert needle not in reject.reject_message diff --git a/apps/ssi-control-tower/tests/test_ssiplus_v3_parser.py b/apps/ssi-control-tower/tests/test_ssiplus_v3_parser.py new file mode 100644 index 0000000..c52f2c5 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_ssiplus_v3_parser.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from pathlib import Path + + +SAMPLE = Path(__file__).resolve().parent.parent / "data" / "sample_swiftref_ssiplus_v3_synthetic.tsv" + + +def test_is_ssiplus_v3_bytes_recognises_synthetic_sample(): + from app.services.ssiplus_v3 import is_ssiplus_v3_bytes + + data = SAMPLE.read_bytes() + assert is_ssiplus_v3_bytes(data) is True + + +def test_is_ssiplus_v3_bytes_rejects_csv_payload(): + from app.services.ssiplus_v3 import is_ssiplus_v3_bytes + + csv = b"Entity Name,LEI,BIC\nVega,529900SYNTHVEGA00001,VEGAGB2L\n" + assert is_ssiplus_v3_bytes(csv) is False + + +def test_parse_ssiplus_v3_rows_parses_synthetic_sample_with_no_rejects(): + from app.services.ssiplus_v3 import parse_ssiplus_v3_rows + + result = parse_ssiplus_v3_rows(SAMPLE.read_bytes()) + assert result.rejects == [] + assert len(result.rows) >= 50 + first = result.rows[0] + assert first.source_record_key == "SSI000000001" + assert first.owner_bic == "ORIOUS33XXX" + assert first.currency_code == "USD" + assert first.asset_category == "SECU" + assert first.account_holder_bic == "HOLDUS33XXX" + assert "****" in first.account_number_masked + assert first.start_date == "2025-01-01" + assert first.update_date == "2026-01-01" + + +def test_parse_ssiplus_v3_rejects_unmasked_account_values_without_echoing_them(): + from app.services.ssiplus_v3 import parse_ssiplus_v3_rows + + raw_payload = SAMPLE.read_text().replace("ACCT-****-9001", "123456789001", 1).encode("utf-8") + result = parse_ssiplus_v3_rows(raw_payload) + + assert len(result.rows) >= 49 + assert result.rejects + reject = result.rejects[0] + assert reject.reject_code == "row.invalid_account_value" + assert "123456789001" not in reject.reject_message + assert "****" not in reject.reject_message + + +def test_parse_rejects_missing_header_without_echoing_row_values(): + from app.services.ssiplus_v3 import parse_ssiplus_v3_rows + + bad_header = ( + b"MODIFICATION FLAG\tRECORD KEY\tBIC OWNER\n" + b"A\tSSI000000001\tORIOUS33XXX\n" + ) + result = parse_ssiplus_v3_rows(bad_header) + assert result.rows == [] + assert result.rejects, "expected schema reject" + reject = result.rejects[0] + assert reject.reject_code in {"schema.header_mismatch", "schema.invalid_header"} + assert "ORIOUS33XXX" not in reject.reject_message + assert "SSI000000001" not in reject.reject_message + + +def test_looks_like_ssiplus_v3_bytes_detects_candidates(): + from app.services.ssiplus_v3 import looks_like_ssiplus_v3_bytes + + candidate = ( + b"MODIFICATION FLAG\tRECORD KEY\tBIC OWNER\tINSTITUTION NAME OWNER\n" + b"A\tSSI000000001\tORIOUS33XXX\tOrion\n" + ) + assert looks_like_ssiplus_v3_bytes(candidate) is True + + csv = b"Entity Name,LEI,BIC\nVega,529900,VEGAGB2L\n" + assert looks_like_ssiplus_v3_bytes(csv) is False + + +def test_mask_account_number_always_redacts(): + from app.services.ssiplus_v3 import mask_account_number + + assert "****" in mask_account_number("12345678") + assert mask_account_number("12345678").endswith("5678") + assert "12345678" not in mask_account_number("12345678") + assert mask_account_number("ACCT-****-9001") == "ACCT-****-9001" + assert mask_account_number(None) == "ACCT-****" + assert mask_account_number("") == "ACCT-****" + assert mask_account_number("AB") == "ACCT-****" + + +def test_normalize_yyyymmdd_converts_dates(): + from app.services.ssiplus_v3 import normalize_yyyymmdd + + assert normalize_yyyymmdd("20260101") == "2026-01-01" + assert normalize_yyyymmdd("") is None + assert normalize_yyyymmdd(None) is None + assert normalize_yyyymmdd("invalid") is None + + +def test_parse_ssiplus_v3_rejects_row_missing_required_fields(): + from app.services.ssiplus_v3 import EXPECTED_SSIPLUS_V3_FIELDS, parse_ssiplus_v3_rows + + header = "\t".join(EXPECTED_SSIPLUS_V3_FIELDS).encode("utf-8") + blank = "\t" * (len(EXPECTED_SSIPLUS_V3_FIELDS) - 1) + body = ("\nA\t\tORIOUS33XXX" + "\t" * (len(EXPECTED_SSIPLUS_V3_FIELDS) - 3)).encode("utf-8") + payload = header + body + result = parse_ssiplus_v3_rows(payload) + assert result.rows == [] + assert result.rejects + assert all("****" not in r.reject_message for r in result.rejects) + assert all("ORIOUS33XXX" not in r.reject_message for r in result.rejects) + _ = blank # silence diff --git a/apps/ssi-control-tower/tests/test_unified_exception_queue.py b/apps/ssi-control-tower/tests/test_unified_exception_queue.py new file mode 100644 index 0000000..00cd024 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_unified_exception_queue.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from tests.test_instruction_exceptions import _add_invalid_instruction + + +def test_control_exception_queue_combines_v1_and_instruction_exceptions(client, db_session): + from app.services.instruction_validation import validate_instruction + + instruction = _add_invalid_instruction(db_session) + validate_instruction(db_session, instruction, actor="tester@example.com") + + response = client.get("/api/v1/control-exceptions") + + assert response.status_code == 200, response.text + payload = response.json() + queue_types = {item["queue_type"] for item in payload} + assert "legacy_ssi" in queue_types + assert "instruction" in queue_types + for item in payload: + assert set(item) == { + "queue_type", + "exception_id", + "record_type", + "record_id", + "rule_id", + "severity", + "failed_field", + "description", + "suggested_fix", + "owner_user_email", + "status", + "created_at", + "updated_at", + "context_key", + } + text = str(payload) + for forbidden in ("fp-instruction-exception", "ACCT-****-4242", "OWNRUS33XXX", "HOLDUS33XXX"): + assert forbidden not in text + + +def test_existing_exception_endpoint_shape_remains_unchanged(client): + response = client.get("/api/v1/exceptions") + + assert response.status_code == 200, response.text + assert response.json() + item = response.json()[0] + assert "exception_id" in item + assert "ssi_id" in item + assert "queue_type" not in item diff --git a/apps/ssi-control-tower/tests/test_v2_dashboard.py b/apps/ssi-control-tower/tests/test_v2_dashboard.py new file mode 100644 index 0000000..82cfad6 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_v2_dashboard.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +SAMPLE = Path(__file__).resolve().parent.parent / "data" / "sample_swiftref_ssiplus_v3_synthetic.tsv" + + +def _import(client) -> None: + with SAMPLE.open("rb") as handle: + response = client.post( + "/api/v1/imports", + files={"file": ("ssiplus.tsv", handle, "text/tab-separated-values")}, + headers={"X-User-Email": "ops-owner@example.com"}, + ) + assert response.status_code == 201, response.text + + +def test_v2_source_controls_returns_expected_aggregates(client): + _import(client) + response = client.get("/api/v1/dashboard/v2/source-controls") + assert response.status_code == 200, response.text + payload = response.json() + + assert payload["source_files"]["total"] == 1 + assert payload["instructions"]["total"] >= 50 + + coverage = payload["coverage"] + assert coverage["by_currency"] + assert coverage["by_asset_category"] + assert coverage["by_owner_country"] + assert coverage["by_account_holder_country"] + + preferred = payload["preferred"] + assert "by_flag" in preferred + + conflicts = payload["conflicts"] + assert "preferred_conflict_groups" in conflicts + assert "overlapping_active_groups" in conflicts + + lifecycle = payload["lifecycle"] + assert "missing_start_date" in lifecycle + + freshness = payload["freshness"] + assert "missing_update_date" in freshness + + +def test_v2_dashboard_does_not_leak_sensitive_values(client, db_session): + from app.models import SsiInstruction + + _import(client) + response = client.get("/api/v1/dashboard/v2/source-controls") + assert response.status_code == 200, response.text + payload_text = json.dumps(response.json()) + + # Forbidden raw values pulled from the synthetic sample. + forbidden = ( + "ORIOUS33XXX", + "Orion Synthetic Bank", + "SSI000000001", + "BDP000000001", + "1000001", + "New York", + "ACCT-****-9001", + ) + for needle in forbidden: + assert needle not in payload_text + + # Also assert no value from any actual instruction column leaks. + for inst in db_session.query(SsiInstruction).all(): + for value in ( + inst.owner_bic, + inst.owner_name, + inst.owner_city, + inst.account_holder_bic, + inst.account_holder_name, + inst.source_record_key, + inst.account_number_masked, + inst.record_key_bdp_owner, + inst.eid_owner, + inst.record_key_bdp_account_holder, + inst.eid_account_holder, + ): + if value: + assert value not in payload_text, f"dashboard leaked {value!r}" + + +@pytest.mark.parametrize("preferred_flag", ["P", "Y", "TRUE", "1"]) +def test_v2_dashboard_preferred_conflict_detection(client, preferred_flag): + """Two preferred instructions sharing owner/currency/asset/holder should register as a conflict.""" + from app.db import SessionLocal + from app.services.ssiplus_v3 import EXPECTED_SSIPLUS_V3_FIELDS + from app.services.ingestion import ingest_ssiplus_v3_bytes + + fields = EXPECTED_SSIPLUS_V3_FIELDS + rows = [ + # Two preferred rows with same owner/currency/asset/holder => conflict + ["A", "SSIA0000001", "ORIOUS33XXX", "Owner US", "New York", "US", "USD", "SECU", + "HOLDUS33XXX", "Holder US", "US", "ACCT-****-1111", preferred_flag, "CORRESPONDENT", + "GRP01", "BDP01", "EID01", "BDP02", "EID02", "20260101", "Y", "20260315", "20250101", "", "", "", "", ""], + ["A", "SSIA0000002", "ORIOUS33XXX", "Owner US", "New York", "US", "USD", "SECU", + "HOLDUS33XXX", "Holder US", "US", "ACCT-****-2222", preferred_flag, "CORRESPONDENT", + "GRP01", "BDP03", "EID03", "BDP04", "EID04", "20260101", "Y", "20260315", "20250101", "", "", "", "", ""], + ] + payload = ( + "\t".join(fields) + "\n" + "\n".join("\t".join(r) for r in rows) + "\n" + ).encode("utf-8") + + with SessionLocal() as session: + ingest_ssiplus_v3_bytes(session, payload, file_name="conflict.tsv", uploaded_by="ops@example.com") + + response = client.get("/api/v1/dashboard/v2/source-controls") + body = response.json() + assert body["conflicts"]["preferred_conflict_groups"] >= 1 diff --git a/apps/ssi-control-tower/tests/test_v2_models.py b/apps/ssi-control-tower/tests/test_v2_models.py new file mode 100644 index 0000000..6b8c8c9 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_v2_models.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from sqlalchemy import inspect + + +def test_v2_tables_exist(client): + from app.db import get_engine + + inspector = inspect(get_engine()) + tables = set(inspector.get_table_names()) + expected = { + "source_files", + "source_records", + "source_rejects", + "institutions", + "ssi_instructions", + "ssi_source_links", + } + missing = expected - tables + assert not missing, f"missing v2 tables: {sorted(missing)}" + + +def test_v2_models_have_expected_columns(client): + from app.db import get_engine + + inspector = inspect(get_engine()) + + def cols(table: str) -> set[str]: + return {c["name"] for c in inspector.get_columns(table)} + + assert { + "source_file_id", + "import_id", + "file_name", + "file_hash", + "source_system", + "source_schema", + "parser_version", + "uploaded_by", + "records_received", + "records_accepted", + "records_rejected", + "created_at", + } <= cols("source_files") + + assert { + "source_record_id", + "source_file_id", + "import_id", + "row_number", + "source_record_key", + "source_hash", + "status", + "created_at", + } <= cols("source_records") + + assert { + "source_reject_id", + "source_file_id", + "import_id", + "row_number", + "reject_code", + "reject_message", + "created_at", + } <= cols("source_rejects") + + assert { + "institution_id", + "bic", + "name", + "city", + "country_code", + "source_system", + "created_at", + "updated_at", + } <= cols("institutions") + + assert { + "ssi_instruction_id", + "source_record_key", + "owner_institution_id", + "owner_bic", + "owner_name", + "owner_city", + "owner_country_code", + "currency_code", + "asset_category", + "account_holder_institution_id", + "account_holder_bic", + "account_holder_name", + "account_holder_country_code", + "account_number_masked", + "preferred_flag", + "account_holder_type", + "group_key_owner", + "record_key_bdp_owner", + "eid_owner", + "record_key_bdp_account_holder", + "eid_account_holder", + "update_date", + "traffic_flag", + "traffic_date", + "start_date", + "stop_date", + "status", + "created_at", + "updated_at", + } <= cols("ssi_instructions") + + assert { + "ssi_source_link_id", + "ssi_instruction_id", + "source_record_id", + "source_file_id", + "import_id", + "created_at", + } <= cols("ssi_source_links") diff --git a/apps/ssi-control-tower/tests/test_v2_web_dashboard.py b/apps/ssi-control-tower/tests/test_v2_web_dashboard.py new file mode 100644 index 0000000..d2d5700 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_v2_web_dashboard.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + +SAMPLE = Path(__file__).resolve().parent.parent / "data" / "sample_swiftref_ssiplus_v3_synthetic.tsv" + + +def test_home_dashboard_exposes_v2_source_truth_slice(client): + with SAMPLE.open("rb") as handle: + response = client.post( + "/api/v1/imports", + files={"file": ("ssiplus.tsv", handle, "text/tab-separated-values")}, + headers={"X-User-Email": "ops-owner@example.com"}, + ) + assert response.status_code == 201, response.text + + page = client.get("/") + assert page.status_code == 200, page.text + html = page.text + assert "SSI Plus source truth" in html + assert "Source files" in html + assert "Domain instructions" in html + assert "Preferred conflict groups" in html + assert "Overlapping active groups" in html + assert "data/sample_swiftref_ssiplus_v3_synthetic.tsv" in html diff --git a/docs/ssi-control-tower-fold-plan.md b/docs/ssi-control-tower-fold-plan.md new file mode 100644 index 0000000..3810f73 --- /dev/null +++ b/docs/ssi-control-tower-fold-plan.md @@ -0,0 +1,53 @@ +# SSI Control Tower fold plan + +_Last updated: 2026-05-07_ + +## Decision + +SSI Control Tower is folded into this repository as a self-contained backend module at `apps/ssi-control-tower/`. + +This does **not** convert the existing Payment Intelligence browser suite into a backend app. The root Vite/React app remains browser-only and static-host friendly. Its privacy audit remains scoped to `src/` and must not be weakened by SSI Control Tower work. + +## Selected implementation scope + +This branch implements the approved hybrid SSI instruction flow plan from the historical source repo commit `d6c9787`: + +- Phase -1: repository fold into Payment Intelligence Modules. +- Phase 0: baseline guardrails and SQLite `create_all` schema compatibility discipline. +- Phase 1: unified source adapters and shared canonical instruction lineage ingestion. +- Phase 2: instruction-level validation, instruction exceptions, and a unified control queue API. + +Phases 3-5, including instruction approval workflow, readiness/export expansion, and full UI polish, remain follow-up scope unless explicitly promoted. + +## Fold checklist + +- [x] Copy SSI Control Tower as ordinary tracked source files, not a git submodule. +- [x] Do not copy a nested `.git/` directory. +- [x] Do not copy generated DBs, exports, caches, virtualenvs, or local private data. +- [x] Keep the Python app runnable from `apps/ssi-control-tower/` with its own `Makefile` and `pyproject.toml`. +- [x] Keep root `src/` browser-only and leave the root route table untouched. +- [x] Keep root `scripts/privacy-audit.sh src` unchanged and scoped to the browser suite. +- [x] Document SSI Control Tower as an adjacent backend module inside the repo, not as part of the static browser runtime. + +## Verification commands + +From the repository root: + +```bash +pnpm verify +``` + +From the folded module root: + +```bash +cd apps/ssi-control-tower +make test +ruff check . +git diff --check +``` + +For model-touching changes under SQLite `Base.metadata.create_all`, add and run a schema compatibility regression proving new additive tables appear for existing databases without adding columns to existing tables. + +## Privacy invariants for SSI Control Tower + +Implementation under `apps/ssi-control-tower/` must not expose raw account numbers, source record keys, BDP keys, EIDs, institution names, city values, or raw source rows in source rejects, audit payloads, APIs, UI, docs, tests, or prompts. Public fixtures must remain synthetic only.