diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..bb71360 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,25 @@ +# Risk Compliance interface pattern migration + +Risk Compliance uses the platform source-evidence, governed-operation, review-queue, and revisioned-record patterns. It owns sanctions screening and assurance evidence; Connectors owns optional external source acquisition. + +## Surfaces + +- `risk_compliance.workspace` is the stable route surface and `risk_compliance.navigation` is its navigation entry. +- `risk_compliance.sanctions.sources` lists immutable source snapshots. `risk_compliance.action.import-snapshot` confirms copying acquired connector evidence into the normalized sanctions store. +- `risk_compliance.sanctions.screening` collects the minimum subject data needed for a version-pinned run. `risk_compliance.action.run-screening` creates durable screening evidence. +- `risk_compliance.sanctions.review` is a list-detail review queue. `risk_compliance.review.disposition` appends a legal disposition or time-bounded reusable exception. +- `risk_compliance.assurance.graph` exposes current effective assurance revisions and their bounded relationships. `risk_compliance.assurance.editor` appends object revisions and `risk_compliance.action.connect-assurance` appends typed relationships. + +Backend and WebUI manifests publish the same surface identifiers and parent hierarchy so Views can reduce the workspace without private module knowledge. + +## Consequences and recovery + +Snapshot import is confirmed because it creates immutable normalized evidence. Importing the same content reuses the existing snapshot. A screening run pins that snapshot version and clears the transient subject draft after the backend has accepted the run. + +Review dispositions are append-only. Reusable exceptions require an explicit expiry. Assurance edits preserve earlier effective-dated revisions, while system-managed sanctions projections remain read-only. A failed request leaves the draft available; navigating away from a changed screening, disposition, assurance object, or relationship invokes the shared unsaved-change guard. + +Unavailable actions remain visible with selection, permission, busy-state, missing-snapshot, system-managed-record, or optional-provider reasons. Contextual help resolves through `govoplan-docs` when enabled and through the hosted fallback otherwise. + +## Optional boundaries + +Connectors may announce sanctions-source snapshots through its public capability. Risk Compliance does not import connector internals or credentials. Audit, Policy, Records, Files, Tasks, Notifications, Views, and Workflow integrations remain optional and communicate through declared capabilities, interfaces, and stable references. diff --git a/src/govoplan_risk_compliance/backend/manifest.py b/src/govoplan_risk_compliance/backend/manifest.py index 6b2681d..ae0da83 100644 --- a/src/govoplan_risk_compliance/backend/manifest.py +++ b/src/govoplan_risk_compliance/backend/manifest.py @@ -336,6 +336,13 @@ DOCUMENTATION = ( ), kind="repository", ), + DocumentationLink( + label="Interface pattern migration", + href=( + "govoplan-risk-compliance/docs/INTERFACE_PATTERN_MIGRATION.md" + ), + kind="repository", + ), ), metadata={ "domain_objects": [ @@ -363,6 +370,28 @@ DOCUMENTATION = ( "Every node and edge is effective-dated, revisioned, tenant-scoped, " "and linked through opaque governed-object references." ), + "help_contexts": [ + "risk_compliance.workspace", + "risk_compliance.sanctions.sources", + "risk_compliance.action.import-snapshot", + "risk_compliance.sanctions.screening", + "risk_compliance.action.run-screening", + "risk_compliance.sanctions.review", + "risk_compliance.review.disposition", + "risk_compliance.assurance.graph", + "risk_compliance.assurance.editor", + "risk_compliance.action.connect-assurance", + "risk_compliance.state.source-unavailable", + "risk_compliance.state.read-only", + ], + "consequence_classes": { + "import_snapshot": "copy connector evidence into an immutable normalized sanctions-list snapshot", + "run_screening": "create immutable version-pinned screening evidence from the minimum submitted subject data", + "record_disposition": "append an evidence-backed legal review disposition that is not edited in place", + "record_exception": "append a time-bounded subject-and-entry exception with explicit expiry", + "revise_assurance_object": "append a new effective-dated revision while preserving prior evidence", + "connect_assurance_objects": "append a governed typed relationship between assurance objects", + }, }, ), ) @@ -431,6 +460,7 @@ manifest = ModuleManifest( module_id=MODULE_ID, kind="section", label="Sanctions source snapshots", + parent_id="risk_compliance.workspace", order=20, ), ViewSurface( @@ -438,6 +468,7 @@ manifest = ModuleManifest( module_id=MODULE_ID, kind="section", label="Sanctions screening", + parent_id="risk_compliance.workspace", order=30, ), ViewSurface( @@ -445,6 +476,7 @@ manifest = ModuleManifest( module_id=MODULE_ID, kind="section", label="Sanctions review queue", + parent_id="risk_compliance.workspace", order=40, ), ViewSurface( @@ -452,8 +484,49 @@ manifest = ModuleManifest( module_id=MODULE_ID, kind="section", label="Assurance graph", + parent_id="risk_compliance.workspace", order=50, ), + ViewSurface( + id="risk_compliance.action.import-snapshot", + module_id=MODULE_ID, + kind="action", + label="Import sanctions source snapshot", + parent_id="risk_compliance.sanctions.sources", + order=60, + ), + ViewSurface( + id="risk_compliance.action.run-screening", + module_id=MODULE_ID, + kind="action", + label="Run sanctions screening", + parent_id="risk_compliance.sanctions.screening", + order=70, + ), + ViewSurface( + id="risk_compliance.review.disposition", + module_id=MODULE_ID, + kind="dialog", + label="Record screening disposition", + parent_id="risk_compliance.sanctions.review", + order=80, + ), + ViewSurface( + id="risk_compliance.assurance.editor", + module_id=MODULE_ID, + kind="dialog", + label="Assurance object editor", + parent_id="risk_compliance.assurance.graph", + order=90, + ), + ViewSurface( + id="risk_compliance.action.connect-assurance", + module_id=MODULE_ID, + kind="action", + label="Connect assurance objects", + parent_id="risk_compliance.assurance.graph", + order=100, + ), ), ), tenant_summary_providers=(_tenant_summary,), diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..183411c --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +from govoplan_risk_compliance.backend.manifest import get_manifest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class RiskComplianceInterfaceDocumentationContractTests(unittest.TestCase): + def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None: + frontend = get_manifest().frontend + self.assertIsNotNone(frontend) + surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr] + expected = { + "risk_compliance.sanctions.sources", + "risk_compliance.sanctions.screening", + "risk_compliance.sanctions.review", + "risk_compliance.assurance.graph", + "risk_compliance.action.import-snapshot", + "risk_compliance.action.run-screening", + "risk_compliance.review.disposition", + "risk_compliance.assurance.editor", + "risk_compliance.action.connect-assurance", + } + self.assertEqual(expected, set(surfaces)) + self.assertEqual( + "risk_compliance.workspace", + frontend.routes[0].surface_id, # type: ignore[union-attr] + ) + for surface_id in ( + "risk_compliance.sanctions.sources", + "risk_compliance.sanctions.screening", + "risk_compliance.sanctions.review", + "risk_compliance.assurance.graph", + ): + self.assertEqual("risk_compliance.workspace", surfaces[surface_id].parent_id) + self.assertEqual( + "risk_compliance.sanctions.sources", + surfaces["risk_compliance.action.import-snapshot"].parent_id, + ) + self.assertEqual( + "risk_compliance.sanctions.screening", + surfaces["risk_compliance.action.run-screening"].parent_id, + ) + self.assertEqual( + "risk_compliance.sanctions.review", + surfaces["risk_compliance.review.disposition"].parent_id, + ) + self.assertEqual( + "risk_compliance.assurance.graph", + surfaces["risk_compliance.assurance.editor"].parent_id, + ) + + def test_help_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in get_manifest().documentation} + topic = topics["risk_compliance.module-boundary"] + + for context in ( + "risk_compliance.action.import-snapshot", + "risk_compliance.action.run-screening", + "risk_compliance.review.disposition", + "risk_compliance.assurance.editor", + "risk_compliance.action.connect-assurance", + ): + self.assertIn(context, topic.metadata["help_contexts"]) + for consequence in ( + "import_snapshot", + "run_screening", + "record_disposition", + "record_exception", + "revise_assurance_object", + "connect_assurance_objects", + ): + self.assertIn(consequence, topic.metadata["consequence_classes"]) + + def test_webui_uses_shared_governed_operation_patterns(self) -> None: + page = ( + REPO_ROOT + / "webui/src/features/riskCompliance/RiskCompliancePage.tsx" + ).read_text(encoding="utf-8") + module = (REPO_ROOT / "webui/src/module.ts").read_text(encoding="utf-8") + + for component in ( + "ActionBlockerHint", + "ConfirmDialog", + "DocumentationHelpLink", + "MetricCard", + "SelectionList", + "useUnsavedDraftGuard", + ): + self.assertIn(component, page) + self.assertIn("risk_compliance.assurance.graph", module) + self.assertIn("risk_compliance.action.connect-assurance", module) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/riskCompliance/RiskCompliancePage.tsx b/webui/src/features/riskCompliance/RiskCompliancePage.tsx index adca5b6..0da4ab5 100644 --- a/webui/src/features/riskCompliance/RiskCompliancePage.tsx +++ b/webui/src/features/riskCompliance/RiskCompliancePage.tsx @@ -18,16 +18,24 @@ import { } from "react"; import { useSearchParams } from "react-router"; import { + ActionBlockerHint, Button, + ConfirmDialog, Dialog, DismissibleAlert, + DocumentationHelpLink, FormField, IconButton, LoadingIndicator, + MetricCard, SegmentedControl, + SelectionList, + SelectionListItem, StatusBadge, ToggleSwitch, hasScope, + i18nMessage, + useUnsavedDraftGuard, type PlatformRouteContext } from "@govoplan/core-webui"; import { @@ -55,6 +63,12 @@ import { type ReviewQueueItem, type ScreeningRun } from "../../api/riskCompliance"; +import { + RISK_COMPLIANCE_ADMIN_DOCUMENTATION, + RISK_COMPLIANCE_BLOCKER_LABELS, + RISK_COMPLIANCE_DOCUMENTATION, + RISK_COMPLIANCE_I18N +} from "./interfacePatterns"; type ViewMode = "sources" | "screen" | "review" | "assurance"; @@ -93,6 +107,7 @@ export default function RiskCompliancePage({ const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); + const [pendingImport, setPendingImport] = useState(null); const canAdmin = hasScope( auth, "risk_compliance:sanctions:admin" @@ -221,6 +236,7 @@ export default function RiskCompliancePage({ ? `Imported ${result.snapshot.entry_count} list entries.` : "This immutable snapshot was already imported." ); + setPendingImport(null); await refresh(); } catch (reason) { setError(errorMessage(reason)); @@ -254,7 +270,8 @@ export default function RiskCompliancePage({ Screen ), - disabled: !canScreen + disabled: !canScreen, + title: !canScreen ? RISK_COMPLIANCE_I18N.screenRequired : undefined }, { id: "review", @@ -267,7 +284,8 @@ export default function RiskCompliancePage({ )} ), - disabled: !canReview + disabled: !canReview, + title: !canReview ? RISK_COMPLIANCE_I18N.reviewRequired : undefined }, { id: "assurance", @@ -277,17 +295,20 @@ export default function RiskCompliancePage({ Assurance ), - disabled: !canReadAssurance + disabled: !canReadAssurance, + title: !canReadAssurance ? RISK_COMPLIANCE_I18N.assuranceReadRequired : undefined } ]} /> + {loading && } } onClick={() => void refresh()} disabled={loading || busy} + disabledReason={loading ? RISK_COMPLIANCE_I18N.loading : busy ? RISK_COMPLIANCE_I18N.busy : undefined} /> {(error || notice) && ( @@ -320,7 +341,7 @@ export default function RiskCompliancePage({ imported={listSnapshots} canImport={canAdmin} busy={busy} - onImport={importSnapshot} + onImport={setPendingImport} /> )} {view === "screen" && ( @@ -363,6 +384,18 @@ export default function RiskCompliancePage({ /> )} + setPendingImport(null)} + onConfirm={() => pendingImport && void importSnapshot(pendingImport)} + /> ); } @@ -380,7 +413,7 @@ function SourcesPane({ imported: ListSnapshot[]; canImport: boolean; busy: boolean; - onImport: (item: ConnectorSnapshot) => Promise; + onImport: (item: ConnectorSnapshot) => void; }) { const importedRefs = useMemo( () => new Set(imported.map((item) => item.sha256)), @@ -394,11 +427,21 @@ function SourcesPane({ Connector evidence Immutable acquired source snapshots + {!available && ( -
- The Connectors sanctions source capability is not enabled. -
+ )}
{sources.map((item) => { @@ -419,6 +462,7 @@ function SourcesPane({ label="Import snapshot" icon={} disabled={!canImport || busy} + disabledReason={!canImport ? RISK_COMPLIANCE_I18N.adminRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined} onClick={() => void onImport(item)} /> )} @@ -486,8 +530,31 @@ function ScreenPane({ } }, [listId, snapshots]); + const draftDirty = Boolean(name.trim() || identifier.trim()); + const submitDisabledReason = submitting + ? RISK_COMPLIANCE_I18N.busy + : !listId + ? RISK_COMPLIANCE_I18N.snapshotRequired + : !name.trim() && !identifier.trim() + ? RISK_COMPLIANCE_I18N.subjectRequired + : undefined; + + useUnsavedDraftGuard({ + dirty: draftDirty, + onSave: executeScreening, + onDiscard: () => { + setName(""); + setIdentifier(""); + } + }); + async function submit(event: FormEvent) { event.preventDefault(); + await executeScreening(); + } + + async function executeScreening(): Promise { + if (submitDisabledReason) return false; setSubmitting(true); onError(""); try { @@ -503,8 +570,12 @@ function ScreenPane({ } }); onRun(response.run); + setName(""); + setIdentifier(""); + return true; } catch (reason) { onError(errorMessage(reason)); + return false; } finally { setSubmitting(false); } @@ -518,8 +589,22 @@ function ScreenPane({ New screening Use only the data needed for comparison
+
+ {!snapshots.length && ( + + )} ( emptyAssuranceNodeDraft() ); + const [nodeSavedKey, setNodeSavedKey] = useState(""); const [edgeRelation, setEdgeRelation] = useState(""); const [edgeTarget, setEdgeTarget] = useState(""); + const [edgeSavedKey, setEdgeSavedKey] = useState(""); const selected = nodes.find((item) => item.stable_id === selectedId) ?? null; const visibleNodes = useMemo(() => { const needle = query.trim().toLocaleLowerCase(); @@ -973,21 +1100,63 @@ function AssurancePane({ const edgeTargets = activeRelation ? nodes.filter((item) => item.kind === activeRelation.target) : []; + const nodeSaveDisabledReason = busy + ? RISK_COMPLIANCE_I18N.busy + : !nodeDraft.stableId.trim() || !nodeDraft.label.trim() || !nodeDraft.ownerRef.trim() || !nodeDraft.validFrom || (nodeDraft.kind === "governed_object" && !nodeDraft.governedObjectRef.trim()) + ? RISK_COMPLIANCE_I18N.assuranceFieldsRequired + : undefined; + const edgeSaveDisabledReason = busy + ? RISK_COMPLIANCE_I18N.busy + : !selected || !activeRelation || !edgeTarget + ? RISK_COMPLIANCE_I18N.relationRequired + : undefined; + const nodeDirty = nodeDialogOpen && nodeDraftKey(nodeDraft) !== nodeSavedKey; + const edgeDirty = edgeDialogOpen && `${edgeRelation}:${edgeTarget}` !== edgeSavedKey; + + function closeNodeDialog() { + setNodeDialogOpen(false); + setEditingNode(null); + } + + function closeEdgeDialog() { + setEdgeDialogOpen(false); + } + + useUnsavedDraftGuard({ + dirty: nodeDirty, + onSave: saveNode, + onDiscard: closeNodeDialog + }); + + useUnsavedDraftGuard({ + dirty: edgeDirty, + onSave: saveEdge, + onDiscard: closeEdgeDialog + }); function openNewNode() { + const draft = emptyAssuranceNodeDraft(); setEditingNode(null); - setNodeDraft(emptyAssuranceNodeDraft()); + setNodeDraft(draft); + setNodeSavedKey(nodeDraftKey(draft)); setNodeDialogOpen(true); } function openEditNode(item: AssuranceNode) { + const draft = nodeDraftFromItem(item); setEditingNode(item); - setNodeDraft(nodeDraftFromItem(item)); + setNodeDraft(draft); + setNodeSavedKey(nodeDraftKey(draft)); setNodeDialogOpen(true); } async function submitNode(event: FormEvent) { event.preventDefault(); + await saveNode(); + } + + async function saveNode(): Promise { + if (nodeSaveDisabledReason) return false; onBusy(true); onError(""); try { @@ -996,7 +1165,7 @@ function AssurancePane({ assuranceNodeWrite(nodeDraft, editingNode?.provenance), editingNode?.revision ); - setNodeDialogOpen(false); + closeNodeDialog(); onSelect(saved.stable_id); onNotice( editingNode @@ -1004,8 +1173,10 @@ function AssurancePane({ : "Created the assurance object." ); await onRefresh(); + return true; } catch (reason) { onError(errorMessage(reason)); + return false; } finally { onBusy(false); } @@ -1013,18 +1184,23 @@ function AssurancePane({ function openEdgeDialog() { const first = availableRelations[0]; - setEdgeRelation(first?.id ?? ""); - setEdgeTarget( - first - ? nodes.find((item) => item.kind === first.target)?.stable_id ?? "" - : "" - ); + const relation = first?.id ?? ""; + const target = first + ? nodes.find((item) => item.kind === first.target)?.stable_id ?? "" + : ""; + setEdgeRelation(relation); + setEdgeTarget(target); + setEdgeSavedKey(`${relation}:${target}`); setEdgeDialogOpen(true); } async function submitEdge(event: FormEvent) { event.preventDefault(); - if (!selected || !activeRelation || !edgeTarget) return; + await saveEdge(); + } + + async function saveEdge(): Promise { + if (edgeSaveDisabledReason || !selected || !activeRelation || !edgeTarget) return false; onBusy(true); onError(""); const value: AssuranceEdgeWrite = { @@ -1044,11 +1220,13 @@ function AssurancePane({ }; try { await saveAssuranceEdge(settings, value); - setEdgeDialogOpen(false); + closeEdgeDialog(); onNotice("Connected the assurance objects."); await onRefresh(); + return true; } catch (reason) { onError(errorMessage(reason)); + return false; } finally { onBusy(false); } @@ -1056,11 +1234,31 @@ function AssurancePane({ return (
-
- {summary?.node_count ?? 0}objects - {summary?.edge_count ?? 0}relationships - {summary?.by_kind.risk ?? 0}risks - {summary?.by_state.open ?? 0}open findings +
+ {!canWrite && ( + + )} +
+ + + + 0 ? "danger" : "good"} + /> +
- {canWrite && ( +
+ } onClick={openNewNode} + disabled={!canWrite || busy} + disabledReason={!canWrite ? RISK_COMPLIANCE_I18N.assuranceWriteRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined} /> - )} +
- {visibleNodes.map((item) => ( - - ))} + {visibleNodes.length > 0 && ( + + {visibleNodes.map((item) => ( + onSelect(item.stable_id)} + > + + {item.label} + {formatToken(item.kind)} · revision {item.revision} + + + + ))} + + )} {!visibleNodes.length && (
No assurance objects match.
)} @@ -1126,13 +1327,16 @@ function AssurancePane({ {selected?.label || "Assurance object"} {selected ? formatToken(selected.kind) : "Select an object"}
- {selected && canWrite && !selected.stable_id.startsWith("sanctions-") && ( +
+ } - onClick={() => openEditNode(selected)} + onClick={() => selected && openEditNode(selected)} + disabled={!selected || !canWrite || selected.stable_id.startsWith("sanctions-") || busy} + disabledReason={!selected ? RISK_COMPLIANCE_I18N.assuranceObjectRequired : !canWrite ? RISK_COMPLIANCE_I18N.assuranceWriteRequired : selected.stable_id.startsWith("sanctions-") ? RISK_COMPLIANCE_I18N.systemManagedObject : busy ? RISK_COMPLIANCE_I18N.busy : undefined} /> - )} +
{!selected && (
Select an assurance object.
@@ -1155,12 +1359,14 @@ function AssurancePane({ Relationships {graph?.edges.length ?? 0} in the bounded graph
- {canWrite && availableRelations.length > 0 && ( - - )} +
{graph?.edges.map((edge) => ( @@ -1190,29 +1396,32 @@ function AssurancePane({ busy={busy} editing={Boolean(editingNode)} draft={nodeDraft} + saveDisabledReason={nodeSaveDisabledReason} onChange={setNodeDraft} - onClose={() => setNodeDialogOpen(false)} + onClose={closeNodeDialog} onSubmit={submitNode} /> setEdgeDialogOpen(false)} + onClose={closeEdgeDialog} closeDisabled={busy} footer={ <> - + } > +