-
- Payment Intelligence Modules · v
- {SUITE_VERSION}
-
-
-
- Browser-only · No data leaves your device
-
+
+
+
+
+ Payment Intelligence Modules · v
+ {SUITE_VERSION}
+
+
+
+ Browser-only · No data leaves your device
+
+
);
diff --git a/src/components/layout/SuiteHeader.tsx b/src/components/layout/SuiteHeader.tsx
index 6d1edc5..939dcea 100644
--- a/src/components/layout/SuiteHeader.tsx
+++ b/src/components/layout/SuiteHeader.tsx
@@ -10,12 +10,14 @@ interface NavItem {
const navItems: NavItem[] = [
{ to: "/", label: "Overview", end: true },
+ { to: "/workbench", label: "Workbench", end: false },
{ to: "/scrubber", label: "Scrubber", end: false },
{ to: "/storyteller", label: "Storyteller", end: false },
{ to: "/iban", label: "IBAN", end: false },
{ to: "/bic", label: "BIC*", end: false },
{ to: "/cbpr", label: "CBPR+", end: false },
{ to: "/insights", label: "Insights", end: false },
+ { to: "/docs", label: "Docs", end: false },
];
export function SuiteHeader() {
diff --git a/src/components/workbench/CapabilityBadge.tsx b/src/components/workbench/CapabilityBadge.tsx
new file mode 100644
index 0000000..959c38d
--- /dev/null
+++ b/src/components/workbench/CapabilityBadge.tsx
@@ -0,0 +1,38 @@
+import { cn } from "@/lib/utils";
+import { CAPABILITY_LABEL, MATURITY_LABEL } from "@/lib/workbench/taxonomy";
+import type { CapabilityState, MaturityTier } from "@/lib/workbench/types";
+
+const CAPABILITY_CLASS: Record
= {
+ available: "border-brand/25 bg-brand/10 text-primary",
+ demo: "border-amber-300/60 bg-amber-100/70 text-amber-900",
+ local: "border-accent/30 bg-accent/10 text-accent-foreground",
+ prototype: "border-indigo-300/60 bg-indigo-100/70 text-indigo-900",
+ gated: "border-slate-300 bg-slate-100 text-slate-700",
+ planned: "border-border bg-muted text-muted-foreground",
+};
+
+interface CapabilityBadgeProps {
+ capability: CapabilityState;
+ maturity?: MaturityTier;
+ className?: string;
+}
+
+export function CapabilityBadge({ capability, maturity, className }: CapabilityBadgeProps) {
+ return (
+
+
+ {CAPABILITY_LABEL[capability]}
+
+ {maturity ? (
+
+ {MATURITY_LABEL[maturity].split(" · ")[0]}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/workbench/CaveatPanel.tsx b/src/components/workbench/CaveatPanel.tsx
new file mode 100644
index 0000000..787068d
--- /dev/null
+++ b/src/components/workbench/CaveatPanel.tsx
@@ -0,0 +1,36 @@
+import { AlertTriangle } from "lucide-react";
+
+interface CaveatPanelProps {
+ title?: string;
+ points: string[];
+}
+
+/**
+ * Shared "scope / what this does not do" panel. Used to keep anti-overclaim
+ * caveats visually consistent across modules and platform surfaces.
+ */
+export function CaveatPanel({ title = "Scope & limitations", points }: CaveatPanelProps) {
+ if (points.length === 0) return null;
+ return (
+
+
+
+ {points.map((point) => (
+ -
+
+ {point}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/workbench/FindingsList.tsx b/src/components/workbench/FindingsList.tsx
new file mode 100644
index 0000000..3e76588
--- /dev/null
+++ b/src/components/workbench/FindingsList.tsx
@@ -0,0 +1,36 @@
+import { cn } from "@/lib/utils";
+import { SEVERITY_LABEL } from "@/lib/workbench/taxonomy";
+import type { Finding, Severity } from "@/lib/workbench/types";
+
+const SEVERITY_CLASS: Record = {
+ pass: "border-emerald-300/60 bg-emerald-50 text-emerald-900",
+ info: "border-sky-300/60 bg-sky-50 text-sky-900",
+ warning: "border-amber-300/60 bg-amber-50 text-amber-900",
+ critical: "border-red-300/60 bg-red-50 text-red-900",
+};
+
+export function FindingsList({ findings }: { findings: Finding[] }) {
+ if (findings.length === 0) {
+ return No findings in this session yet.
;
+ }
+ return (
+
+ );
+}
diff --git a/src/components/workbench/GateChecklist.tsx b/src/components/workbench/GateChecklist.tsx
new file mode 100644
index 0000000..f1bc1f9
--- /dev/null
+++ b/src/components/workbench/GateChecklist.tsx
@@ -0,0 +1,31 @@
+import { Lock } from "lucide-react";
+
+interface GateChecklistProps {
+ requirements: string[];
+ /** When true (the default) every gate renders as locked/unmet. */
+ locked?: boolean;
+}
+
+/**
+ * Renders gate requirements for a disabled connector / gated surface. By
+ * design every item shows as unmet — these gates are not satisfied here.
+ */
+export function GateChecklist({ requirements, locked = true }: GateChecklistProps) {
+ return (
+
+ {requirements.map((requirement) => (
+ -
+
+ {requirement}
+
+ ))}
+
+ );
+}
diff --git a/src/components/workbench/MaturityLegend.tsx b/src/components/workbench/MaturityLegend.tsx
new file mode 100644
index 0000000..b753663
--- /dev/null
+++ b/src/components/workbench/MaturityLegend.tsx
@@ -0,0 +1,19 @@
+import { MATURITY_DESCRIPTION, MATURITY_LABEL } from "@/lib/workbench/taxonomy";
+import type { MaturityTier } from "@/lib/workbench/types";
+
+const TIERS: MaturityTier[] = ["P0", "P1", "P2", "P3"];
+
+export function MaturityLegend() {
+ return (
+
+ {TIERS.map((tier) => (
+
+
- {MATURITY_LABEL[tier]}
+ -
+ {MATURITY_DESCRIPTION[tier]}
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/workbench/ProvenanceCard.tsx b/src/components/workbench/ProvenanceCard.tsx
new file mode 100644
index 0000000..6042335
--- /dev/null
+++ b/src/components/workbench/ProvenanceCard.tsx
@@ -0,0 +1,68 @@
+import { CheckCircle2, XCircle } from "lucide-react";
+import type { Provenance } from "@/lib/workbench/types";
+
+export function ProvenanceCard({ provenance }: { provenance: Provenance }) {
+ return (
+
+
+ Provenance
+
+ Live: No
+
+
+
+
+
- Source:
+ - {provenance.source}
+
+
+
- Method:
+ - {provenance.method}
+
+ {provenance.freshness ? (
+
+
- Freshness:
+ - {provenance.freshness}
+
+ ) : null}
+
+
+
+
+ Checked
+
+
+ {provenance.checked.map((item) => (
+ -
+
+ {item}
+
+ ))}
+
+
+
+
+ Not checked
+
+
+ {provenance.notChecked.map((item) => (
+ -
+
+ {item}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/lib/workbench/analytics.test.ts b/src/lib/workbench/analytics.test.ts
new file mode 100644
index 0000000..1136352
--- /dev/null
+++ b/src/lib/workbench/analytics.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { computeAnalytics } from "./analytics";
+import type { ModuleResult, Provenance } from "./types";
+
+const provenance: Provenance = {
+ source: "synthetic",
+ method: "synthetic-fixture",
+ checked: [],
+ notChecked: [],
+ live: false,
+};
+
+const results: ModuleResult[] = [
+ {
+ moduleId: "storyteller",
+ documentId: "d1",
+ resultStatus: "pass",
+ findings: [{ id: "ok", severity: "pass", message: "ok" }],
+ provenance,
+ limitations: [],
+ },
+ {
+ moduleId: "insights",
+ documentId: "d2",
+ resultStatus: "warning",
+ findings: [
+ { id: "r", severity: "warning", field: "RtrRsnInf/Rsn/Cd", message: "w" },
+ { id: "i", severity: "info", message: "i" },
+ ],
+ provenance,
+ limitations: [],
+ },
+];
+
+describe("computeAnalytics", () => {
+ it("aggregates severities, pass-rate, modules and reason codes", () => {
+ const analytics = computeAnalytics(results, 2);
+ expect(analytics.totalResults).toBe(2);
+ expect(analytics.totalDocuments).toBe(2);
+ expect(analytics.totalFindings).toBe(3);
+ expect(analytics.bySeverity).toEqual({ pass: 1, info: 1, warning: 1, critical: 0 });
+ expect(analytics.passRate).toBe(0.5);
+ expect(analytics.byModule).toHaveLength(2);
+ expect(analytics.reasonCodes[0]?.count).toBeGreaterThan(0);
+ });
+
+ it("treats an empty session as fully passing", () => {
+ const analytics = computeAnalytics([]);
+ expect(analytics.passRate).toBe(1);
+ expect(analytics.totalResults).toBe(0);
+ });
+});
diff --git a/src/lib/workbench/analytics.ts b/src/lib/workbench/analytics.ts
new file mode 100644
index 0000000..e8f0097
--- /dev/null
+++ b/src/lib/workbench/analytics.ts
@@ -0,0 +1,87 @@
+import { SEVERITY_RANK } from "./taxonomy";
+import type { ModuleResult, Severity } from "./types";
+
+export interface SeverityTally {
+ pass: number;
+ info: number;
+ warning: number;
+ critical: number;
+}
+
+export interface ReasonCode {
+ code: string;
+ count: number;
+}
+
+export interface ModuleBreakdown {
+ moduleId: string;
+ results: number;
+ findings: number;
+}
+
+export interface SessionAnalytics {
+ totalDocuments: number;
+ totalResults: number;
+ totalFindings: number;
+ bySeverity: SeverityTally;
+ byModule: ModuleBreakdown[];
+ /** Fraction (0..1) of results whose status is pass or info. */
+ passRate: number;
+ reasonCodes: ReasonCode[];
+}
+
+/**
+ * Compute analytics over the analyses run in this session. Pure and
+ * synchronous — no storage, no telemetry, no network.
+ */
+export function computeAnalytics(
+ results: readonly ModuleResult[],
+ documentCount = 0,
+): SessionAnalytics {
+ const bySeverity: SeverityTally = { pass: 0, info: 0, warning: 0, critical: 0 };
+ const moduleMap = new Map();
+ const reasonMap = new Map();
+
+ let totalFindings = 0;
+ let passing = 0;
+
+ for (const result of results) {
+ if (SEVERITY_RANK[result.resultStatus] <= SEVERITY_RANK.info) {
+ passing += 1;
+ }
+
+ const moduleEntry = moduleMap.get(result.moduleId) ?? {
+ moduleId: result.moduleId,
+ results: 0,
+ findings: 0,
+ };
+ moduleEntry.results += 1;
+ moduleEntry.findings += result.findings.length;
+ moduleMap.set(result.moduleId, moduleEntry);
+
+ for (const finding of result.findings) {
+ totalFindings += 1;
+ bySeverity[finding.severity] += 1;
+ const code = finding.field ?? finding.id;
+ reasonMap.set(code, (reasonMap.get(code) ?? 0) + 1);
+ }
+ }
+
+ const reasonCodes: ReasonCode[] = [...reasonMap.entries()]
+ .map(([code, count]) => ({ code, count }))
+ .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
+
+ return {
+ totalDocuments: documentCount,
+ totalResults: results.length,
+ totalFindings,
+ bySeverity,
+ byModule: [...moduleMap.values()].sort((a, b) => b.results - a.results),
+ passRate: results.length === 0 ? 1 : passing / results.length,
+ reasonCodes,
+ };
+}
+
+export function severityList(): Severity[] {
+ return ["critical", "warning", "info", "pass"];
+}
diff --git a/src/lib/workbench/connectors.test.ts b/src/lib/workbench/connectors.test.ts
new file mode 100644
index 0000000..78dcf80
--- /dev/null
+++ b/src/lib/workbench/connectors.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import {
+ CONNECTORS,
+ ConnectorDisabledError,
+ gateMatrix,
+ getConnector,
+ invokeConnector,
+} from "./connectors";
+
+describe("connector registry (P3, disabled)", () => {
+ it("registers the seven tracks, all disabled", () => {
+ const matrix = gateMatrix();
+ expect(matrix.total).toBe(7);
+ expect(matrix.liveEnabled).toBe(0);
+ expect(matrix.disabled).toBe(7);
+ expect(new Set(matrix.tracks).size).toBe(7);
+ });
+
+ it("hard-disables live integration and enablement on every connector", () => {
+ for (const connector of CONNECTORS) {
+ expect(connector.liveIntegration).toBe(false);
+ expect(connector.enabled).toBe(false);
+ expect(connector.gateRequirements.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("cannot invoke any connector — the guard always throws", () => {
+ for (const connector of CONNECTORS) {
+ expect(() => invokeConnector(connector.id)).toThrow(ConnectorDisabledError);
+ }
+ });
+
+ it("resolves known connectors and returns null for unknown ids", () => {
+ expect(getConnector("vop")?.track).toBe("VOP");
+ expect(getConnector("does-not-exist")).toBeNull();
+ });
+});
diff --git a/src/lib/workbench/connectors.ts b/src/lib/workbench/connectors.ts
new file mode 100644
index 0000000..2ec5df9
--- /dev/null
+++ b/src/lib/workbench/connectors.ts
@@ -0,0 +1,216 @@
+// P3 live-data connector framework — front-end mirror of the disabled registry.
+//
+// This is the honest, gated representation of the enterprise/live-data tracks.
+// EVERY connector is disabled: `liveIntegration` and `enabled` are hard-typed
+// `false`. There is no code path that performs a real provider call. The only
+// "invoke" entry point, `invokeConnector`, ALWAYS throws — proving a disabled
+// connector cannot be called accidentally from the browser suite.
+//
+// The companion offline FastAPI scaffold (apps/payment-intelligence-pilot)
+// enforces the same invariant server-side with its own registry + tests.
+
+export type ConnectorTrack =
+ | "BIC_DIRECTORY"
+ | "VOP"
+ | "REACHABILITY"
+ | "MQ"
+ | "DIRECTORY_FILE_DROP"
+ | "PAYMENT_MONITOR"
+ | "CERTIFIED_CBPR";
+
+export interface ConnectorProfile {
+ id: string;
+ track: ConnectorTrack;
+ trackLabel: string;
+ displayName: string;
+ vendorExamples: string[];
+ description: string;
+ liveIntegration: false;
+ enabled: false;
+ gateRequirements: string[];
+ licensingNote: string;
+ complianceNote: string;
+ securityNote: string;
+ maturity: "P3";
+}
+
+export const CONNECTORS: readonly ConnectorProfile[] = [
+ {
+ id: "bic-directory",
+ track: "BIC_DIRECTORY",
+ trackLabel: "BIC directory",
+ displayName: "Live BIC directory lookup",
+ vendorExamples: ["SWIFTRef / BIC Directory", "SwiftRef SSI Plus"],
+ description:
+ "Resolve and enrich BICs against a licensed live directory instead of a bundled snapshot.",
+ liveIntegration: false,
+ enabled: false,
+ gateRequirements: [
+ "Signed SWIFTRef / directory data license",
+ "Compliance sign-off on redistribution",
+ "Security review of egress path",
+ ],
+ licensingNote: "Requires a commercial SWIFTRef license. Not bundled.",
+ complianceNote: "Directory redistribution terms must be cleared before enabling.",
+ securityNote: "Outbound lookup is a new data-egress surface; pen-test required.",
+ maturity: "P3",
+ },
+ {
+ id: "vop",
+ track: "VOP",
+ trackLabel: "Verification of Payee",
+ displayName: "Verification of Payee (name matching)",
+ vendorExamples: ["EPC VOP scheme", "Bank/aggregator VOP API"],
+ description: "Match payee name to account under the EPC VOP scheme.",
+ liveIntegration: false,
+ enabled: false,
+ gateRequirements: [
+ "VOP scheme adherence / certification",
+ "Data-protection assessment (name + account matching)",
+ "Security review",
+ ],
+ licensingNote: "VOP routing requires scheme participation.",
+ complianceNote: "Name-matching processes personal data — DPA and lawful basis required.",
+ securityNote: "Handles account + name pairs; encryption-in-transit and at-rest required.",
+ maturity: "P3",
+ },
+ {
+ id: "reachability",
+ track: "REACHABILITY",
+ trackLabel: "Scheme reachability",
+ displayName: "SEPA + FIN reachability",
+ vendorExamples: ["EPC routing tables", "SWIFT FIN reachability"],
+ description: "Determine whether a BIC/scheme combination is reachable for a given rail.",
+ liveIntegration: false,
+ enabled: false,
+ gateRequirements: [
+ "Licensed reachability data feed",
+ "Freshness / staleness SLA agreed",
+ "Compliance sign-off",
+ ],
+ licensingNote: "Reachability tables are licensed reference data.",
+ complianceNote: "Stale reachability data can mis-route — operational liability gate.",
+ securityNote: "Feed ingestion must be integrity-checked.",
+ maturity: "P3",
+ },
+ {
+ id: "mq",
+ track: "MQ",
+ trackLabel: "Message queue",
+ displayName: "IBM MQ connector",
+ vendorExamples: ["IBM MQ", "Bank middleware"],
+ description: "Ingest messages from an enterprise MQ instead of file paste/upload.",
+ liveIntegration: false,
+ enabled: false,
+ gateRequirements: [
+ "Credential vaulting designed and reviewed (no plaintext)",
+ "Network architecture review",
+ "Security sign-off / pen-test",
+ ],
+ licensingNote: "Enterprise middleware; deployment-specific.",
+ complianceNote: "Ingests live operational traffic — data-residency decision required.",
+ securityNote: "Credentials must be vaulted, never stored in plaintext or shown in UI.",
+ maturity: "P3",
+ },
+ {
+ id: "directory-file-drop",
+ track: "DIRECTORY_FILE_DROP",
+ trackLabel: "Directory / file drop",
+ displayName: "Directory file-drop connector",
+ vendorExamples: ["SFTP drop", "DTCC ALERT directory export"],
+ description: "Watch a directory / SFTP drop for batch files to ingest.",
+ liveIntegration: false,
+ enabled: false,
+ gateRequirements: [
+ "Security review of file-system / SFTP access",
+ "Scrub-before-store policy",
+ "No dev paths or env config leaked in UI",
+ ],
+ licensingNote: "Source-system dependent.",
+ complianceNote: "Batch files may carry raw PII — scrub-before-store required.",
+ securityNote: "Filesystem credentials and paths must never surface in the UI.",
+ maturity: "P3",
+ },
+ {
+ id: "payment-monitor",
+ track: "PAYMENT_MONITOR",
+ trackLabel: "Payment monitoring",
+ displayName: "Live payment / settlement monitor",
+ vendorExamples: ["Scheme tracker feeds", "Settlement monitoring"],
+ description: "Stream live payment/settlement status instead of grouping user-supplied files.",
+ liveIntegration: false,
+ enabled: false,
+ gateRequirements: [
+ "Certified status feed + scheme compliance",
+ "Operational liability / indemnity agreement",
+ "Circuit-breakers + honest degrade-to-non-live",
+ ],
+ licensingNote: "Certified settlement feed required.",
+ complianceNote: "Highest liability surface — ops + scheme compliance gate.",
+ securityNote: "Live feed is continuous egress; requires monitoring + failover.",
+ maturity: "P3",
+ },
+ {
+ id: "certified-cbpr",
+ track: "CERTIFIED_CBPR",
+ trackLabel: "Certified CBPR+ validation",
+ displayName: "Certified CBPR+ / MyStandards validation",
+ vendorExamples: ["SWIFT MyStandards", "Certified CBPR+ validator"],
+ description: "Replace local shape checks with a certified CBPR+ / MyStandards validator.",
+ liveIntegration: false,
+ enabled: false,
+ gateRequirements: [
+ "Actual certification obtained",
+ "Only then may the 'not certified' caveat be dropped",
+ "License for certified rule content",
+ ],
+ licensingNote: "Certified rule content is licensed.",
+ complianceNote: "'Certified' wording is forbidden until certification exists.",
+ securityNote: "Validator integration must not exfiltrate message content.",
+ maturity: "P3",
+ },
+];
+
+export class ConnectorDisabledError extends Error {
+ readonly connectorId: string;
+
+ constructor(connectorId: string) {
+ super(
+ `Connector "${connectorId}" is disabled (live_integration=false, enabled=false). ` +
+ "It cannot be invoked until licensing, compliance and security gates are signed off.",
+ );
+ this.name = "ConnectorDisabledError";
+ this.connectorId = connectorId;
+ }
+}
+
+export function getConnector(id: string): ConnectorProfile | null {
+ return CONNECTORS.find((connector) => connector.id === id) ?? null;
+}
+
+/**
+ * The only "call" entry point — and it always throws. There is deliberately no
+ * branch that performs a real provider request.
+ */
+export function invokeConnector(id: string): never {
+ throw new ConnectorDisabledError(id);
+}
+
+export interface ConnectorGateMatrix {
+ total: number;
+ liveEnabled: number;
+ disabled: number;
+ tracks: ConnectorTrack[];
+}
+
+export function gateMatrix(): ConnectorGateMatrix {
+ const liveEnabled = CONNECTORS.filter(
+ (connector) => connector.liveIntegration || connector.enabled,
+ ).length;
+ return {
+ total: CONNECTORS.length,
+ liveEnabled,
+ disabled: CONNECTORS.length - liveEnabled,
+ tracks: CONNECTORS.map((connector) => connector.track),
+ };
+}
diff --git a/src/lib/workbench/index.ts b/src/lib/workbench/index.ts
new file mode 100644
index 0000000..49b5c05
--- /dev/null
+++ b/src/lib/workbench/index.ts
@@ -0,0 +1,8 @@
+export * from "./types";
+export * from "./taxonomy";
+export * from "./surfaces";
+export * from "./scenarios";
+export * from "./analytics";
+export * from "./review";
+export * from "./vault";
+export * from "./connectors";
diff --git a/src/lib/workbench/review.test.ts b/src/lib/workbench/review.test.ts
new file mode 100644
index 0000000..73e011d
--- /dev/null
+++ b/src/lib/workbench/review.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { countByStatus, deriveReviewItems, setReviewStatus } from "./review";
+import type { ModuleResult, Provenance } from "./types";
+
+const provenance: Provenance = {
+ source: "synthetic",
+ method: "synthetic-fixture",
+ checked: [],
+ notChecked: [],
+ live: false,
+};
+
+const results: ModuleResult[] = [
+ {
+ moduleId: "m",
+ documentId: "d",
+ resultStatus: "warning",
+ findings: [
+ { id: "w", severity: "warning", message: "w" },
+ { id: "c", severity: "critical", message: "c" },
+ { id: "p", severity: "pass", message: "p" },
+ ],
+ provenance,
+ limitations: [],
+ },
+];
+
+describe("review queue", () => {
+ it("derives only warning/critical items, criticals first", () => {
+ const items = deriveReviewItems(results);
+ expect(items).toHaveLength(2);
+ expect(items[0]?.severity).toBe("critical");
+ expect(items.every((item) => item.status === "open")).toBe(true);
+ });
+
+ it("updates status immutably and counts by status", () => {
+ const items = deriveReviewItems(results);
+ const first = items[0];
+ if (!first) throw new Error("expected at least one review item");
+ const next = setReviewStatus(items, first.id, "resolved");
+ expect(items[0]?.status).toBe("open");
+ expect(countByStatus(next).resolved).toBe(1);
+ expect(countByStatus(next).open).toBe(1);
+ });
+});
diff --git a/src/lib/workbench/review.ts b/src/lib/workbench/review.ts
new file mode 100644
index 0000000..8f7a1cd
--- /dev/null
+++ b/src/lib/workbench/review.ts
@@ -0,0 +1,55 @@
+import { SEVERITY_RANK } from "./taxonomy";
+import type { ModuleResult, Severity } from "./types";
+
+export type ReviewStatus = "open" | "acknowledged" | "resolved";
+
+export interface ReviewItem {
+ id: string;
+ moduleId: string;
+ documentId: string;
+ severity: Severity;
+ message: string;
+ field?: string | undefined;
+ status: ReviewStatus;
+}
+
+const NEEDS_REVIEW: ReadonlySet = new Set(["warning", "critical"]);
+
+/**
+ * Derive a session-scoped review queue from the warning/critical findings of
+ * this session's results. Pure: returns a fresh array, mutates nothing.
+ */
+export function deriveReviewItems(results: readonly ModuleResult[]): ReviewItem[] {
+ const items: ReviewItem[] = [];
+ for (const result of results) {
+ for (const finding of result.findings) {
+ if (!NEEDS_REVIEW.has(finding.severity)) continue;
+ items.push({
+ id: `${result.documentId}:${finding.id}`,
+ moduleId: result.moduleId,
+ documentId: result.documentId,
+ severity: finding.severity,
+ message: finding.message,
+ field: finding.field,
+ status: "open",
+ });
+ }
+ }
+ return items.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
+}
+
+export function setReviewStatus(
+ items: readonly ReviewItem[],
+ id: string,
+ status: ReviewStatus,
+): ReviewItem[] {
+ return items.map((item) => (item.id === id ? { ...item, status } : item));
+}
+
+export function countByStatus(items: readonly ReviewItem[]): Record {
+ const counts: Record = { open: 0, acknowledged: 0, resolved: 0 };
+ for (const item of items) {
+ counts[item.status] += 1;
+ }
+ return counts;
+}
diff --git a/src/lib/workbench/scenarios.test.ts b/src/lib/workbench/scenarios.test.ts
new file mode 100644
index 0000000..556f748
--- /dev/null
+++ b/src/lib/workbench/scenarios.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { SCENARIOS, getScenario, resultForScenario, scenariosForModule } from "./scenarios";
+
+describe("scenarios", () => {
+ it("ships clearly-synthetic bundled scenarios", () => {
+ expect(SCENARIOS.length).toBeGreaterThan(0);
+ for (const scenario of SCENARIOS) {
+ expect(scenario.previewXml).toContain("Document");
+ expect(scenario.previewXml).toMatch(/SYNTH|TEST/);
+ }
+ });
+
+ it("builds a non-live synthetic result for a scenario", () => {
+ const first = SCENARIOS[0];
+ if (!first) throw new Error("expected at least one scenario");
+ const result = resultForScenario(first, "doc-1");
+ expect(result.documentId).toBe("doc-1");
+ expect(result.provenance.live).toBe(false);
+ expect(result.provenance.method).toBe("synthetic-fixture");
+ expect(result.findings.length).toBeGreaterThan(0);
+ expect(result.limitations.join(" ")).toMatch(/synthetic/i);
+ });
+
+ it("looks scenarios up by id and by module", () => {
+ const first = SCENARIOS[0];
+ if (!first) throw new Error("expected at least one scenario");
+ expect(getScenario(first.id)?.id).toBe(first.id);
+ expect(getScenario("nope")).toBeUndefined();
+ expect(scenariosForModule("storyteller").length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/lib/workbench/scenarios.ts b/src/lib/workbench/scenarios.ts
new file mode 100644
index 0000000..86718b1
--- /dev/null
+++ b/src/lib/workbench/scenarios.ts
@@ -0,0 +1,204 @@
+// Bundled, deliberately-synthetic example scenarios for the scenario switcher.
+//
+// Every snippet below is fabricated for demonstration: BICs follow the
+// `TEST..` pattern and account numbers are pre-masked. Nothing here is real
+// payment data, and the switcher performs no network calls — the content is
+// shipped in-code so the simulator works fully offline.
+
+import type { Finding, ModuleResult, Severity } from "./types";
+
+export type ScenarioArchetype = "clean" | "return" | "status" | "non-latin" | "ack-nack";
+
+export interface Scenario {
+ id: string;
+ label: string;
+ description: string;
+ /** Suite module this scenario is most relevant to. */
+ moduleId: string;
+ family: string;
+ archetype: ScenarioArchetype;
+ /** Tiny synthetic ISO 20022 fragment — safe to display and hand off. */
+ previewXml: string;
+}
+
+const PACS008_CLEAN = `
+
+
+ SYNTH-MSG-00012026-01-02T09:15:001
+
+ SYNTH-E2E-000100000000-0000-4000-8000-000000000001
+ 1000.00
+ Synthetic Debtor AG
+ TESTDEFFXXX
+ TESTFRPPXXX
+ Synthetic Creditor SA
+ FR7630006000010000000000***
+
+
+`;
+
+const PACS002_STATUS = `
+
+
+ SYNTH-STS-00012026-01-02T09:16:00
+
+ SYNTH-E2E-0001
+ 00000000-0000-4000-8000-000000000001
+ ACSP
+
+
+`;
+
+const PACS004_RETURN = `
+
+
+ SYNTH-RTN-00012026-01-03T11:00:001
+
+ SYNTH-E2E-0001
+ 00000000-0000-4000-8000-000000000001
+ 1000.00
+ AC04
+
+
+`;
+
+const CAMT053_STATEMENT = `
+
+
+ SYNTH-STMT-00012026-01-04T06:00:00
+
+ SYNTH-STMT-0001
+ DE89370400440000000000***
+ 1000.00CRDTBOOK
+
+
+`;
+
+export const SCENARIOS: Scenario[] = [
+ {
+ id: "clean-credit-transfer",
+ label: "Clean credit transfer (pacs.008)",
+ description:
+ "A well-formed single credit transfer. Good baseline for Storyteller and CBPR+ shape checks.",
+ moduleId: "storyteller",
+ family: "pacs.008",
+ archetype: "clean",
+ previewXml: PACS008_CLEAN,
+ },
+ {
+ id: "status-report",
+ label: "Status report (pacs.002 · ACSP)",
+ description:
+ "An accepted-settlement status that correlates to the clean transfer by UETR and end-to-end id.",
+ moduleId: "insights",
+ family: "pacs.002",
+ archetype: "status",
+ previewXml: PACS002_STATUS,
+ },
+ {
+ id: "payment-return",
+ label: "Payment return (pacs.004 · AC04)",
+ description:
+ "A return for the same UETR with reason AC04 — useful for lifecycle threading in Insights.",
+ moduleId: "insights",
+ family: "pacs.004",
+ archetype: "return",
+ previewXml: PACS004_RETURN,
+ },
+ {
+ id: "bank-statement",
+ label: "Bank statement entry (camt.053)",
+ description: "A single booked entry. Exercises Storyteller's statement narrative path.",
+ moduleId: "storyteller",
+ family: "camt.053",
+ archetype: "clean",
+ previewXml: CAMT053_STATEMENT,
+ },
+];
+
+export function getScenario(id: string): Scenario | undefined {
+ return SCENARIOS.find((scenario) => scenario.id === id);
+}
+
+export function scenariosForModule(moduleId: string): Scenario[] {
+ return SCENARIOS.filter((scenario) => scenario.moduleId === moduleId);
+}
+
+/**
+ * Build a synthetic in-memory ModuleResult for a scenario so the workbench,
+ * analytics and review surfaces have honest session data to display. The
+ * provenance is explicitly synthetic and non-live.
+ */
+export function resultForScenario(scenario: Scenario, documentId: string): ModuleResult {
+ const findings: Finding[] = [];
+ let status: Severity = "pass";
+
+ switch (scenario.archetype) {
+ case "clean":
+ findings.push({
+ id: "shape-ok",
+ severity: "pass",
+ message: "Well-formed message; AppHdr/Document shape recognised.",
+ moduleId: scenario.moduleId,
+ });
+ status = "pass";
+ break;
+ case "status":
+ findings.push({
+ id: "status-code",
+ severity: "info",
+ field: "TxSts",
+ message: "Status report present (e.g. ACSP) — correlate by UETR / end-to-end id.",
+ moduleId: scenario.moduleId,
+ });
+ status = "info";
+ break;
+ case "return":
+ findings.push({
+ id: "return-reason",
+ severity: "warning",
+ field: "RtrRsnInf/Rsn/Cd",
+ message: "Return present with a reason code (e.g. AC04) — needs lifecycle review.",
+ moduleId: scenario.moduleId,
+ });
+ status = "warning";
+ break;
+ case "non-latin":
+ findings.push({
+ id: "charset",
+ severity: "warning",
+ message: "Non-Latin characters present — check downstream character-set handling.",
+ moduleId: scenario.moduleId,
+ });
+ status = "warning";
+ break;
+ case "ack-nack":
+ findings.push({
+ id: "ack-nack",
+ severity: "info",
+ message: "ACK/NACK fragment — group with related messages in Insights.",
+ moduleId: scenario.moduleId,
+ });
+ status = "info";
+ break;
+ }
+
+ return {
+ moduleId: scenario.moduleId,
+ documentId,
+ messageFamily: scenario.family,
+ resultStatus: status,
+ findings,
+ provenance: {
+ source: "Bundled synthetic scenario fixture",
+ method: "synthetic-fixture",
+ checked: ["XML well-formedness (shape)", "Message family detection"],
+ notChecked: ["Live BIC directory", "VOP / name match", "Reachability", "Settlement status"],
+ live: false,
+ },
+ limitations: [
+ "Synthetic fixture — not a real message",
+ "Shape checks only, not certified validation",
+ ],
+ };
+}
diff --git a/src/lib/workbench/session/SessionContext.ts b/src/lib/workbench/session/SessionContext.ts
new file mode 100644
index 0000000..50416ac
--- /dev/null
+++ b/src/lib/workbench/session/SessionContext.ts
@@ -0,0 +1,35 @@
+import { createContext } from "react";
+import type { ModuleResult } from "../types";
+
+export interface SessionDocument {
+ id: string;
+ label: string;
+ moduleId?: string | undefined;
+ family?: string | undefined;
+ content: string;
+ loadedAt: string;
+}
+
+export interface HandoffPayload {
+ targetModuleId: string;
+ label: string;
+ content: string;
+}
+
+export interface SessionContextValue {
+ documents: SessionDocument[];
+ results: ModuleResult[];
+ handoff: HandoffPayload | null;
+ addDocument(doc: Omit): string;
+ removeDocument(id: string): void;
+ addResult(result: ModuleResult): void;
+ stageHandoff(payload: HandoffPayload): void;
+ clearHandoff(): void;
+ clearSession(): void;
+}
+
+/**
+ * In-memory only. The provider holds React state; there is no persistence,
+ * so everything here is cleared on refresh. Never wire this to storage.
+ */
+export const SessionContext = createContext(null);
diff --git a/src/lib/workbench/session/SessionProvider.tsx b/src/lib/workbench/session/SessionProvider.tsx
new file mode 100644
index 0000000..aded0d2
--- /dev/null
+++ b/src/lib/workbench/session/SessionProvider.tsx
@@ -0,0 +1,77 @@
+import { useCallback, useMemo, useState, type ReactNode } from "react";
+import type { ModuleResult } from "../types";
+import {
+ SessionContext,
+ type HandoffPayload,
+ type SessionContextValue,
+ type SessionDocument,
+} from "./SessionContext";
+
+function newId(): string {
+ const candidate = globalThis.crypto as Crypto | undefined;
+ if (candidate && "randomUUID" in candidate) {
+ return candidate.randomUUID();
+ }
+ return `doc-${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
+}
+
+export function SessionProvider({ children }: { children: ReactNode }) {
+ const [documents, setDocuments] = useState([]);
+ const [results, setResults] = useState([]);
+ const [handoff, setHandoff] = useState(null);
+
+ const addDocument = useCallback((doc: Omit): string => {
+ const id = newId();
+ setDocuments((prev) => [{ ...doc, id, loadedAt: new Date().toISOString() }, ...prev]);
+ return id;
+ }, []);
+
+ const removeDocument = useCallback((id: string) => {
+ setDocuments((prev) => prev.filter((doc) => doc.id !== id));
+ }, []);
+
+ const addResult = useCallback((result: ModuleResult) => {
+ setResults((prev) => [result, ...prev]);
+ }, []);
+
+ const stageHandoff = useCallback((payload: HandoffPayload) => {
+ setHandoff(payload);
+ }, []);
+
+ const clearHandoff = useCallback(() => {
+ setHandoff(null);
+ }, []);
+
+ const clearSession = useCallback(() => {
+ setDocuments([]);
+ setResults([]);
+ setHandoff(null);
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ documents,
+ results,
+ handoff,
+ addDocument,
+ removeDocument,
+ addResult,
+ stageHandoff,
+ clearHandoff,
+ clearSession,
+ }),
+ [
+ documents,
+ results,
+ handoff,
+ addDocument,
+ removeDocument,
+ addResult,
+ stageHandoff,
+ clearHandoff,
+ clearSession,
+ ],
+ );
+
+ return {children};
+}
diff --git a/src/lib/workbench/session/index.ts b/src/lib/workbench/session/index.ts
new file mode 100644
index 0000000..022a2f6
--- /dev/null
+++ b/src/lib/workbench/session/index.ts
@@ -0,0 +1,4 @@
+export { SessionProvider } from "./SessionProvider";
+export { useSession, useOptionalSession } from "./useSession";
+export { SessionContext } from "./SessionContext";
+export type { SessionContextValue, SessionDocument, HandoffPayload } from "./SessionContext";
diff --git a/src/lib/workbench/session/useSession.ts b/src/lib/workbench/session/useSession.ts
new file mode 100644
index 0000000..69d9a7d
--- /dev/null
+++ b/src/lib/workbench/session/useSession.ts
@@ -0,0 +1,19 @@
+import { useContext } from "react";
+import { SessionContext, type SessionContextValue } from "./SessionContext";
+
+export function useSession(): SessionContextValue {
+ const ctx = useContext(SessionContext);
+ if (!ctx) {
+ throw new Error("useSession must be used within a SessionProvider.");
+ }
+ return ctx;
+}
+
+/**
+ * Non-throwing variant: returns null when there is no provider. Useful for
+ * optional integrations (e.g. handoff consumption) inside module pages that
+ * are also rendered standalone in unit tests without a SessionProvider.
+ */
+export function useOptionalSession(): SessionContextValue | null {
+ return useContext(SessionContext);
+}
diff --git a/src/lib/workbench/surfaces.ts b/src/lib/workbench/surfaces.ts
new file mode 100644
index 0000000..fc0c414
--- /dev/null
+++ b/src/lib/workbench/surfaces.ts
@@ -0,0 +1,240 @@
+import {
+ Activity,
+ BarChart3,
+ BookOpen,
+ Building2,
+ Eraser,
+ FileCheck2,
+ FileText,
+ FlaskConical,
+ GitBranch,
+ Hash,
+ LayoutDashboard,
+ ListChecks,
+ Lock,
+ PlugZap,
+ ServerCog,
+ type LucideIcon,
+} from "lucide-react";
+import type { CapabilityState, MaturityTier } from "./types";
+
+export type SurfaceGroup = "module" | "workbench" | "platform";
+
+export interface SuiteSurface {
+ id: string;
+ route: string;
+ name: string;
+ /** Short label for nav / footer chips. */
+ short: string;
+ summary: string;
+ icon: LucideIcon;
+ group: SurfaceGroup;
+ maturity: MaturityTier;
+ capability: CapabilityState;
+ caveats?: string[];
+}
+
+/**
+ * Single source of truth for the suite's discovery surface.
+ *
+ * The SSI Control Tower (`/ssi`) is intentionally NOT registered here: it stays
+ * an unlinked boundary pointer, never promoted in nav or on the home launcher.
+ */
+export const SURFACES: SuiteSurface[] = [
+ {
+ id: "scrubber",
+ route: "/scrubber",
+ name: "Scrubber",
+ short: "Scrubber",
+ summary: "Strip personally identifying fields from ISO 20022 payment XML before sharing.",
+ icon: Eraser,
+ group: "module",
+ maturity: "P0",
+ capability: "available",
+ },
+ {
+ id: "storyteller",
+ route: "/storyteller",
+ name: "Storyteller",
+ short: "Storyteller",
+ summary:
+ "Turn pacs.* and camt.* messages into a plain-language narrative and field projection.",
+ icon: FileText,
+ group: "module",
+ maturity: "P0",
+ capability: "available",
+ },
+ {
+ id: "iban",
+ route: "/iban",
+ name: "IBAN Workbench",
+ short: "IBAN",
+ summary:
+ "Validate, build, catalogue and trace IBAN provenance from the bundled registry snapshot.",
+ icon: Hash,
+ group: "module",
+ maturity: "P0",
+ capability: "available",
+ caveats: ["No live BIC lookup", "No VOP / account-name check", "No reachability check"],
+ },
+ {
+ id: "bic",
+ route: "/bic",
+ name: "BIC Validator",
+ short: "BIC*",
+ summary: "ISO 9362 structural checks plus a small bundled snapshot. Demonstration data only.",
+ icon: Building2,
+ group: "module",
+ maturity: "P0",
+ capability: "demo",
+ caveats: ["Snapshot is not accurate/current", "Not a live directory lookup"],
+ },
+ {
+ id: "cbpr",
+ route: "/cbpr",
+ name: "CBPR+ Readiness Checker",
+ short: "CBPR+",
+ summary:
+ "Inspect AppHdr, Document namespace and bundled CBPR+ schema-profile coverage locally.",
+ icon: FileCheck2,
+ group: "module",
+ maturity: "P0",
+ capability: "available",
+ caveats: ["Shape checks, not certified validation", "Not a MyStandards substitute"],
+ },
+ {
+ id: "insights",
+ route: "/insights",
+ name: "Payment Insights Lite",
+ short: "Insights",
+ summary: "Group ACK/NACK, pacs.* and camt.* files you provide into local lifecycle threads.",
+ icon: GitBranch,
+ group: "module",
+ maturity: "P0",
+ capability: "available",
+ caveats: ["Not live payment tracking", "Not settlement monitoring"],
+ },
+ {
+ id: "workbench",
+ route: "/workbench",
+ name: "Workbench Command Center",
+ short: "Workbench",
+ summary:
+ "An in-memory command center over this browser session: loaded documents, findings, suggested next actions and cross-module handoff.",
+ icon: LayoutDashboard,
+ group: "workbench",
+ maturity: "P1",
+ capability: "local",
+ caveats: ["Session-only — cleared on refresh", "Nothing is stored or uploaded"],
+ },
+ {
+ id: "scenarios",
+ route: "/scenarios",
+ name: "Scenario Switcher",
+ short: "Scenarios",
+ summary:
+ "Load bundled, clearly-synthetic ISO 20022 example scenarios into the session and hand them to a module.",
+ icon: FlaskConical,
+ group: "workbench",
+ maturity: "P0",
+ capability: "available",
+ caveats: ["Synthetic fixtures only", "Fake BICs, masked accounts"],
+ },
+ {
+ id: "review",
+ route: "/review",
+ name: "Review Queue",
+ short: "Review",
+ summary: "Triage low-confidence and failed findings from this session as a local review queue.",
+ icon: ListChecks,
+ group: "workbench",
+ maturity: "P1",
+ capability: "local",
+ caveats: ["Session-only triage", "No tickets, no backend case management"],
+ },
+ {
+ id: "analytics",
+ route: "/analytics",
+ name: "Analytics",
+ short: "Analytics",
+ summary: "Counts, pass-rates and reason-code trends across the analyses run in this session.",
+ icon: BarChart3,
+ group: "workbench",
+ maturity: "P1",
+ capability: "local",
+ caveats: ["Current-session / synthetic data only", "No telemetry, no remote analytics"],
+ },
+ {
+ id: "vault",
+ route: "/vault",
+ name: "Local Vault",
+ short: "Vault",
+ summary:
+ "User-triggered, passphrase-encrypted export/import of session data via WebCrypto and a downloaded file.",
+ icon: Lock,
+ group: "workbench",
+ maturity: "P1",
+ capability: "local",
+ caveats: ["No cloud vault", "No browser storage — file download/import only"],
+ },
+ {
+ id: "health",
+ route: "/health",
+ name: "Health & Scope",
+ short: "Health",
+ summary:
+ "Static build/version/scope and privacy-posture surface, plus an honest map of what is and isn't live.",
+ icon: Activity,
+ group: "platform",
+ maturity: "P0",
+ capability: "available",
+ },
+ {
+ id: "docs",
+ route: "/docs",
+ name: "Docs, Methodology & Support",
+ short: "Docs",
+ summary: "Module guides, methodology, the privacy model, limitations and the maturity roadmap.",
+ icon: BookOpen,
+ group: "platform",
+ maturity: "P0",
+ capability: "available",
+ },
+ {
+ id: "connectors",
+ route: "/connectors",
+ name: "Connector Readiness",
+ short: "Connectors",
+ summary:
+ "P3 live-data connector framework (BIC / VOP / reachability / MQ / directory / payment-monitor / certified CBPR+) — every track disabled behind explicit gates.",
+ icon: PlugZap,
+ group: "platform",
+ maturity: "P3",
+ capability: "gated",
+ caveats: ["live_integration = false for every connector", "No real provider calls"],
+ },
+ {
+ id: "pilot",
+ route: "/pilot",
+ name: "Private-Pilot Scaffold",
+ short: "Pilot",
+ summary:
+ "P2 hosted-pilot architecture: an offline FastAPI scaffold with stub auth, immutable audit and four-eyes approval — a prototype, never production.",
+ icon: ServerCog,
+ group: "platform",
+ maturity: "P2",
+ capability: "prototype",
+ caveats: [
+ "Separate offline app, not wired to this static suite",
+ "Stub auth — not production auth",
+ ],
+ },
+];
+
+export function surfacesByGroup(group: SurfaceGroup): SuiteSurface[] {
+ return SURFACES.filter((surface) => surface.group === group);
+}
+
+export function getSurface(id: string): SuiteSurface | undefined {
+ return SURFACES.find((surface) => surface.id === id);
+}
diff --git a/src/lib/workbench/taxonomy.test.ts b/src/lib/workbench/taxonomy.test.ts
new file mode 100644
index 0000000..e54df82
--- /dev/null
+++ b/src/lib/workbench/taxonomy.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import {
+ CAPABILITY_LABEL,
+ MATURITY_LABEL,
+ SEVERITY_LABEL,
+ highestSeverity,
+ tallyFindings,
+} from "./taxonomy";
+import type { Finding } from "./types";
+
+const findings: Finding[] = [
+ { id: "a", severity: "info", message: "i" },
+ { id: "b", severity: "critical", message: "c" },
+ { id: "c", severity: "warning", message: "w" },
+];
+
+describe("taxonomy", () => {
+ it("highestSeverity picks the worst severity", () => {
+ expect(highestSeverity(findings)).toBe("critical");
+ expect(highestSeverity([])).toBe("pass");
+ });
+
+ it("tallyFindings counts by severity", () => {
+ expect(tallyFindings(findings)).toEqual({ pass: 0, info: 1, warning: 1, critical: 1 });
+ });
+
+ it("exposes honest labels", () => {
+ expect(SEVERITY_LABEL.critical).toBe("Critical");
+ expect(CAPABILITY_LABEL.gated).toMatch(/disabled/i);
+ expect(MATURITY_LABEL.P3).toMatch(/gated/i);
+ });
+});
diff --git a/src/lib/workbench/taxonomy.ts b/src/lib/workbench/taxonomy.ts
new file mode 100644
index 0000000..c437bdc
--- /dev/null
+++ b/src/lib/workbench/taxonomy.ts
@@ -0,0 +1,65 @@
+import type { CapabilityState, Finding, MaturityTier, Severity } from "./types";
+
+export const SEVERITY_RANK: Record = {
+ pass: 0,
+ info: 1,
+ warning: 2,
+ critical: 3,
+};
+
+export const SEVERITY_LABEL: Record = {
+ pass: "Pass",
+ info: "Info",
+ warning: "Needs review",
+ critical: "Critical",
+};
+
+export const CAPABILITY_LABEL: Record = {
+ available: "Available",
+ demo: "Demo",
+ local: "Local prototype",
+ prototype: "Pilot scaffold",
+ gated: "Gated · disabled",
+ planned: "Planned",
+};
+
+export const CAPABILITY_DESCRIPTION: Record = {
+ available: "Runs now, fully in your browser.",
+ demo: "Runs now with intentionally limited or snapshot data.",
+ local: "In-memory operational UX for this browser session only.",
+ prototype: "Offline, synthetic private-pilot scaffold — not production, not hosted here.",
+ gated: "Designed but switched off behind explicit licensing, compliance and security gates.",
+ planned: "Designed, not yet implemented.",
+};
+
+export const MATURITY_LABEL: Record = {
+ P0: "P0 · Public static",
+ P1: "P1 · Local operational",
+ P2: "P2 · Private-pilot scaffold",
+ P3: "P3 · Enterprise / live (gated)",
+};
+
+export const MATURITY_DESCRIPTION: Record = {
+ P0: "Browser-only suite. No backend, no storage, no telemetry.",
+ P1: "In-memory session UX layered on the static suite. Still no persistence by default.",
+ P2: "Separate, offline FastAPI scaffold with stub auth — a prototype, never production.",
+ P3: "Disabled live-data connector framework. Every integration is off until gates are signed.",
+};
+
+export function highestSeverity(findings: readonly Finding[]): Severity {
+ let worst: Severity = "pass";
+ for (const finding of findings) {
+ if (SEVERITY_RANK[finding.severity] > SEVERITY_RANK[worst]) {
+ worst = finding.severity;
+ }
+ }
+ return worst;
+}
+
+export function tallyFindings(findings: readonly Finding[]): Record {
+ const tally: Record = { pass: 0, info: 0, warning: 0, critical: 0 };
+ for (const finding of findings) {
+ tally[finding.severity] += 1;
+ }
+ return tally;
+}
diff --git a/src/lib/workbench/types.ts b/src/lib/workbench/types.ts
new file mode 100644
index 0000000..7858a04
--- /dev/null
+++ b/src/lib/workbench/types.ts
@@ -0,0 +1,51 @@
+// Shared "payment intelligence workbench" taxonomy.
+//
+// These types give every module/surface a common vocabulary for findings,
+// provenance, and capability maturity. They are pure data shapes with no
+// persistence and no network — they describe in-memory analysis only.
+
+export type MaturityTier = "P0" | "P1" | "P2" | "P3";
+
+export type CapabilityState =
+ | "available" // works now, fully browser-only
+ | "demo" // works now, intentionally limited / snapshot data
+ | "local" // P1 local/in-memory operational UX
+ | "prototype" // P2 hosted private-pilot scaffold (offline, synthetic)
+ | "gated" // P3 enterprise/live, implemented as a disabled gated surface
+ | "planned"; // designed, not yet implemented
+
+export type Severity = "pass" | "info" | "warning" | "critical";
+
+/**
+ * Where a result came from and — just as importantly — what it did NOT check.
+ * `live` is hard-typed `false`: the browser suite never reaches a live source.
+ */
+export interface Provenance {
+ source: string;
+ method: "syntax" | "registry-snapshot" | "heuristic" | "narrative" | "synthetic-fixture";
+ checked: string[];
+ notChecked: string[];
+ freshness?: string | undefined;
+ live: false;
+}
+
+export interface Finding {
+ id: string;
+ severity: Severity;
+ message: string;
+ field?: string | undefined;
+ moduleId?: string | undefined;
+}
+
+/**
+ * A common conceptual output shape for any module, held in memory only.
+ */
+export interface ModuleResult {
+ moduleId: string;
+ documentId: string;
+ messageFamily?: string | undefined;
+ resultStatus: Severity;
+ findings: Finding[];
+ provenance: Provenance;
+ limitations: string[];
+}
diff --git a/src/lib/workbench/vault.test.ts b/src/lib/workbench/vault.test.ts
new file mode 100644
index 0000000..4953b57
--- /dev/null
+++ b/src/lib/workbench/vault.test.ts
@@ -0,0 +1,36 @@
+import { webcrypto } from "node:crypto";
+import { describe, expect, it } from "vitest";
+import { decryptFromVault, encryptToVault, isVaultBundle, type VaultBundle } from "./vault";
+
+const cryptoImpl = webcrypto as unknown as Crypto;
+
+describe("vault (WebCrypto, user-triggered only)", () => {
+ it("round-trips an encrypted payload", async () => {
+ const payload = { documents: [{ id: "1", label: "x" }], note: "hi" };
+ const bundle = await encryptToVault(payload, "correct horse battery", { crypto: cryptoImpl });
+ expect(isVaultBundle(bundle)).toBe(true);
+ expect(bundle.cipher).toBe("AES-GCM");
+ expect(bundle.kdf).toBe("PBKDF2-SHA256");
+
+ const decoded = await decryptFromVault(bundle, "correct horse battery", { crypto: cryptoImpl });
+ expect(decoded).toEqual(payload);
+ });
+
+ it("fails to decrypt with the wrong passphrase", async () => {
+ const bundle = await encryptToVault({ a: 1 }, "right", { crypto: cryptoImpl });
+ await expect(decryptFromVault(bundle, "wrong", { crypto: cryptoImpl })).rejects.toThrow(
+ /Decryption failed/i,
+ );
+ });
+
+ it("requires a passphrase to encrypt", async () => {
+ await expect(encryptToVault({}, "", { crypto: cryptoImpl })).rejects.toThrow(/passphrase/i);
+ });
+
+ it("rejects objects that are not vault bundles", async () => {
+ expect(isVaultBundle({ format: "nope" })).toBe(false);
+ await expect(
+ decryptFromVault({ format: "nope" } as unknown as VaultBundle, "x", { crypto: cryptoImpl }),
+ ).rejects.toThrow(/vault bundle/i);
+ });
+});
diff --git a/src/lib/workbench/vault.ts b/src/lib/workbench/vault.ts
new file mode 100644
index 0000000..6ee74c4
--- /dev/null
+++ b/src/lib/workbench/vault.ts
@@ -0,0 +1,139 @@
+// Local Vault — passphrase-encrypted, user-triggered export/import.
+//
+// This module performs encryption only. It NEVER writes to localStorage,
+// sessionStorage, indexedDB or cookies, and never makes a network call. The
+// caller is responsible for turning a VaultBundle into a downloaded file and
+// for reading an imported file back — both of which are explicit user actions.
+//
+// `crypto` is injectable so the same code path can be unit-tested with Node's
+// WebCrypto while running on `globalThis.crypto` in the browser.
+
+export interface VaultBundle {
+ format: "pim-vault";
+ version: 1;
+ kdf: "PBKDF2-SHA256";
+ iterations: number;
+ cipher: "AES-GCM";
+ salt: string;
+ iv: string;
+ ciphertext: string;
+ createdAt: string;
+ note: string;
+}
+
+export interface VaultCryptoOptions {
+ crypto?: Crypto;
+}
+
+const DEFAULT_ITERATIONS = 150_000;
+const DEFAULT_NOTE =
+ "Encrypted locally with WebCrypto (AES-GCM + PBKDF2). No server and no browser storage — this bundle only exists as a file you chose to download.";
+
+function resolveCrypto(provided?: Crypto): Crypto {
+ const candidate = provided ?? (globalThis.crypto as Crypto | undefined);
+ if (!candidate || !candidate.subtle) {
+ throw new Error("WebCrypto SubtleCrypto API is unavailable in this environment.");
+ }
+ return candidate;
+}
+
+function bytesToBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+ return btoa(binary);
+}
+
+function base64ToBytes(value: string): Uint8Array {
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index);
+ }
+ return bytes;
+}
+
+async function deriveKey(
+ crypto: Crypto,
+ passphrase: string,
+ salt: Uint8Array,
+ iterations: number,
+): Promise {
+ const baseKey = await crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(passphrase),
+ "PBKDF2",
+ false,
+ ["deriveKey"],
+ );
+ return crypto.subtle.deriveKey(
+ { name: "PBKDF2", salt, iterations, hash: "SHA-256" },
+ baseKey,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["encrypt", "decrypt"],
+ );
+}
+
+export async function encryptToVault(
+ payload: unknown,
+ passphrase: string,
+ options: VaultCryptoOptions & { note?: string } = {},
+): Promise {
+ if (!passphrase) {
+ throw new Error("A passphrase is required to encrypt a vault bundle.");
+ }
+ const crypto = resolveCrypto(options.crypto);
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const key = await deriveKey(crypto, passphrase, salt, DEFAULT_ITERATIONS);
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
+ const ciphertext = new Uint8Array(
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext),
+ );
+
+ return {
+ format: "pim-vault",
+ version: 1,
+ kdf: "PBKDF2-SHA256",
+ iterations: DEFAULT_ITERATIONS,
+ cipher: "AES-GCM",
+ salt: bytesToBase64(salt),
+ iv: bytesToBase64(iv),
+ ciphertext: bytesToBase64(ciphertext),
+ createdAt: new Date().toISOString(),
+ note: options.note ?? DEFAULT_NOTE,
+ };
+}
+
+export async function decryptFromVault(
+ bundle: VaultBundle,
+ passphrase: string,
+ options: VaultCryptoOptions = {},
+): Promise {
+ if (!bundle || bundle.format !== "pim-vault") {
+ throw new Error("Not a Payment Intelligence vault bundle.");
+ }
+ const crypto = resolveCrypto(options.crypto);
+ const key = await deriveKey(crypto, passphrase, base64ToBytes(bundle.salt), bundle.iterations);
+ let plaintext: ArrayBuffer;
+ try {
+ plaintext = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv: base64ToBytes(bundle.iv) },
+ key,
+ base64ToBytes(bundle.ciphertext),
+ );
+ } catch {
+ throw new Error("Decryption failed — wrong passphrase or corrupted bundle.");
+ }
+ return JSON.parse(new TextDecoder().decode(plaintext)) as T;
+}
+
+export function isVaultBundle(value: unknown): value is VaultBundle {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ (value as { format?: unknown }).format === "pim-vault"
+ );
+}
diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx
new file mode 100644
index 0000000..2ce7810
--- /dev/null
+++ b/src/pages/AnalyticsPage.tsx
@@ -0,0 +1,104 @@
+import { Link } from "react-router-dom";
+import { ModuleLayout } from "@/components/layout/ModuleLayout";
+import { CaveatPanel } from "@/components/workbench/CaveatPanel";
+import { computeAnalytics, severityList } from "@/lib/workbench/analytics";
+import { SEVERITY_LABEL } from "@/lib/workbench/taxonomy";
+import { useSession } from "@/lib/workbench/session";
+
+export function AnalyticsPage() {
+ const { results, documents } = useSession();
+ const analytics = computeAnalytics(results, documents.length);
+ const maxSeverity = Math.max(
+ 1,
+ ...severityList().map((severity) => analytics.bySeverity[severity]),
+ );
+
+ return (
+
+ Back to workbench
+
+ }
+ >
+ {analytics.totalResults === 0 ? (
+
+
No analyses in this session yet.
+
+ Load a scenario
+
+
+ ) : (
+
+
+ Findings by severity
+
+ {severityList().map((severity) => {
+ const value = analytics.bySeverity[severity];
+ return (
+ -
+
+ {SEVERITY_LABEL[severity]}
+
+
+
+
+
+ {value}
+
+
+ );
+ })}
+
+
+ Pass rate:{" "}
+ {Math.round(analytics.passRate * 100)}%{" "}
+ across {analytics.totalResults} analyses.
+
+
+
+
+ By module
+
+ {analytics.byModule.map((entry) => (
+ -
+ {entry.moduleId}
+
+ {entry.results} analyses · {entry.findings} findings
+
+
+ ))}
+
+
+ Top reason codes
+
+ {analytics.reasonCodes.slice(0, 6).map((reason) => (
+ -
+
{reason.code}
+ {reason.count}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/ConnectorsPage.test.tsx b/src/pages/ConnectorsPage.test.tsx
new file mode 100644
index 0000000..85deaae
--- /dev/null
+++ b/src/pages/ConnectorsPage.test.tsx
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ConnectorsPage } from "./ConnectorsPage";
+
+describe("ConnectorsPage", () => {
+ it("renders all P3 connector tracks as disabled", () => {
+ render();
+ expect(
+ screen.getByRole("heading", { level: 1, name: /connector readiness/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/live_integration = false/i)).toBeInTheDocument();
+ expect(screen.getAllByRole("button", { name: /attempt invoke/i })).toHaveLength(7);
+ });
+
+ it("proves a disabled connector cannot be invoked", async () => {
+ render();
+ const buttons = screen.getAllByRole("button", { name: /attempt invoke/i });
+ const first = buttons[0];
+ if (!first) throw new Error("expected an invoke button");
+ await userEvent.click(first);
+ expect(await screen.findByText(/Blocked:/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/ConnectorsPage.tsx b/src/pages/ConnectorsPage.tsx
new file mode 100644
index 0000000..f0261d4
--- /dev/null
+++ b/src/pages/ConnectorsPage.tsx
@@ -0,0 +1,104 @@
+import { useState } from "react";
+import { Ban, ShieldAlert } from "lucide-react";
+import { ModuleLayout } from "@/components/layout/ModuleLayout";
+import { CapabilityBadge } from "@/components/workbench/CapabilityBadge";
+import { GateChecklist } from "@/components/workbench/GateChecklist";
+import {
+ CONNECTORS,
+ ConnectorDisabledError,
+ gateMatrix,
+ invokeConnector,
+} from "@/lib/workbench/connectors";
+
+export function ConnectorsPage() {
+ const matrix = gateMatrix();
+ const [attempted, setAttempted] = useState>({});
+
+ const attemptInvoke = (id: string) => {
+ try {
+ invokeConnector(id);
+ // Unreachable: invokeConnector always throws.
+ setAttempted((prev) => ({
+ ...prev,
+ [id]: "Unexpectedly returned — this should never happen.",
+ }));
+ } catch (err) {
+ const message =
+ err instanceof ConnectorDisabledError
+ ? err.message
+ : err instanceof Error
+ ? err.message
+ : "Blocked.";
+ setAttempted((prev) => ({ ...prev, [id]: message }));
+ }
+ };
+
+ return (
+
+
+
+
+
+ {matrix.disabled} of {matrix.total}
+ {" "}
+ connectors disabled · {matrix.liveEnabled} live integrations enabled.
+ Every connector reports live_integration = false. There
+ is no code path that performs a real provider call from this suite.
+
+
+
+
+ {CONNECTORS.map((connector) => (
+
+
+
+
{connector.trackLabel}
+
{connector.displayName}
+
+
+
+ {connector.description}
+
+ Examples: {connector.vendorExamples.join(", ")}
+
+
+
+
+ Gate requirements (unmet)
+
+
+
+
+
+
+
+
+ {attempted[connector.id] ? (
+
+ Blocked: {attempted[connector.id]}
+
+ ) : null}
+
+
+ ))}
+
+
+
+ Mirrored server-side by the offline scaffold at{" "}
+ apps/payment-intelligence-pilot, whose registry enforces
+ the same invariant and whose tests prove disabled connectors cannot be called.
+
+
+ );
+}
diff --git a/src/pages/DocsPage.tsx b/src/pages/DocsPage.tsx
new file mode 100644
index 0000000..8bc1e6a
--- /dev/null
+++ b/src/pages/DocsPage.tsx
@@ -0,0 +1,151 @@
+import { Link } from "react-router-dom";
+import { ModuleLayout } from "@/components/layout/ModuleLayout";
+import { MaturityLegend } from "@/components/workbench/MaturityLegend";
+import { CapabilityBadge } from "@/components/workbench/CapabilityBadge";
+import { CaveatPanel } from "@/components/workbench/CaveatPanel";
+import { surfacesByGroup } from "@/lib/workbench/surfaces";
+
+export function DocsPage() {
+ const modules = surfacesByGroup("module");
+ const workbench = surfacesByGroup("workbench");
+ const platform = surfacesByGroup("platform");
+
+ return (
+
+
+
+
+ Methodology
+
+ -
+ Syntax / shape checks — structural and
+ checksum validation (MOD-97, ISO 9362, AppHdr/Document namespaces). These are not
+ certified validation.
+
+ -
+ Registry-snapshot lookups — IBAN/BIC
+ reference data is bundled and can go stale. Provenance and freshness are surfaced.
+
+ -
+ Narrative & lifecycle grouping —
+ derived from the message you provide, in memory, with no live tracking.
+
+ -
+ Shared findings taxonomy — every result
+ carries a severity (pass / info / needs-review / critical) and a provenance block of
+ what was and was not checked.
+
+
+
+
+
+ Module guides
+
+ {modules.map((surface) => (
+
+
+ {surface.name}
+
+ {surface.summary}
+
+
+
+
+ ))}
+
+
+
+
+ Workbench surfaces
+
+ {workbench.map((surface) => (
+
+
+ {surface.name}
+
+ {surface.summary}
+
+
+
+
+ ))}
+
+
+
+
+ Platform & expansion
+
+ {platform.map((surface) => (
+
+
+ {surface.name}
+
+ {surface.summary}
+
+
+
+
+ ))}
+
+
+
+
+ Maturity roadmap
+
+
+
+
+
+
+ Support
+
+ -
+ Pick a bundled scenario from the Scenario Switcher to try a module end-to-end.
+
+ -
+ Run the suite locally with
pnpm dev; quality gate
+ is pnpm verify.
+
+ -
+ The hosted private-pilot scaffold lives at{" "}
+
apps/payment-intelligence-pilot and is run
+ separately.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/HealthPage.tsx b/src/pages/HealthPage.tsx
new file mode 100644
index 0000000..5a54571
--- /dev/null
+++ b/src/pages/HealthPage.tsx
@@ -0,0 +1,143 @@
+import { Activity, CheckCircle2, MinusCircle } from "lucide-react";
+import { ModuleLayout } from "@/components/layout/ModuleLayout";
+import { MaturityLegend } from "@/components/workbench/MaturityLegend";
+import { SUITE_VERSION } from "@/version";
+
+interface ScopeRow {
+ capability: string;
+ live: boolean;
+ note: string;
+}
+
+const SCOPE: ScopeRow[] = [
+ {
+ capability: "ISO 20022 shape / readiness checks",
+ live: false,
+ note: "Local, in-browser. Not certified validation.",
+ },
+ {
+ capability: "IBAN validate / build",
+ live: false,
+ note: "MOD-97 computed locally from a bundled registry snapshot.",
+ },
+ {
+ capability: "BIC lookup",
+ live: false,
+ note: "Tiny bundled demo snapshot — not a live directory.",
+ },
+ {
+ capability: "Verification of Payee (VOP)",
+ live: false,
+ note: "Not implemented. P3 gated connector, disabled.",
+ },
+ {
+ capability: "Scheme reachability",
+ live: false,
+ note: "Not implemented. P3 gated connector, disabled.",
+ },
+ {
+ capability: "Payment / settlement monitoring",
+ live: false,
+ note: "Not implemented. P3 gated connector, disabled.",
+ },
+ {
+ capability: "Certified CBPR+ / MyStandards",
+ live: false,
+ note: "Shape checks only until certification — caveat stays.",
+ },
+];
+
+const POSTURE: string[] = [
+ "Runs entirely in your browser. Nothing is uploaded.",
+ "No backend API in the public root suite.",
+ "No localStorage / sessionStorage / indexedDB / cookies.",
+ "No analytics, telemetry, error reporting or remote logging.",
+ "Only network calls are same-origin fetches of bundled /samples/**.",
+];
+
+export function HealthPage() {
+ return (
+
+
+
+
Public suite status
+
+
+ Operational (static)
+
+
+
+
- Version
+ - {SUITE_VERSION}
+
+
+
- Runtime
+ - Browser SPA
+
+
+
- Live integrations
+ - 0
+
+
+
+
+
Privacy posture
+
+ {POSTURE.map((item) => (
+ -
+
+ {item}
+
+ ))}
+
+
+
+
+
+ What is and is not live
+
+ Every capability below is local or disabled. The suite never presents live or certified
+ status without a real source.
+
+
+
+
+
+ | Capability |
+ Live? |
+ Notes |
+
+
+
+ {SCOPE.map((row) => (
+
+ | {row.capability} |
+
+
+
+ No
+
+ |
+ {row.note} |
+
+ ))}
+
+
+
+
+
+
+ Maturity tiers
+
+ The hosted pilot exposes its own backed /health{" "}
+ contract (see the Private-Pilot Scaffold). This page reflects the public static suite.
+
+
+
+
+ );
+}
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index ce9e912..d4cea64 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -1,96 +1,19 @@
import { Link } from "react-router-dom";
-import {
- ArrowRight,
- Building2,
- CheckCircle2,
- Eraser,
- FileCheck2,
- GitBranch,
- FileText,
- Hash,
- Lock,
- ShieldCheck,
- Sparkles,
-} from "lucide-react";
-import { cn } from "@/lib/utils";
-
-interface ModuleTile {
- to: string;
- name: string;
- summary: string;
- icon: typeof Eraser;
- status: "available" | "demo" | "planned";
-}
-
-const modules: ModuleTile[] = [
- {
- to: "/scrubber",
- name: "Scrubber",
- summary:
- "Strip personally identifying fields from ISO 20022 payment XML before sharing with peers or vendors.",
- icon: Eraser,
- status: "available",
- },
- {
- to: "/storyteller",
- name: "Storyteller",
- summary:
- "Turn pacs.* and camt.* messages into a plain-language narrative with a structured field projection.",
- icon: FileText,
- status: "available",
- },
- {
- to: "/iban",
- name: "IBAN Workbench",
- summary:
- "Validate, build, catalogue, and trace IBAN provenance from the bundled registry snapshot. No live BIC, no VOP.",
- icon: Hash,
- status: "available",
- },
- {
- to: "/bic",
- name: "BIC Validator*",
- summary:
- "ISO 9362 structural checks plus a small bundled snapshot lookup. Demonstration only — data is not accurate/current, with no live lookup or reachability check.",
- icon: Building2,
- status: "demo",
- },
- {
- to: "/cbpr",
- name: "CBPR+ Readiness Checker",
- summary:
- "Inspect AppHdr, Document namespace, bundled CBPR+ schema-profile coverage, UETR, BIC, and IBAN shape locally. Not a certified validator.",
- icon: FileCheck2,
- status: "available",
- },
- {
- to: "/insights",
- name: "Payment Insights Lite",
- summary:
- "Group ACK/NACK, pacs.* and camt.* files you provide into local lifecycle threads. Not live payment tracking.",
- icon: GitBranch,
- status: "available",
- },
- {
- to: "#",
- name: "Vault",
- summary:
- "Planned encrypted local export bundle: user-controlled download/import, no cloud vault, no server storage, and no browser persistence by default.",
- icon: Lock,
- status: "planned",
- },
-];
+import { ArrowRight, CheckCircle2, Lock, ShieldCheck, Sparkles } from "lucide-react";
+import { CapabilityBadge } from "@/components/workbench/CapabilityBadge";
+import { MaturityLegend } from "@/components/workbench/MaturityLegend";
+import { surfacesByGroup, type SuiteSurface } from "@/lib/workbench/surfaces";
const howItWorks = [
"Drop in or paste ISO 20022 XML — Storyteller handles selected pacs./camt. narratives, CBPR+ Readiness checks AppHdr/MX structure, and Payment Insights Lite groups local lifecycle files by identifiers.",
- "Parsing, scrubbing, narrative generation, readiness checks, and local lifecycle grouping all run inside the page using browser APIs and bundled metadata.",
- "Outputs are produced from in-memory data only and stay on your machine until you copy or download them.",
+ "Load a bundled, synthetic scenario into the in-memory workbench, then hand it off to a module — all without uploading anything.",
+ "Outputs are produced from in-memory data only and stay on your machine until you copy, download, or encrypt them into a local vault file.",
];
const whatItDoesNotDo = [
"No upload from the root browser runtime. No root API, telemetry, or remote logging.",
"No persistence — nothing is written to localStorage, sessionStorage, IndexedDB or cookies.",
- "No telemetry, analytics or error reporting beacons.",
+ "No live BIC/VOP/reachability/settlement calls. Live-data connectors exist only as disabled, gated stubs.",
];
const supportedMessageFamilies = [
@@ -103,18 +26,44 @@ const supportedMessageFamilies = [
"camt.054",
];
-const stats = [
- {
- value: String(
- modules.filter((module) => module.status === "available" || module.status === "demo").length,
- ),
- label: "Live browser modules",
- },
- { value: String(supportedMessageFamilies.length), label: "ISO 20022 message families" },
- { value: "0", label: "Uploads, storage, telemetry" },
-];
+function SurfaceCard({ surface }: { surface: SuiteSurface }) {
+ const Icon = surface.icon;
+ return (
+
+
+
+
+
+
+
+ {surface.name}
+ {surface.summary}
+
+ Open
+
+
+
+ );
+}
export function HomePage() {
+ const moduleSurfaces = surfacesByGroup("module");
+ const workbenchSurfaces = surfacesByGroup("workbench");
+ const platformSurfaces = surfacesByGroup("platform");
+
+ const stats = [
+ { value: String(moduleSurfaces.length), label: "Browser modules" },
+ { value: String(supportedMessageFamilies.length), label: "ISO 20022 message families" },
+ { value: "0", label: "Uploads, storage, telemetry" },
+ ];
+
return (
@@ -128,12 +77,16 @@ export function HomePage() {
the reference data underneath payments.
- Practitioner-grade utilities for payment operations and integration teams reviewing ISO
- 20022 XML. Scrub identifying data before sharing a sample, or turn a message into a
- plain-language narrative — without uploading anything.
+ A privacy-first workbench for payment operations and integration teams reviewing ISO
+ 20022 XML. Scrub, explain, validate-shape and group lifecycle fragments — locally — then
+ grow, behind honest gates, toward a hosted pilot and gated live-data tracks.
-
+
+ Open the workbench
+
+
+
Open Scrubber
@@ -141,10 +94,6 @@ export function HomePage() {
Open Storyteller
-
- Open IBAN
-
-
{stats.map((stat) => (
@@ -189,77 +138,47 @@ export function HomePage() {
Choose a review workflow
- Each module is designed as a small, auditable workflow: clear input, transparent
- transformation, and copy/download outputs that stay under your control.
+ Each module is a small, auditable workflow: clear input, transparent transformation,
+ and copy/download outputs that stay under your control.
+
+
Local operational layer (P1)
+
+ Work across modules in one session
+
+
+ A command center, scenario switcher, review queue, analytics and an encrypted local vault
+ — all in-memory, all on your device.
+
- {modules.map((module) => {
- const Icon = module.icon;
- const isPlanned = module.status === "planned";
- const isDemo = module.status === "demo";
- const tileClass = cn(
- "group relative flex min-h-64 flex-col p-6 transition-all",
- "practice-card",
- isPlanned
- ? "cursor-not-allowed opacity-65"
- : "hover:-translate-y-0.5 hover:border-accent/40 hover:shadow-md hover:shadow-slate-200/80",
- );
- const badgeLabel = isPlanned ? "Planned" : isDemo ? "Demo" : "Available";
- const badgeClass = isPlanned
- ? "border-border bg-muted text-muted-foreground"
- : isDemo
- ? "border-amber-300/60 bg-amber-100/70 text-amber-900"
- : "border-brand/25 bg-brand/10 text-primary";
- const inner = (
- <>
-
-
-
-
-
- {badgeLabel}
-
-
-
- {module.name}
-
-
- {module.summary}
-
- {!isPlanned ? (
-
- Open module
-
-
- ) : (
-
- Coming soon
-
- )}
- >
- );
+ {workbenchSurfaces.map((surface) => (
+
+ ))}
+
+
- return isPlanned ? (
-
+
Platform & expansion (P0–P3)
+
+ Honest gates, not fake production
+
+
+ Status and docs ship now. The hosted pilot and live-data connectors are present as gated,
+ disabled prototypes — never presented as live or certified.
+
+
+ {platformSurfaces.map((surface) => (
+
+ ))}
@@ -311,6 +230,14 @@ export function HomePage() {
+
+
+
+
+
+ A prototype, never production — and separate from this static suite.
+
+
+ The scaffold lives at apps/payment-intelligence-pilot
+ . It is a synthetic, offline FastAPI app with a loud demo-stub auth, SQLite persistence,
+ immutable audit, four-eyes approval and a disabled connector registry. The public suite
+ does not couple to it — there is no backend wiring in{" "}
+ src/.
+
+
+
+
+