diff --git a/HANDOFF.md b/HANDOFF.md index e9c67a7..8ec5320 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,6 +1,6 @@ # Payment Intelligence Modules — handoff -_Last updated: 2026-05-07 17:07 +08_ +_Last updated: 2026-05-07 20:40 +08_ This is the next-session entry point for `Raafet57/payment-intelligence-modules`. Read this file first, then check live `git` / GitHub state before acting. @@ -9,54 +9,60 @@ Read this file first, then check live `git` / GitHub state before acting. - Repository: `https://github.com/Raafet57/payment-intelligence-modules` - Local path on Hermes: `/Users/Shared/AgentWork/repos/payment-intelligence-modules` -- Product baseline before the current branch: `c31575c` — merge commit for PR #11, which folded SSI Control Tower into `apps/ssi-control-tower/`. -- Active feature branch for unified SSI UI integration: `feat/ssi-unified-ui-integration` +- Product baseline before the current branch: `3e71236` — merge commit for PR #12, which added the static root `/ssi` suite entry point. +- Active feature branch: `feat/ssi-public-evidence-control-tower` - Deployment/release/tag/public announcement: **not done** ## Current branch scope -This branch adds the first unified-suite UI slice for SSI Control Tower while preserving the root static/browser-only privacy boundary. +This branch improves the folded SSI Control Tower backend module under `apps/ssi-control-tower/` using public bank SSI / wire / settlement-instruction evidence while preserving the synthetic-only privacy boundary. Implemented shape: -- New root-suite route: `/ssi`. -- New static page: `src/pages/SsiPage.tsx`. -- SSI appears in the suite header nav and Home module grid with `Backend` status. -- The `/ssi` page documents the folded FastAPI/Jinja app posture and local run path without coupling to the backend runtime. -- No root-suite API call, iframe, storage, telemetry, external request, or clickable localhost link was added. -- README and ROADMAP route lists/docs include `/ssi` and clarify root-browser-runtime boundaries. +- Sanitized public evidence sidecar: `apps/ssi-control-tower/docs/public_ssi_field_evidence_sources.json`. +- Evidence note: `apps/ssi-control-tower/docs/public_ssi_field_evidence.md`. +- At least 10 official bank public pages were verified; the repo keeps only bank name, URL, page title, and abstract field categories. +- New `/public-evidence` Jinja page for the source/category model. +- New `/api/v1/dashboard/operator-summary` aggregate API. +- Dashboard now has five command-centre panels: today's risk, T+1 blockers, pending approvals, aging exceptions, and export readiness. +- Rule chips drill into `/exceptions?rule_id=...`; the exceptions queue honors the filter. +- New `/exceptions/{exception_id}` detail page with root cause, impacted market/currency/asset, owner, recommended action, evidence required, SLA/age/breach, approval/export context, and recent audit trail. +- New read-only `/demo` and `/demo/step/{n}` walkthrough, fully synthetic and database-write-free. +- Claude Code was used for implementation and frontend review; the final Claude frontend review returned `APPROVE` after fixing blockers. ## Modules currently in the browser suite -| Module | Route | Current posture | -| ----------------------- | -------------: | ------------------------------------------------------------------------------------------------ | -| Scrubber | `/scrubber` | Browser-only ISO 20022 XML scrubber/anonymiser. | +| Module | Route | Current posture | +| ----------------------- | -------------- | ------------------------------------------------------------------------------------------------ | +| Scrubber | `/scrubber` | Browser-only ISO 20022 XML scrubber/anonymiser. | | Storyteller | `/storyteller` | Operator narrative and structured field projection for supported `pacs.*` / `camt.*` files. | -| IBAN Workbench | `/iban` | Validate, Build, Catalogue, and Provenance panels from bundled SWIFT IBAN Registry-derived data. | -| BIC Validator | `/bic` | Demo/snapshot-bound only; not current-production BIC Directory validation. | -| CBPR+ Readiness Checker | `/cbpr` | Local readiness/structure checks only; not certified/full XSD/MyStandards validation. | -| Payment Insights Lite | `/insights` | Local lifecycle insight over files the user provides; not live payment tracking. | -| SSI Control Tower | `/ssi` | Static entry page for the folded backend module under `apps/ssi-control-tower/`. | +| IBAN Workbench | `/iban` | Validate, Build, Catalogue, and Provenance panels from bundled SWIFT IBAN Registry-derived data. | +| BIC Validator | `/bic` | Demo/snapshot-bound only; not current-production BIC Directory validation. | +| CBPR+ Readiness Checker | `/cbpr` | Local readiness/structure checks only; not certified/full XSD/MyStandards validation. | +| Payment Insights Lite | `/insights` | Local lifecycle insight over files the user provides; not live payment tracking. | +| SSI Control Tower | `/ssi` | Static root entry page for the folded backend module under `apps/ssi-control-tower/`. | ## Verification evidence for this branch Local gates run before handoff: ```bash +cd apps/ssi-control-tower && make test +python3 -m ruff check apps/ssi-control-tower pnpm verify pnpm test:e2e git diff --check -cd apps/ssi-control-tower && make test ``` Results: +- `cd apps/ssi-control-tower && make test` passed: **106 passed**. +- `python3 -m ruff check apps/ssi-control-tower` passed: all checks passed. - `pnpm verify` passed: lint, format check, typecheck, Vitest, privacy audit, deterministic IBAN/BIC/CBPR data checks, and build. -- `pnpm test:e2e` passed, including the new `/ssi` Playwright smoke and the privacy-boundary route loop. +- Root Vitest inside `pnpm verify`: 30 files passed / 230 tests passed. +- `pnpm test:e2e` passed: 21 Playwright tests passed. - `git diff --check` passed. -- `cd apps/ssi-control-tower && make test` passed as a sanity check that the folded backend module was not broken. -- Static secret scan over changed/untracked files: 11 files scanned, 0 findings. -- Independent read-only Codex diff review: `APPROVE`. +- Claude Code frontend/design review: final verdict `APPROVE`. ## Boundaries to preserve @@ -64,6 +70,7 @@ Design source: - `DESIGN.md` remains the app-level design contract. - Use the light fintech/practitioner visual system: spacious white cards, subtle borders/shadows, blue/cyan accents, and strong privacy-first messaging. +- Claude Code is currently the preferred frontend/design implementation agent for this app; use it for UI polish, then verify independently. Root suite privacy/product boundaries: @@ -74,8 +81,10 @@ Root suite privacy/product boundaries: SSI backend module boundaries: - `apps/ssi-control-tower/` is a separate FastAPI/Jinja backend module with its own Python environment, tests, SQLite dev data, and synthetic fixtures. -- Public SSI data must remain synthetic and masked. +- Public-source SSI evidence is category-only. Do not commit raw account numbers, IBANs, BICs, ABA/clearing codes, correspondent chains, page excerpts, retrieval hashes, or reversible redaction maps. +- Public SSI data in the app must remain synthetic and masked. Account-bearing fields must contain literal `****` when represented in fixtures. - Real SSI data, raw account numbers, proprietary workflows, credentials, and reversible redaction maps must never be committed. +- Demo routes must remain read-only and database-write-free. Payment/product claim boundaries: diff --git a/README.md b/README.md index d78add8..46d3aa9 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,16 @@ The root suite remains static and browser-only. Backend/product-control modules ## Modules -| Module | Route | Status | What it does | -| --------------------------- | -------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Scrubber** | `/scrubber` | Available | Strip personally identifying fields from `pacs.*` / `camt.*` XML before sharing with peers or vendors. Produces a privacy-safe mapping summary. | -| **Storyteller** | `/storyteller` | Available | Turn a `pacs.002 / 004 / 008 / 009` or `camt.052 / 053 / 054` message into a plain-language narrative plus a structured field projection and quick insights. | -| **IBAN Workbench** | `/iban` | Available | Validate, build, catalogue, and trace provenance for IBANs from bundled SWIFT IBAN Registry-derived data. Builder computes MOD-97 check digits from exact-length BBAN fields; no live BIC, VOP, account existence, or reachability checks. | -| **BIC Validator\*** | `/bic` | Demo | ISO 9362 syntax checks plus a tiny bundled snapshot lookup. **Demonstration only:** bundled BIC data is not accurate/current enough for production, routing, compliance, reachability, or payment decisions. | -| **CBPR+ Readiness Checker** | `/cbpr` | Available | Browser-only AppHdr / Document namespace / CBPR+ schema-profile coverage checks, plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator or MyStandards usage-rule engine. | -| **Payment Insights Lite** | `/insights` | Available | Local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files you provide. Groups files by identifiers in memory; not live payment tracking, VOP, reachability, or settlement monitoring. | -| **SSI Control Tower** | `/ssi` | Backend (folded) | Static entry point for the separate FastAPI/Jinja SSI readiness and governance cockpit under `apps/ssi-control-tower/`. Not part of the root browser runtime; run locally when needed. | -| Vault | — | Planned | Planned encrypted local export bundle: user-controlled download/import, no cloud vault, no server storage, and no persistent browser storage by default. Not built yet. | +| Module | Route | Status | What it does | +| --------------------------- | -------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Scrubber** | `/scrubber` | Available | Strip personally identifying fields from `pacs.*` / `camt.*` XML before sharing with peers or vendors. Produces a privacy-safe mapping summary. | +| **Storyteller** | `/storyteller` | Available | Turn a `pacs.002 / 004 / 008 / 009` or `camt.052 / 053 / 054` message into a plain-language narrative plus a structured field projection and quick insights. | +| **IBAN Workbench** | `/iban` | Available | Validate, build, catalogue, and trace provenance for IBANs from bundled SWIFT IBAN Registry-derived data. Builder computes MOD-97 check digits from exact-length BBAN fields; no live BIC, VOP, account existence, or reachability checks. | +| **BIC Validator\*** | `/bic` | Demo | ISO 9362 syntax checks plus a tiny bundled snapshot lookup. **Demonstration only:** bundled BIC data is not accurate/current enough for production, routing, compliance, reachability, or payment decisions. | +| **CBPR+ Readiness Checker** | `/cbpr` | Available | Browser-only AppHdr / Document namespace / CBPR+ schema-profile coverage checks, plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator or MyStandards usage-rule engine. | +| **Payment Insights Lite** | `/insights` | Available | Local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files you provide. Groups files by identifiers in memory; not live payment tracking, VOP, reachability, or settlement monitoring. | +| **SSI Control Tower** | `/ssi` | Backend (folded) | Static root entry point for the separate FastAPI/Jinja SSI readiness and governance cockpit under `apps/ssi-control-tower/`. The backend module now includes public-evidence sourcing, command-centre panels, exception detail, and guided demo routes; run locally when needed. | +| Vault | — | Planned | Planned encrypted local export bundle: user-controlled download/import, no cloud vault, no server storage, and no persistent browser storage by default. Not built yet. | \*The BIC module intentionally does **not** perform live BIC Directory lookup, current bank-directory enrichment, current SEPA reachability checks, SWIFT FIN @@ -62,6 +62,11 @@ banned APIs or absolute-URL `fetch` calls appear under `src/`. `pacs.*`, and `camt.*` files the user provides in the browser. It is not live payment tracking, settlement monitoring, VOP, SEPA reachability, or live bank lookup. +- **SSI Control Tower:** the folded backend module uses synthetic fixtures plus a + sanitized public-evidence sidecar from official bank SSI/wire/settlement pages. + The sidecar stores only bank name, URL, title, and abstract field categories; + no account numbers, IBANs, BICs, routing codes, correspondent chains, excerpts, + hashes, or real SSI values are committed. - **VOP / Verification of Payee:** out of scope for the offline suite because it requires live certified EPC access. - **Current SEPA reachability, bank address enrichment, and live BIC validation:** @@ -167,6 +172,7 @@ 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 + docs/ # SSI docs including sanitized public-source evidence methodology tests/ # pytest suite for the SSI backend module pyproject.toml # Python dependencies owned by the nested module Makefile # module-local test/install commands diff --git a/ROADMAP.md b/ROADMAP.md index d573a33..f51262a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,7 +30,7 @@ Merged SSI foundation includes: - instruction-level validation, instruction exceptions, and a unified control queue API; - approval, export, and account-privacy hardening for the synthetic public prototype. -Follow-up scope remains: deeper product UI polish inside the FastAPI app, deployment/publication decisions, and real-data/private-lab workflows only after explicit approval. +Follow-up scope now includes a public-evidence command-centre slice inside the FastAPI app: sanitized public bank SSI/wire/settlement-instruction source pointers, `/public-evidence`, `/api/v1/dashboard/operator-summary`, five operator dashboard panels, rule-filtered exceptions, exception detail pages, and a read-only guided `/demo`. Deployment/publication decisions and real-data/private-lab workflows remain separate approval gates. ### 1. Deployment target decision diff --git a/apps/ssi-control-tower/README.md b/apps/ssi-control-tower/README.md index 4d3fa72..8f5ebe2 100644 --- a/apps/ssi-control-tower/README.md +++ b/apps/ssi-control-tower/README.md @@ -37,13 +37,26 @@ V2 adds a first SSI Plus-shaped source-truth layer while preserving the V1 synth 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`. +## Public SSI evidence and command-centre slice + +The Control Tower now includes a public-evidence methodology that uses at least 10 official bank wire/settlement-instruction pages to shape field categories without committing operational SSI values: + +- `/public-evidence` renders the sanitized source list and category model. +- `docs/public_ssi_field_evidence.md` documents the evidence rule: store only bank, URL, page title, and field categories. +- `docs/public_ssi_field_evidence_sources.json` is the sanitized sidecar consumed by tests and demo mode. +- `/api/v1/dashboard/operator-summary` emits five operator panels: today's risk, T+1 blockers, pending approvals, aging exceptions, and export readiness. +- `/demo` is a read-only guided scenario from import to exception remediation, approval, and export. It is synthetic and does not write to the database. +- `/exceptions/{exception_id}` provides root cause, impacted market/currency/asset, owner, recommended action, required evidence, and recent audit context. + +The public-source methodology is category-only. It does not copy account numbers, IBANs, BICs, routing codes, correspondent chains, page excerpts, or retrieval hashes into the repo. + ## 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. +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, public-evidence sanitization, guided demo privacy, command-centre aggregates, exception detail pages, and the full demo flow. ## Commands diff --git a/apps/ssi-control-tower/app/api/dashboard.py b/apps/ssi-control-tower/app/api/dashboard.py index da7d3fa..4b8fc05 100644 --- a/apps/ssi-control-tower/app/api/dashboard.py +++ b/apps/ssi-control-tower/app/api/dashboard.py @@ -9,6 +9,7 @@ from app.api import as_dict from app.deps import get_db from app.models import ExceptionCase, SourceFile, SourceReject, SsiInstruction +from app.services.operator_dashboard import operator_summary from app.services.readiness import UNRESOLVED, readiness_score router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"]) @@ -20,6 +21,12 @@ def get_readiness_score(session: Session = Depends(get_db)): return readiness_score(session) +@router.get("/operator-summary") +def get_operator_summary(session: Session = Depends(get_db)): + """Five-panel command-centre summary for the operator dashboard.""" + return operator_summary(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() diff --git a/apps/ssi-control-tower/app/services/demo_scenarios.py b/apps/ssi-control-tower/app/services/demo_scenarios.py new file mode 100644 index 0000000..5062241 --- /dev/null +++ b/apps/ssi-control-tower/app/services/demo_scenarios.py @@ -0,0 +1,185 @@ +"""Synthetic demo-scenario loader. + +The loader enforces the same privacy invariants as ingestion: + +- Every account-bearing field value must contain literal contiguous ``****``. +- Blank values for account-bearing fields are rejected. +- The error message never echoes the offending value. +- ``inspired_by`` URLs must come from the sanitised public source list at + ``docs/public_ssi_field_evidence_sources.json``. + +The loader does **not** perform any I/O against external systems and does +**not** write to the database. It is a pure read-only structure used by the +``/demo`` route and by the operator dashboard for badge/example copy. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +ACCOUNT_BEARING_KEYS: tuple[str, ...] = ( + "securities_account_masked", + "cash_account_masked", + "iban_placeholder", + "clearing_code", + "account_number_masked", +) +ALLOWED_FIELD_CATEGORIES: frozenset[str] = frozenset( + { + "bic_or_swift", + "iban_or_account", + "aba_routing_or_clearing", + "intermediary_correspondent", + "currency", + "market_security_asset", + "effective_or_versioning", + "contact_or_owner", + } +) +_BIC_SHAPED = re.compile(r"^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$") + + +class DemoScenarioError(ValueError): + """Raised when a demo scenario violates a privacy or structural invariant. + + The message is intentionally generic so account-bearing values are never + echoed back to the caller or to logs. + """ + + +@dataclass(frozen=True) +class DemoStep: + id: str + title: str + narrative: str + field_categories: tuple[str, ...] + fixtures: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class DemoScenario: + id: str + name: str + summary: str + inspired_by: tuple[str, ...] + steps: tuple[DemoStep, ...] + + +def _load_allowed_urls(sources_path: Path) -> frozenset[str]: + if not sources_path.exists(): + return frozenset() + raw = json.loads(sources_path.read_text()) + return frozenset(item["url"] for item in raw if isinstance(item, dict) and "url" in item) + + +def _validate_value(field_name: str, value: Any) -> None: + if field_name in ACCOUNT_BEARING_KEYS: + if not isinstance(value, str) or not value.strip(): + raise DemoScenarioError( + f"Account-bearing field '{field_name}' is blank or missing in demo fixture" + ) + if "****" not in value: + raise DemoScenarioError( + f"Account-bearing field '{field_name}' must contain a literal '****' segment" + ) + + +def _validate_no_bic_shaped_token(text: str) -> None: + """Reject 8/11 character BIC-shaped tokens in demo fixture values. + + We intentionally tokenise on whitespace and punctuation so the check is + forgiving of camel-case-with-numbers strings like ``ROUT-****-US01``. + """ + for token in re.split(r"[^A-Za-z0-9]+", text): + token = token.strip() + if not token: + continue + if _BIC_SHAPED.match(token): + raise DemoScenarioError( + "Demo fixtures must not contain BIC-shaped tokens; use 'bic_or_swift' or 'BIC-****-DEMO'" + ) + + +def _validate_fixtures(fixtures: dict[str, Any]) -> None: + for top_key, body in fixtures.items(): + if not isinstance(body, dict): + continue + for k, v in body.items(): + _validate_value(k, v) + if isinstance(v, str): + _validate_no_bic_shaped_token(v) + # also catch top-level account-bearing keys (defensive) + if top_key in ACCOUNT_BEARING_KEYS: + _validate_value(top_key, body) + + +def _validate_categories(categories: list[str]) -> None: + for c in categories: + if c not in ALLOWED_FIELD_CATEGORIES: + raise DemoScenarioError(f"Unknown field category '{c}' in demo step") + + +def load_scenario(yaml_path: Path, sources_path: Path) -> DemoScenario: + """Load and validate a synthetic demo scenario. + + ``yaml_path`` and ``sources_path`` are explicit so tests can swap them in + without touching the runtime defaults. + """ + if not yaml_path.exists(): + raise DemoScenarioError("Demo scenario file is missing") + raw = yaml.safe_load(yaml_path.read_text()) or {} + allowed_urls = _load_allowed_urls(sources_path) + + inspired_by = list(raw.get("inspired_by") or []) + if allowed_urls: + for url in inspired_by: + if url not in allowed_urls: + raise DemoScenarioError( + "inspired_by URL is not present in the sanitised public source list" + ) + + steps_raw = raw.get("steps") or [] + if not steps_raw: + raise DemoScenarioError("Demo scenario must declare at least one step") + + steps: list[DemoStep] = [] + for step in steps_raw: + cats = list(step.get("field_categories") or []) + _validate_categories(cats) + fixtures = step.get("fixtures") or {} + _validate_fixtures(fixtures) + steps.append( + DemoStep( + id=str(step["id"]), + title=str(step["title"]), + narrative=str(step.get("narrative", "")).strip(), + field_categories=tuple(cats), + fixtures=fixtures, + ) + ) + + return DemoScenario( + id=str(raw.get("id", "demo")), + name=str(raw.get("name", "Demo")), + summary=str(raw.get("summary", "")).strip(), + inspired_by=tuple(inspired_by), + steps=tuple(steps), + ) + + +def default_scenario_path() -> Path: + return Path(__file__).resolve().parents[2] / "data" / "demo_scenarios" / "control_tower_demo.yaml" + + +def default_sources_path() -> Path: + return Path(__file__).resolve().parents[2] / "docs" / "public_ssi_field_evidence_sources.json" + + +def load_default_scenario() -> DemoScenario: + return load_scenario(default_scenario_path(), default_sources_path()) diff --git a/apps/ssi-control-tower/app/services/operator_dashboard.py b/apps/ssi-control-tower/app/services/operator_dashboard.py new file mode 100644 index 0000000..5e7e3de --- /dev/null +++ b/apps/ssi-control-tower/app/services/operator_dashboard.py @@ -0,0 +1,149 @@ +"""Operator command-centre aggregator. + +Pure read-only aggregation over the existing models. The output is a flat +dict of counts/labels suitable for templating and for the JSON API. No +account values, account field names, or operational SSI values appear in the +output - only counts, severity labels, status labels, rule IDs, and market +codes. +""" + +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from app.models import ApprovalRequest, ExceptionCase, SsiRecord +from app.rules.evaluators import ACTIVE_STATUSES +from app.services.exceptions import UNRESOLVED +from app.services.readiness import readiness_score + + +def _parse_iso(value: str | None) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _age_hours(value: str | None, now: datetime) -> float | None: + parsed = _parse_iso(value) + if parsed is None: + return None + return max(0.0, (now - parsed).total_seconds() / 3600.0) + + +def _age_days(value: str | None, now: datetime) -> int | None: + hours = _age_hours(value, now) + if hours is None: + return None + return int(hours // 24) + + +def _changed_since(rows: list[Any], *, threshold: datetime, fields: tuple[str, ...]) -> int: + """Count rows with any selected timestamp at or after ``threshold``. + + This deliberately returns only an aggregate count. It never surfaces record + identifiers, account-bearing fields, or raw source values. + """ + changed = 0 + for row in rows: + for field_name in fields: + parsed = _parse_iso(getattr(row, field_name, None)) + if parsed is not None and parsed >= threshold: + changed += 1 + break + return changed + + +def operator_summary(session: Session, *, now: datetime | None = None) -> dict[str, Any]: + """Return a five-panel command-centre summary. + + Panels: today's risk, T+1 blockers, pending approvals, aging exceptions, + export readiness. The result is composed of counts and label-only buckets; + no account-bearing fields or values are included. + """ + now = now or datetime.now(timezone.utc) + score = readiness_score(session) + + active_records = session.query(SsiRecord).filter(SsiRecord.status.in_(ACTIVE_STATUSES)).all() + records_by_id = {record.ssi_id: record for record in active_records} + active_ids = set(records_by_id) + + unresolved_cases = session.query(ExceptionCase).filter(ExceptionCase.status.in_(UNRESOLVED)).all() + unresolved_active = [case for case in unresolved_cases if case.ssi_id in active_ids] + + severity_breakdown = Counter(case.severity for case in unresolved_active) + rule_breakdown = Counter(case.rule_id for case in unresolved_active) + + t1_cases = [case for case in unresolved_active if case.rule_id.startswith("SSI.T1.")] + t1_by_market = Counter((records_by_id.get(case.ssi_id).market or "UNKNOWN") for case in t1_cases if records_by_id.get(case.ssi_id)) + + aging_buckets = {"<24h": 0, "24-72h": 0, ">72h": 0, "breached": 0} + for case in unresolved_active: + deadline = _parse_iso(case.sla_deadline) + if deadline is not None and deadline < now: + aging_buckets["breached"] += 1 + continue + age_h = _age_hours(case.created_at, now) + if age_h is None or age_h < 24: + aging_buckets["<24h"] += 1 + elif age_h < 72: + aging_buckets["24-72h"] += 1 + else: + aging_buckets[">72h"] += 1 + + pending_approvals = session.query(ApprovalRequest).filter(ApprovalRequest.status == "pending").all() + all_approvals = session.query(ApprovalRequest).all() + pending_by_risk = Counter(approval.risk_level for approval in pending_approvals) + ages = [_age_days(approval.created_at, now) for approval in pending_approvals] + oldest_age_days = max((age for age in ages if age is not None), default=0) + + critical_blocked_ids = {case.ssi_id for case in unresolved_active if case.severity == "critical"} + approved_records = [record for record in active_records if record.approval_status == "approved"] + export_ready = [record for record in approved_records if record.ssi_id not in critical_blocked_ids] + since_yesterday = now - timedelta(days=1) + + return { + "todays_risk": { + "score": score["score"], + "band": score["band"], + "active_ssis": len(active_records), + "unresolved_exceptions": len(unresolved_active), + "by_severity": dict(severity_breakdown), + }, + "t1_blockers": { + "total": len(t1_cases), + "by_market": dict(t1_by_market), + }, + "pending_approvals": { + "total": len(pending_approvals), + "oldest_age_days": oldest_age_days, + "by_risk_level": dict(pending_by_risk), + }, + "aging_exceptions": { + "total_unresolved": len(unresolved_active), + "buckets": aging_buckets, + }, + "export_readiness": { + "approved_records": len(approved_records), + "export_ready": len(export_ready), + "blocked_by_critical_exception": len(approved_records) - len(export_ready), + }, + "changed_since_yesterday": { + "window_label": "Changed since yesterday", + "counts": { + "ssis": _changed_since(active_records, threshold=since_yesterday, fields=("created_at", "updated_at")), + "exceptions": _changed_since(unresolved_active, threshold=since_yesterday, fields=("created_at", "updated_at", "resolved_at")), + "approvals": _changed_since(all_approvals, threshold=since_yesterday, fields=("created_at", "decided_at")), + }, + }, + "rule_breakdown": dict(rule_breakdown), + } diff --git a/apps/ssi-control-tower/app/web/routes.py b/apps/ssi-control-tower/app/web/routes.py index ce24332..5e8c7a5 100644 --- a/apps/ssi-control-tower/app/web/routes.py +++ b/apps/ssi-control-tower/app/web/routes.py @@ -1,6 +1,10 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Request +import json +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session @@ -8,10 +12,68 @@ 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.demo_scenarios import load_default_scenario +from app.services.operator_dashboard import operator_summary from app.services.readiness import readiness_score router = APIRouter(tags=["web"]) templates = Jinja2Templates(directory="app/web/templates") +APP_ROOT = Path(__file__).resolve().parents[2] +PUBLIC_EVIDENCE_PATH = APP_ROOT / "docs" / "public_ssi_field_evidence_sources.json" + +RECOMMENDED_ACTIONS = { + "SSI.STALE.12M": "Request a fresh owner confirmation, attach evidence, then rerun validation before export.", + "SSI.STALE.CRITICAL_MARKET": "Prioritise owner confirmation because this market is settlement-critical for T+1 readiness.", + "SSI.DUPLICATE.ACTIVE": "Retire or expire one overlapping instruction, then submit the retained SSI through four-eyes approval.", + "SSI.GOVERNANCE.OWNER_REQUIRED": "Assign an accountable owner or owner team before the instruction can be treated as governed.", + "SSI.T1.RECENT_CONFIRMATION": "Refresh confirmation evidence for this T+1 market before release.", + "SSI.T1.NO_CRITICAL_EXCEPTIONS": "Resolve critical SSI exceptions before treating this record as export-ready.", +} + +EVIDENCE_REQUIRED = { + "SSI.STALE.12M": "Fresh confirmation from the SSI owner or source-of-truth steward.", + "SSI.STALE.CRITICAL_MARKET": "Fresh owner confirmation plus market-readiness acknowledgement.", + "SSI.DUPLICATE.ACTIVE": "Retirement/expiry evidence for the inactive duplicate and approval evidence for the retained record.", + "SSI.GOVERNANCE.OWNER_REQUIRED": "Named owner or team assignment with accountability evidence.", + "SSI.T1.RECENT_CONFIRMATION": "Recent confirmation evidence aligned to the T+1 cutover window.", + "SSI.T1.NO_CRITICAL_EXCEPTIONS": "Closed exception records with remediation evidence.", +} + + +def _public_sources() -> list[dict]: + raw = json.loads(PUBLIC_EVIDENCE_PATH.read_text()) + return [item for item in raw if isinstance(item, dict)] + + +def _parse_iso(value: str | None) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _case_sla_context(case: ExceptionCase) -> dict[str, int | bool | str | None]: + now = datetime.now(timezone.utc) + created_at = _parse_iso(case.created_at) + deadline = _parse_iso(case.sla_deadline) + age_hours = int(max(0, (now - created_at).total_seconds() // 3600)) if created_at else None + breached = bool(deadline and deadline < now and case.status not in {"closed", "waived"}) + if breached: + status = "SLA breached" + elif deadline: + status = "Within SLA" + else: + status = "No SLA deadline" + return {"age_hours": age_hours, "breached": breached, "status": status, "deadline": case.sla_deadline} + + +def _distinct_categories(sources: list[dict]) -> list[str]: + return sorted({category for source in sources for category in source.get("field_categories", [])}) @router.get("/") @@ -23,6 +85,7 @@ def dashboard(request: Request, session: Session = Depends(get_db)): "dashboard.html", { "score": score, + "operator_summary": operator_summary(session), "exceptions": session.query(ExceptionCase).count(), "ssis": session.query(SsiRecord).count(), "v2_controls": v2_controls, @@ -42,12 +105,48 @@ def ssis_page(request: Request, session: Session = Depends(get_db)): @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()}) + ssi = session.get(SsiRecord, ssi_id) + if ssi is None: + raise HTTPException(status_code=404, detail="SSI not found") + return templates.TemplateResponse(request, "ssi_detail.html", {"ssi": ssi, "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()}) +def exceptions_page(request: Request, rule_id: str | None = None, session: Session = Depends(get_db)): + query = session.query(ExceptionCase) + if rule_id: + query = query.filter(ExceptionCase.rule_id == rule_id) + cases = query.order_by(ExceptionCase.sla_deadline.asc(), ExceptionCase.created_at.desc()).all() + return templates.TemplateResponse(request, "exceptions.html", {"exceptions": cases, "rule_id": rule_id}) + + +@router.get("/exceptions/{exception_id}") +def exception_detail_page(exception_id: str, request: Request, session: Session = Depends(get_db)): + exception = session.get(ExceptionCase, exception_id) + if exception is None: + raise HTTPException(status_code=404, detail="Exception not found") + ssi = session.get(SsiRecord, exception.ssi_id) + approval = session.query(ApprovalRequest).filter(ApprovalRequest.ssi_id == exception.ssi_id).order_by(ApprovalRequest.created_at.desc()).first() + audit_events = ( + session.query(AuditEvent) + .filter(AuditEvent.entity_id.in_([exception.exception_id, exception.ssi_id])) + .order_by(AuditEvent.created_at.desc()) + .limit(8) + .all() + ) + return templates.TemplateResponse( + request, + "exception_detail.html", + { + "exception": exception, + "ssi": ssi, + "approval": approval, + "audit_events": audit_events, + "recommended_action": exception.suggested_fix or RECOMMENDED_ACTIONS.get(exception.rule_id, "Review the failed field, assign an owner, attach remediation evidence, and rerun validation."), + "evidence_required": EVIDENCE_REQUIRED.get(exception.rule_id, "Owner-confirmed remediation evidence and audit reference."), + "sla_context": _case_sla_context(exception), + }, + ) @router.get("/approvals") @@ -63,3 +162,47 @@ def audit_page(request: Request, session: Session = Depends(get_db)): @router.get("/rules") def rules_page(request: Request): return templates.TemplateResponse(request, "rules.html", {"rules": load_rules()}) + + +@router.get("/public-evidence") +def public_evidence_page(request: Request): + sources = _public_sources() + return templates.TemplateResponse( + request, + "public_evidence.html", + {"sources": sources, "distinct_categories": _distinct_categories(sources)}, + ) + + +@router.get("/demo") +def demo_page(request: Request, session: Session = Depends(get_db)): + scenario = load_default_scenario() + return templates.TemplateResponse( + request, + "demo.html", + { + "scenario": scenario, + "current_step": None, + "step_number": None, + "total_steps": len(scenario.steps), + "operator_summary": operator_summary(session), + }, + ) + + +@router.get("/demo/step/{step_number}") +def demo_step_page(step_number: int, request: Request, session: Session = Depends(get_db)): + scenario = load_default_scenario() + if step_number < 1 or step_number > len(scenario.steps): + raise HTTPException(status_code=404, detail="Demo step not found") + return templates.TemplateResponse( + request, + "demo.html", + { + "scenario": scenario, + "current_step": scenario.steps[step_number - 1], + "step_number": step_number, + "total_steps": len(scenario.steps), + "operator_summary": operator_summary(session), + }, + ) diff --git a/apps/ssi-control-tower/app/web/templates/base.html b/apps/ssi-control-tower/app/web/templates/base.html index 2eff56b..5024f94 100644 --- a/apps/ssi-control-tower/app/web/templates/base.html +++ b/apps/ssi-control-tower/app/web/templates/base.html @@ -24,8 +24,10 @@ Approvals Audit Rules + Demo + Evidence - + diff --git a/apps/ssi-control-tower/app/web/templates/dashboard.html b/apps/ssi-control-tower/app/web/templates/dashboard.html index 5ee50e8..5f762b7 100644 --- a/apps/ssi-control-tower/app/web/templates/dashboard.html +++ b/apps/ssi-control-tower/app/web/templates/dashboard.html @@ -1,4 +1,14 @@ {% extends "base.html" %}{% block content %} +{% set band = score.band|lower %} +{% if band == "green" %} + {% set band_class = "border-emerald-200 bg-emerald-50 text-emerald-700" %} +{% elif band == "amber" %} + {% set band_class = "border-amber-200 bg-amber-50 text-amber-700" %} +{% elif band == "red" %} + {% set band_class = "border-rose-200 bg-rose-50 text-rose-600" %} +{% else %} + {% set band_class = "border-slate-300 bg-slate-100 text-slate-700" %} +{% endif %}
@@ -24,7 +34,7 @@

T+1 SSI readiness

{{ score.score }}

- {{ score.band }} + {{ score.band }}

Lifecycle governance, not field validation: owner, SLA, approval, audit, and export readiness are all visible.

@@ -49,6 +59,76 @@

+
+
+

Operator command center

+

Today’s SSI control room

+

Public-bank SSI evidence shaped these five panels: risk, T+1 blockers, approvals, exception aging, and export readiness.

+
+ Run guided demo +
+ +
+
+

Today's risk

+
{{ operator_summary.todays_risk.score }}{{ operator_summary.todays_risk.band }}
+

{{ operator_summary.todays_risk.unresolved_exceptions }} unresolved controls across {{ operator_summary.todays_risk.active_ssis }} active SSIs.

+
+
+

T+1 blockers

+

{{ operator_summary.t1_blockers.total }}

+

Grouped by market code only; no account fields or SSI IDs exposed.

+
+ {% for market, count in operator_summary.t1_blockers.by_market.items() %} + {{ market }} · {{ count }} + {% else %} + No active T+1 market blockers + {% endfor %} +
+
+
+

Pending approvals

+

{{ operator_summary.pending_approvals.total }}

+ {% if operator_summary.pending_approvals.total > 0 %} +

Oldest: {{ operator_summary.pending_approvals.oldest_age_days }} day(s). Four-eyes queue stays human approved.

+ {% else %} +

No pending approvals. Four-eyes queue stays human approved.

+ {% endif %} +
+
+

Aging exceptions

+

{{ operator_summary.aging_exceptions.buckets.breached }}

+

SLA breached from {{ operator_summary.aging_exceptions.total_unresolved }} unresolved items.

+
+
+

Export readiness

+

{{ operator_summary.export_readiness.export_ready }}

+

Approved and not blocked by critical exceptions.

+
+
+ +
+
+
+

{{ operator_summary.changed_since_yesterday.window_label }}

+

Last 24h movement only, aggregated by control object type.

+
+
+ SSI changes · {{ operator_summary.changed_since_yesterday["counts"]["ssis"] }} + Exception changes · {{ operator_summary.changed_since_yesterday["counts"]["exceptions"] }} + Approval changes · {{ operator_summary.changed_since_yesterday["counts"]["approvals"] }} +
+
+
+ +
+ {% for rule_id, count in operator_summary.rule_breakdown.items() %} + {{ rule_id }} · {{ count }} + {% endfor %} +
+

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

Guided demo mode

+

{{ scenario.name }}

+

{{ scenario.summary }}

+
+ {{ total_steps }} read-only steps +
+ +
+
+

Today's risk

+

{{ operator_summary.todays_risk.score }}

+
+
+

T+1 blockers

+

{{ operator_summary.t1_blockers.total }}

+
+
+

Pending approvals

+

{{ operator_summary.pending_approvals.total }}

+
+
+

Aging exceptions

+

{{ operator_summary.aging_exceptions.total_unresolved }}

+
+
+

Export readiness

+

{{ operator_summary.export_readiness.export_ready }}

+
+
+ + {% if current_step %} +
+

Step {{ step_number }} of {{ total_steps }}

+

{{ current_step.title }}

+

{{ current_step.narrative }}

+
+ {% for category in current_step.field_categories %} + {{ category }} + {% endfor %} +
+
+

Synthetic fixture preview

+

Lowercase values such as bic_or_swift are category placeholders, not operational data. Account-bearing placeholders stay masked with ****.

+ {% for group, values in current_step.fixtures.items() %} +
+

{{ group }}

+
+ {% for key, value in values.items() %} +
{{ key }}
{{ value }}
+ {% endfor %} +
+
+ {% endfor %} +
+
+
+ Overview + {% if step_number > 1 %}← Previous{% endif %} + {% if step_number < total_steps %}Next →{% endif %} +
+ {% else %} +
+ {% for step in scenario.steps %} + +

Step {{ loop.index }}

+

{{ step.title }}

+

{{ step.narrative }}

+
+ {% endfor %} +
+ {% endif %} + +
+

Scenario inspiration comes from {{ scenario.inspired_by|length }} public bank URLs, but only field categories are stored. No demo route writes to the database. View public evidence.

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

Exception detail

+

{{ exception.rule_id }}

+

{{ exception.description }}

+
+
+ {{ exception.severity }} + {{ exception.status }} +
+
+ +
+ {{ sla_context.status }} + {% if sla_context.age_hours is not none %}Age {{ sla_context.age_hours }}h{% endif %} + {% if sla_context.deadline %}SLA {{ sla_context.deadline[:16] }}{% endif %} +
+ +
+
+

Root cause

+

Failed rule {{ exception.rule_id }}{% if exception.failed_field %} on field {{ exception.failed_field }}{% endif %}. The case remains visible until remediation evidence closes or waives it.

+
+
+

Impacted

+ {% if ssi %} +
+
Market
{{ ssi.market or "UNKNOWN" }}
+
Currency
{{ ssi.currency_code or "UNKNOWN" }}
+
Asset
{{ ssi.asset_class or "UNKNOWN" }}
+
Owner
{{ exception.owner_user_email or ssi.owner_team or "Unassigned" }}
+
+ {% else %} +

Linked SSI record was not found.

+ {% endif %} +
+
+

Approval path

+

Critical unresolved exceptions block export readiness. {% if approval %}Latest approval is {{ approval.status }} at risk level {{ approval.risk_level }}.{% else %}No linked approval request is currently queued.{% endif %}

+
    +
  1. Owner remediation · assign an owner and resolve the failed rule.
  2. +
  3. Evidence attachment · capture remediation evidence without account values.
  4. +
  5. Four-eyes approval · route material SSI changes through the approval queue.
  6. +
  7. Export readiness · export only approved records with no critical blockers.
  8. +
+ Open approvals +
+
+ +
+
+

Recommended action

+

{{ recommended_action }}

+
+
+

Evidence required

+

{{ evidence_required }}

+
+
+ +
+
+
+

Field evidence

+

Public bank SSI pages informed the field-category model, but this app stores only URLs, titles, and category labels.

+
+ View public evidence +
+
+ + {% if audit_events %} +
+

Recent audit trail

+
+ {% for event in audit_events %} +
{{ event.action }}{{ event.created_at[:19] }}{{ event.actor_role }}
+ {% endfor %} +
+
+ {% endif %} + +
+ ← Back to exceptions + {% if ssi %}Open SSI record{% endif %} +
+
+{% endblock %} diff --git a/apps/ssi-control-tower/app/web/templates/exceptions.html b/apps/ssi-control-tower/app/web/templates/exceptions.html index ab6e912..11bd093 100644 --- a/apps/ssi-control-tower/app/web/templates/exceptions.html +++ b/apps/ssi-control-tower/app/web/templates/exceptions.html @@ -1,19 +1,29 @@ {% extends "base.html" %}{% block content %} -
+

Controls queue

-

Exceptions

-

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

+

Exceptions{% if rule_id %}: {{ rule_id }}{% endif %}

+

Rule breaches grouped for ownership, remediation, SLA visibility, and drill-down evidence.{% if rule_id %} Clear rule filter.{% endif %}

- {{ exceptions|length }} open items + Public field evidence
{% for e in exceptions %} -
-
{{ e.severity }}{{ e.rule_id }}{{ e.status }}
+
+
+ {{ e.severity }} + {{ e.rule_id }} + {{ e.status }} + {% if e.sla_deadline %}SLA {{ e.sla_deadline[:10] }}{% endif %} +

{{ e.description }}

-
+
+ {% if e.failed_field %}field · {{ e.failed_field }}{% endif %} + {% if e.owner_user_email %}owner assigned{% else %}owner needed{% endif %} + Open detail → +
+ {% else %}
No exceptions currently need attention.
{% endfor %} diff --git a/apps/ssi-control-tower/app/web/templates/public_evidence.html b/apps/ssi-control-tower/app/web/templates/public_evidence.html new file mode 100644 index 0000000..027bac9 --- /dev/null +++ b/apps/ssi-control-tower/app/web/templates/public_evidence.html @@ -0,0 +1,55 @@ +{% extends "base.html" %}{% block content %} +
+
+
+

Public source evidence

+

Bank SSI & settlement-instruction pages

+

+ Each row points to a public, official-domain page. We store only bank, url, title, and field categories; no source text, hashes, or operational tokens are retained. +

+
+ {{ sources|length }} verified sources +
+ +
+ {% for category in distinct_categories %} + {{ category }} + {% endfor %} +
+ +
+ + + + + + + + + + + {% for s in sources %} + + + + + + + {% endfor %} + +
BankTitleField categoriesSource
{{ s.bank }}{{ s.title }} +
+ {% for c in s.field_categories %} + {{ c }} + {% endfor %} +
+
+ Open +
+
+ +

+ These sources informed the field categories validated by the Control Tower. The synthetic demo scenarios in /demo are inspired by these categories but contain only obviously fictional, masked values. +

+
+{% endblock %} diff --git a/apps/ssi-control-tower/data/demo_scenarios/control_tower_demo.yaml b/apps/ssi-control-tower/data/demo_scenarios/control_tower_demo.yaml new file mode 100644 index 0000000..7f1fae4 --- /dev/null +++ b/apps/ssi-control-tower/data/demo_scenarios/control_tower_demo.yaml @@ -0,0 +1,101 @@ +id: control_tower_walkthrough +name: SSI Control Tower walkthrough +summary: >- + A read-only, fully synthetic import-to-export walkthrough that mirrors the + field categories published on public bank SSI/settlement-instruction pages. + No real account values, BICs, IBANs, routing codes, or correspondent chains + are used. +inspired_by: + - https://www.chase.com/digital/wire-transfer/faqs + - https://www.wellsfargo.com/assets/pdf/commercial/international/resources/swift-intermediary-bank-details.pdf + - https://webapp.sebgroup.com/mb/mblib.nsf/alldocsbyunid/4679FC1CDED4F821C125867E0034254B/$FILE/Settlement_instructions_equities.pdf + - https://www.nordea.com/en/doc/standard-settlement-instructions-nordea-bank-abp.pdf + - https://www.dbs.com/iwov-resources/images/vn/DBSVN-Standard-Settlement-Instruction.pdf + - https://www.ocbc.com/business-banking/help-and-support/payment-collection-transactions/inward-tt +steps: + - id: import + title: Import synthetic SSI fixture + narrative: >- + Operator imports a public-evidence-shaped SSI fixture. The import stage + checks source trust, owner fields, market/currency scope, and masked + account-bearing placeholders before any record can enter the control room. + field_categories: + - bic_or_swift + - iban_or_account + - currency + - market_security_asset + fixtures: + sample_record: + market: FR + currency: EUR + asset: market_security_asset + place_of_settlement: bic_or_swift + securities_account_masked: SEC-****-1042 + cash_account_masked: CASH-****-2042 + - id: exception + title: Exception triage + narrative: >- + The exception stage groups rule breaches by severity, market, and public + field category. Operators see the issue and SLA impact, but no SSI IDs, + raw account values, or live settlement instructions are exposed. + field_categories: + - intermediary_correspondent + - aba_routing_or_clearing + - iban_or_account + fixtures: + sample_record: + failed_category: intermediary_correspondent + clearing_code: ROUT-****-US01 + securities_account_masked: SEC-****-2042 + cash_account_masked: CASH-****-3042 + - id: remediation + title: Remediation plan + narrative: >- + Owner prepares remediation evidence: fresh confirmation, missing owner + assignment, or duplicate-retirement proof. The page explains what evidence + is needed before the approval queue can move. + field_categories: + - contact_or_owner + - effective_or_versioning + - bic_or_swift + fixtures: + sample_record: + owner: contact_or_owner + effective_from: "2026-04-01" + place_of_settlement: bic_or_swift + securities_account_masked: SEC-****-4042 + cash_account_masked: CASH-****-4042 + - id: approval + title: Approval handoff + narrative: >- + Four-eyes approver reviews the remediation summary and evidence reference. + The approval stage stays human-approved and shows only aggregate risk and + workflow state in the demo. + field_categories: + - contact_or_owner + - effective_or_versioning + - currency + fixtures: + sample_record: + risk_level: medium + approver: contact_or_owner + currency: EUR + securities_account_masked: SEC-****-5042 + cash_account_masked: CASH-****-5042 + - id: export + title: Export readiness + narrative: >- + Export stage proves which active SSIs are approved and not blocked by + critical exceptions. The export endpoint remains separate from demo mode; + this route only tells the synthetic readiness story. + field_categories: + - currency + - market_security_asset + - effective_or_versioning + fixtures: + sample_record: + market: SE + currency: SEK + asset: market_security_asset + securities_account_masked: SEC-****-6042 + cash_account_masked: CASH-****-6042 diff --git a/apps/ssi-control-tower/docs/public_ssi_field_evidence.md b/apps/ssi-control-tower/docs/public_ssi_field_evidence.md new file mode 100644 index 0000000..51262ab --- /dev/null +++ b/apps/ssi-control-tower/docs/public_ssi_field_evidence.md @@ -0,0 +1,85 @@ +# Public Bank SSI Field Evidence + +This page summarises the **public, official-domain** bank pages we reviewed when +shaping the SSI Control Tower field model. We use these sources to confirm that +the categories the Control Tower validates and reports on (BIC, IBAN/account, +routing/clearing, intermediary/correspondent, currency, market/security/asset, +effective/versioning, contact/owner) are the categories real banks publish on +their wire-transfer and settlement-instruction pages. + +## Privacy and inclusion rules + +The repository only stores **four fields** per source: + +- `bank` — institution name +- `url` — public URL (kept as a pointer, not for retrieval) +- `title` — page title from the public source +- `field_categories` — the abstract field categories the page advertises + +We deliberately exclude: + +- raw or excerpted text from the source page, +- byte length, content hash, or any retrieval metadata that would let a reader + reconstruct the source, +- account numbers, IBANs, BICs, ABA/clearing codes, swift correspondents, or any + other operationally sensitive token. + +The structured copy of this list lives in +`docs/public_ssi_field_evidence_sources.json`. Tests enforce that the JSON only +exposes the four allowed fields and contains no IBAN/BIC/ABA/account-shaped +tokens. + +## Why a "category" only model + +We need to demonstrate that the categories drive Control Tower rules and demo +flows; we do **not** need to copy operational data from any bank. The category +labels are descriptive and reusable across banks, and they map directly to: + +- `app/services/operator_dashboard.py` — operator command-centre panels, +- `app/services/demo_scenarios.py` — synthetic demo scenario steps, +- the validation rules in `rules/`. + +## Verified sources + +The 11 sources below were verified on the public web during this slice (each +source returned HTTP 200 in the repeat verifier) and the title was confirmed to +match a settlement-instruction or wire-transfer landing page. + +> The structured source list with field categories per page is in +> `public_ssi_field_evidence_sources.json`. Reading that file is the +> authoritative way to consume the evidence; the markdown table here is for +> human navigation only and intentionally lists only bank + page title. + +| Bank | Page | +| ---------------- | ------------------------------------------ | +| Bank of America | Wire Transfer flyer | +| Chase | Wire Transfer FAQs | +| Wells Fargo | Swift Intermediary Bank Details | +| U.S. Bank | How do I receive a wire transfer? | +| Citi | Online Wire Transfer Instructions | +| SEB | Settlement Instructions Equities | +| Nordea | Nordea Standard Settlement Instructions | +| Swedbank | Standard Settlement Instructions | +| DBS | DBS Standard Settlement Instruction | +| OCBC | Help and Support - Inward TT | +| BNP Paribas | Info for Settlement Instructions | + +## How this evidence shapes the Control Tower + +- **Operator command centre** surfaces panels (today's risk, T+1 blockers, + pending approvals, aging exceptions, export readiness) keyed on the + categories the public pages publish. +- **Exception detail** ties each rule failure back to the affected category + (e.g. `iban_or_account`, `bic_or_swift`, `intermediary_correspondent`) and + links here so an analyst can read the public framing without leaving the app. +- **Demo scenarios** are 100% synthetic but inspired by these categories. No + scenario value is copied from any source; account-bearing fields contain a + literal `****` segment. + +## How to refresh + +When verifying additional public sources, only persist the four allowed fields. +The internal pipeline that produces the temp file +`/tmp/public_ssi_verified_sources.json` keeps additional working metadata +(text hashes, byte lengths, redacted excerpts), but those fields **must never** +land in the repository copy. diff --git a/apps/ssi-control-tower/docs/public_ssi_field_evidence_sources.json b/apps/ssi-control-tower/docs/public_ssi_field_evidence_sources.json new file mode 100644 index 0000000..224a523 --- /dev/null +++ b/apps/ssi-control-tower/docs/public_ssi_field_evidence_sources.json @@ -0,0 +1,133 @@ +[ + { + "bank": "Bank of America", + "url": "https://info.bankofamerica.com/content/dam/consumer-info/en/digital-banking/pdf/wire-transfers_flyer.pdf?sgm=adv", + "title": "Wire Transfer flyer", + "field_categories": [ + "bic_or_swift", + "iban_or_account", + "aba_routing_or_clearing", + "currency", + "market_security_asset", + "contact_or_owner" + ] + }, + { + "bank": "Chase", + "url": "https://www.chase.com/digital/wire-transfer/faqs", + "title": "Wire Transfer FAQs | Chase", + "field_categories": [ + "bic_or_swift", + "iban_or_account", + "aba_routing_or_clearing", + "intermediary_correspondent", + "currency", + "effective_or_versioning", + "contact_or_owner" + ] + }, + { + "bank": "Wells Fargo", + "url": "https://www.wellsfargo.com/assets/pdf/commercial/international/resources/swift-intermediary-bank-details.pdf", + "title": "Swift Intermediary Bank Details", + "field_categories": [ + "bic_or_swift", + "aba_routing_or_clearing", + "intermediary_correspondent", + "currency", + "market_security_asset" + ] + }, + { + "bank": "U.S. Bank", + "url": "https://www.usbank.com/customer-service/knowledge-base/KB0069429.html", + "title": "How do I receive a wire transfer?", + "field_categories": [ + "bic_or_swift", + "iban_or_account", + "aba_routing_or_clearing", + "market_security_asset", + "contact_or_owner" + ] + }, + { + "bank": "Citi", + "url": "https://citibank.com/citigoldprivateclient/homepage/pdfs/benefits-wiretransfer.pdf", + "title": "Online Wire Transfer Instructions", + "field_categories": [ + "bic_or_swift", + "iban_or_account", + "aba_routing_or_clearing", + "intermediary_correspondent", + "currency", + "effective_or_versioning", + "contact_or_owner" + ] + }, + { + "bank": "SEB", + "url": "https://webapp.sebgroup.com/mb/mblib.nsf/alldocsbyunid/4679FC1CDED4F821C125867E0034254B/$FILE/Settlement_instructions_equities.pdf", + "title": "Settlement Instructions Equities", + "field_categories": [ + "bic_or_swift", + "iban_or_account", + "aba_routing_or_clearing", + "currency", + "market_security_asset" + ] + }, + { + "bank": "Nordea", + "url": "https://www.nordea.com/en/doc/standard-settlement-instructions-nordea-bank-abp.pdf", + "title": "Nordea Standard Settlement Instructions", + "field_categories": [ + "bic_or_swift", + "iban_or_account", + "currency", + "market_security_asset", + "effective_or_versioning" + ] + }, + { + "bank": "Swedbank", + "url": "https://www.swedbank.com/corporate/cash-management-transaction-services/bank-coverage/ssi.html", + "title": "Standard Settlement Instructions", + "field_categories": [ + "market_security_asset", + "contact_or_owner" + ] + }, + { + "bank": "DBS", + "url": "https://www.dbs.com/iwov-resources/images/vn/DBSVN-Standard-Settlement-Instruction.pdf", + "title": "DBS Standard Settlement Instruction", + "field_categories": [ + "bic_or_swift", + "intermediary_correspondent", + "currency", + "market_security_asset", + "effective_or_versioning" + ] + }, + { + "bank": "OCBC", + "url": "https://www.ocbc.com/business-banking/help-and-support/payment-collection-transactions/inward-tt", + "title": "Help and Support - Inward TT", + "field_categories": [ + "bic_or_swift", + "iban_or_account", + "intermediary_correspondent", + "currency", + "contact_or_owner" + ] + }, + { + "bank": "BNP Paribas", + "url": "https://developers.cib.bnpparibas.com/api-docs/info-settlement-instructions", + "title": "Info for Settlement Instructions", + "field_categories": [ + "market_security_asset", + "contact_or_owner" + ] + } +] diff --git a/apps/ssi-control-tower/tests/test_demo_mode.py b/apps/ssi-control-tower/tests/test_demo_mode.py new file mode 100644 index 0000000..0e2dbf3 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_demo_mode.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import re +from html import unescape + +from app.services.demo_scenarios import load_default_scenario + +IBAN_SHAPED = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b") +BIC_SHAPED = re.compile(r"\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b") +ROUTING_SHAPED = re.compile(r"(?m)^\s*\d{9}\s*$") + + +def test_demo_scenario_follows_import_exception_remediation_approval_export_journey(): + scenario = load_default_scenario() + assert [step.id for step in scenario.steps] == ["import", "exception", "remediation", "approval", "export"] + rendered_titles = " ".join(step.title.lower() for step in scenario.steps) + for stage in ["import", "exception", "remediation", "approval", "export"]: + assert stage in rendered_titles + + +def test_demo_overview_renders_all_steps(client): + scenario = load_default_scenario() + response = client.get("/demo") + assert response.status_code == 200, response.text + html = unescape(response.text) + assert scenario.name in html + assert f"{len(scenario.steps)} read-only steps" in html + for step in scenario.steps: + assert step.title in html + assert "/public-evidence" in html + + +def test_demo_step_renders_synthetic_categories(client): + scenario = load_default_scenario() + response = client.get("/demo/step/1") + assert response.status_code == 200, response.text + html = unescape(response.text) + assert "Step 1" in html + assert scenario.steps[0].title in html + for category in scenario.steps[0].field_categories: + assert category in html + + +def test_demo_step_out_of_range_returns_404(client): + response = client.get("/demo/step/999") + assert response.status_code == 404 + + +def test_demo_pages_do_not_render_raw_operational_tokens(client): + html = client.get("/demo/step/1").text + assert not IBAN_SHAPED.search(html) + assert not BIC_SHAPED.search(html) + assert not ROUTING_SHAPED.search(html) + assert "IBAN-****-DEMO" not in html # step 1 should not need even masked IBAN placeholder diff --git a/apps/ssi-control-tower/tests/test_demo_scenario_privacy.py b/apps/ssi-control-tower/tests/test_demo_scenario_privacy.py new file mode 100644 index 0000000..2347dea --- /dev/null +++ b/apps/ssi-control-tower/tests/test_demo_scenario_privacy.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import re +from copy import deepcopy +from pathlib import Path + +import pytest +import yaml + +from app.services.demo_scenarios import DemoScenarioError, default_scenario_path, default_sources_path, load_default_scenario, load_scenario + +IBAN_SHAPED = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b") +BIC_SHAPED = re.compile(r"\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b") +ROUTING_SHAPED = re.compile(r"(?m)^\s*\d{9}\s*$") + + +def test_default_demo_scenario_loads_and_links_public_sources(): + scenario = load_default_scenario() + assert scenario.steps + assert len(scenario.steps) >= 5 + assert scenario.inspired_by + assert all(url.startswith("https://") for url in scenario.inspired_by) + + +def test_demo_scenario_file_contains_only_masked_synthetic_account_values(): + text = default_scenario_path().read_text() + assert "****" in text + assert not IBAN_SHAPED.search(text) + assert not BIC_SHAPED.search(text) + assert not ROUTING_SHAPED.search(text) + data = yaml.safe_load(text) + for step in data["steps"]: + for fixture in (step.get("fixtures") or {}).values(): + for key, value in fixture.items(): + if key.endswith("_masked") or key in {"iban_placeholder", "clearing_code", "account_number_masked"}: + assert "****" in value + + +@pytest.mark.parametrize("bad_value", ["", "123456789", "IBAN-DEMO-RAW"]) +def test_demo_loader_rejects_unmasked_or_blank_account_values_without_echoing(tmp_path: Path, bad_value: str): + data = yaml.safe_load(default_scenario_path().read_text()) + mutated = deepcopy(data) + mutated["steps"][0]["fixtures"]["sample_record"]["securities_account_masked"] = bad_value + scenario_path = tmp_path / "scenario.yaml" + scenario_path.write_text(yaml.safe_dump(mutated)) + + with pytest.raises(DemoScenarioError) as exc: + load_scenario(scenario_path, default_sources_path()) + + message = str(exc.value) + assert "securities_account_masked" in message + if bad_value: + assert bad_value not in message + + +def test_demo_loader_rejects_unknown_inspired_by_url(tmp_path: Path): + data = yaml.safe_load(default_scenario_path().read_text()) + data["inspired_by"] = ["https://example.com/not-a-source"] + scenario_path = tmp_path / "scenario.yaml" + scenario_path.write_text(yaml.safe_dump(data)) + + with pytest.raises(DemoScenarioError): + load_scenario(scenario_path, default_sources_path()) diff --git a/apps/ssi-control-tower/tests/test_design_tokens.py b/apps/ssi-control-tower/tests/test_design_tokens.py new file mode 100644 index 0000000..ba13ce1 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_design_tokens.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from app.models import ExceptionCase + + +def test_core_pages_keep_raafet_cyan_brand_tokens(client, db_session): + exception = db_session.query(ExceptionCase).first() + assert exception is not None + paths = ["/", "/exceptions", f"/exceptions/{exception.exception_id}", "/demo", "/public-evidence"] + for path in paths: + response = client.get(path) + assert response.status_code == 200, response.text + html = response.text + assert "bg-[#EAF8FF]" in html + assert "text-[#0EA5E9]" in html + + +def test_mobile_navigation_exposes_control_tower_destinations(client): + html = client.get("/").text + assert "md:hidden" in html + for label in ["Imports", "SSIs", "Exceptions", "Approvals", "Audit", "Rules", "Demo", "Evidence"]: + assert f">{label}" in html diff --git a/apps/ssi-control-tower/tests/test_exception_detail_page.py b/apps/ssi-control-tower/tests/test_exception_detail_page.py new file mode 100644 index 0000000..7f123b2 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_exception_detail_page.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import re + +from app.models import ExceptionCase + +DIGIT_RUN = re.compile(r"(? str: + case = db_session.query(ExceptionCase).first() + assert case is not None + return case.exception_id + + +def test_exception_detail_page_renders_operator_sections(client, db_session): + exception_id = _first_exception_id(db_session) + response = client.get(f"/exceptions/{exception_id}") + assert response.status_code == 200, response.text + html = response.text + for label in [ + "Root cause", + "Impacted", + "Recommended action", + "Evidence required", + "Approval path", + "Owner remediation", + "Evidence attachment", + "Four-eyes approval", + "Export readiness", + "Age", + "SLA", + ]: + assert label in html + assert "/public-evidence" in html + + +def test_exception_detail_page_does_not_render_account_values(client, db_session): + exception_id = _first_exception_id(db_session) + html = client.get(f"/exceptions/{exception_id}").text + assert "securities_account_masked" not in html + assert "cash_account_masked" not in html + assert "SEC-****" not in html + assert "CASH-****" not in html + assert not DIGIT_RUN.search(html) + + +def test_exception_detail_unknown_id_returns_404(client): + response = client.get("/exceptions/not-a-real-exception") + assert response.status_code == 404 + + +def test_exceptions_queue_links_to_detail_pages(client, db_session): + exception_id = _first_exception_id(db_session) + response = client.get("/exceptions") + assert response.status_code == 200, response.text + assert f"/exceptions/{exception_id}" in response.text diff --git a/apps/ssi-control-tower/tests/test_operator_dashboard.py b/apps/ssi-control-tower/tests/test_operator_dashboard.py new file mode 100644 index 0000000..0f4b9ff --- /dev/null +++ b/apps/ssi-control-tower/tests/test_operator_dashboard.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from app.models import ApprovalRequest, ExceptionCase, SsiRecord + + +def test_operator_summary_api_returns_five_panels_without_account_fields(client): + response = client.get("/api/v1/dashboard/operator-summary") + assert response.status_code == 200, response.text + payload = response.json() + for key in [ + "todays_risk", + "t1_blockers", + "pending_approvals", + "aging_exceptions", + "export_readiness", + "changed_since_yesterday", + ]: + assert key in payload + + rendered = response.text + forbidden = [ + "account_number_masked", + "securities_account_masked", + "cash_account_masked", + "SEC-****", + "CASH-****", + ] + assert not any(token in rendered for token in forbidden) + + +def test_operator_summary_exposes_changed_since_yesterday_label_only(client): + response = client.get("/api/v1/dashboard/operator-summary") + assert response.status_code == 200, response.text + payload = response.json()["changed_since_yesterday"] + assert payload["window_label"] == "Changed since yesterday" + assert set(payload["counts"]) == {"ssis", "exceptions", "approvals"} + assert all(isinstance(value, int) and value >= 0 for value in payload["counts"].values()) + rendered = response.text + assert "account_number" not in rendered + assert "ssi_id" not in rendered + + +def test_operator_summary_aging_buckets_sum_to_total(client): + payload = client.get("/api/v1/dashboard/operator-summary").json() + buckets = payload["aging_exceptions"]["buckets"] + assert set(buckets) == {"<24h", "24-72h", ">72h", "breached"} + assert all(isinstance(value, int) and value >= 0 for value in buckets.values()) + assert sum(buckets.values()) == payload["aging_exceptions"]["total_unresolved"] + + +def test_operator_dashboard_renders_changed_since_yesterday(client): + response = client.get("/") + assert response.status_code == 200, response.text + html = response.text + assert "Changed since yesterday" in html + assert "SSI changes" in html + assert "Exception changes" in html + assert "Approval changes" in html + + +def test_operator_summary_t1_blockers_emit_market_counts_only(client): + payload = client.get("/api/v1/dashboard/operator-summary").json() + by_market = payload["t1_blockers"]["by_market"] + assert isinstance(by_market, dict) + for market, count in by_market.items(): + assert market == "UNKNOWN" or (market.isupper() and 2 <= len(market) <= 3) + assert isinstance(count, int) + assert "ssi_id" not in client.get("/api/v1/dashboard/operator-summary").text + + +def test_demo_routes_do_not_write_to_database(client, db_session): + before = ( + db_session.query(SsiRecord).count(), + db_session.query(ExceptionCase).count(), + db_session.query(ApprovalRequest).count(), + ) + assert client.get("/demo").status_code == 200 + assert client.get("/demo/step/1").status_code == 200 + db_session.expire_all() + after = ( + db_session.query(SsiRecord).count(), + db_session.query(ExceptionCase).count(), + db_session.query(ApprovalRequest).count(), + ) + assert after == before diff --git a/apps/ssi-control-tower/tests/test_operator_dashboard_filters.py b/apps/ssi-control-tower/tests/test_operator_dashboard_filters.py new file mode 100644 index 0000000..5aa1450 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_operator_dashboard_filters.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from app.models import ExceptionCase + + +def test_exceptions_page_filters_by_rule_id(client, db_session): + case = db_session.query(ExceptionCase).first() + assert case is not None + response = client.get(f"/exceptions?rule_id={case.rule_id}") + assert response.status_code == 200, response.text + html = response.text + assert f"Exceptions: {case.rule_id}" in html + assert "Clear rule filter" in html + assert case.rule_id in html + + +def test_dashboard_rule_chips_link_to_filtered_exception_queue(client, db_session): + case = db_session.query(ExceptionCase).first() + assert case is not None + html = client.get("/").text + assert f"/exceptions?rule_id={case.rule_id}" in html + + +def test_dashboard_renders_market_badges_for_t1_blockers(client): + html = client.get("/").text + assert "Grouped by market code only" in html + assert "No active T+1 market blockers" in html or "·" in html diff --git a/apps/ssi-control-tower/tests/test_public_source_evidence.py b/apps/ssi-control-tower/tests/test_public_source_evidence.py new file mode 100644 index 0000000..0e2bea5 --- /dev/null +++ b/apps/ssi-control-tower/tests/test_public_source_evidence.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DOC = ROOT / "docs" / "public_ssi_field_evidence.md" +SOURCES = ROOT / "docs" / "public_ssi_field_evidence_sources.json" +ALLOWED_KEYS = {"bank", "url", "title", "field_categories"} +IBAN_SHAPED = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b") +BIC_SHAPED = re.compile(r"\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b") +ROUTING_SHAPED = re.compile(r"(?m)^\s*\d{9}\s*$") +FORBIDDEN_FIELDS = {"text_sha256", "length", "redacted_evidence_lines", "evidence_lines", "content"} + + +def _payload() -> list[dict]: + return json.loads(SOURCES.read_text()) + + +def test_public_source_sidecar_has_at_least_ten_sanitized_sources(): + payload = _payload() + assert len(payload) >= 10 + for item in payload: + assert set(item) == ALLOWED_KEYS + assert item["url"].startswith("https://") + assert item["bank"] + assert item["title"] + assert item["field_categories"] + assert not (set(item) & FORBIDDEN_FIELDS) + + +def test_public_source_docs_and_json_do_not_copy_operational_tokens(): + combined = DOC.read_text() + "\n" + SOURCES.read_text() + assert not IBAN_SHAPED.search(combined) + assert not BIC_SHAPED.search(combined) + assert not ROUTING_SHAPED.search(combined) + lowered = combined.lower() + assert "redacted_evidence_lines" not in lowered + assert "text_sha256" not in lowered + + +def test_public_evidence_route_renders_sanitized_sources(client): + response = client.get("/public-evidence") + assert response.status_code == 200, response.text + html = response.text + assert "Public source evidence" in html + assert "verified sources" in html + assert "Bank of America" in html + assert "field categories" in html + assert "redacted_evidence_lines" not in html + assert "text_sha256" not in html diff --git a/apps/ssi-control-tower/tests/test_v2_web_dashboard.py b/apps/ssi-control-tower/tests/test_v2_web_dashboard.py index d2d5700..c17c3f7 100644 --- a/apps/ssi-control-tower/tests/test_v2_web_dashboard.py +++ b/apps/ssi-control-tower/tests/test_v2_web_dashboard.py @@ -23,3 +23,9 @@ def test_home_dashboard_exposes_v2_source_truth_slice(client): assert "Preferred conflict groups" in html assert "Overlapping active groups" in html assert "data/sample_swiftref_ssiplus_v3_synthetic.tsv" in html + assert "Operator command center" in html + assert "Today's risk" in html + assert "T+1 blockers" in html + assert "Pending approvals" in html + assert "Aging exceptions" in html + assert "Export readiness" in html