feat: implement assurance graph and screening evidence

This commit is contained in:
2026-08-01 17:48:39 +02:00
parent bbf7288e14
commit a23b53dc9e
18 changed files with 4005 additions and 42 deletions
@@ -1,7 +1,10 @@
import {
CheckCircle2,
Database,
Network,
Pencil,
Play,
Plus,
RefreshCw,
Scale,
Upload
@@ -13,6 +16,7 @@ import {
useState,
type FormEvent
} from "react";
import { useSearchParams } from "react-router";
import {
Button,
Dialog,
@@ -28,12 +32,23 @@ import {
} from "@govoplan/core-webui";
import {
createDisposition,
getAssuranceGraph,
getAssuranceSummary,
getCandidate,
importListSnapshot,
listConnectorSnapshots,
listAssuranceNodes,
listListSnapshots,
listReviewQueue,
runScreening,
saveAssuranceEdge,
saveAssuranceNode,
type AssuranceEdge,
type AssuranceEdgeWrite,
type AssuranceNode,
type AssuranceNodeKind,
type AssuranceNodeWrite,
type AssuranceSummary,
type CandidateDetail,
type ConnectorSnapshot,
type ListSnapshot,
@@ -42,13 +57,18 @@ import {
} from "../../api/riskCompliance";
type ViewMode = "sources" | "screen" | "review";
type ViewMode = "sources" | "screen" | "review" | "assurance";
export default function RiskCompliancePage({
settings,
auth
}: PlatformRouteContext) {
const [view, setView] = useState<ViewMode>("review");
const [searchParams] = useSearchParams();
const requestedView = searchParams.get("view");
const requestedAssuranceId = searchParams.get("node")?.trim() ?? "";
const [view, setView] = useState<ViewMode>(() =>
requestedView === "assurance" ? "assurance" : "review"
);
const [sourceSnapshots, setSourceSnapshots] = useState<
ConnectorSnapshot[]
>([]);
@@ -58,6 +78,17 @@ export default function RiskCompliancePage({
const [selectedCandidateId, setSelectedCandidateId] = useState("");
const [candidate, setCandidate] = useState<CandidateDetail | null>(null);
const [run, setRun] = useState<ScreeningRun | null>(null);
const [assuranceNodes, setAssuranceNodes] = useState<AssuranceNode[]>([]);
const [assuranceSummary, setAssuranceSummary] =
useState<AssuranceSummary | null>(null);
const [selectedAssuranceId, setSelectedAssuranceId] = useState(
requestedAssuranceId
);
const [assuranceGraph, setAssuranceGraph] = useState<{
nodes: AssuranceNode[];
edges: AssuranceEdge[];
truncated: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -74,34 +105,71 @@ export default function RiskCompliancePage({
auth,
"risk_compliance:sanctions:review"
);
const canReadAssurance = [
"risk_compliance:workspace:read",
"risk_compliance:workspace:write",
"risk_compliance:workspace:admin"
].some((scope) => hasScope(auth, scope));
const canWriteAssurance = [
"risk_compliance:workspace:write",
"risk_compliance:workspace:admin"
].some((scope) => hasScope(auth, scope));
useEffect(() => {
if (requestedView === "assurance" && canReadAssurance) {
setView("assurance");
if (requestedAssuranceId) {
setSelectedAssuranceId(requestedAssuranceId);
}
}
}, [canReadAssurance, requestedAssuranceId, requestedView]);
useEffect(() => {
if (view === "review" && !canReview) {
setView(canReadAssurance ? "assurance" : "sources");
} else if (view === "assurance" && !canReadAssurance) {
setView(canReview ? "review" : "sources");
}
}, [canReadAssurance, canReview, view]);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [sources, lists, reviewQueue] = await Promise.all([
const [sources, lists, reviewQueue, assurance] = await Promise.all([
listConnectorSnapshots(settings),
listListSnapshots(settings),
canReview
? listReviewQueue(settings)
: Promise.resolve({ candidates: [] })
: Promise.resolve({ candidates: [] }),
canReadAssurance
? Promise.all([
listAssuranceNodes(settings),
getAssuranceSummary(settings)
]).then(([nodes, summary]) => ({ nodes: nodes.nodes, summary }))
: Promise.resolve({ nodes: [], summary: null })
]);
setSourcesAvailable(sources.available);
setSourceSnapshots(sources.snapshots);
setListSnapshots(lists.snapshots);
setQueue(reviewQueue.candidates);
setAssuranceNodes(assurance.nodes);
setAssuranceSummary(assurance.summary);
setSelectedCandidateId((current) =>
current &&
reviewQueue.candidates.some((item) => item.id === current)
? current
: reviewQueue.candidates[0]?.id ?? ""
);
setSelectedAssuranceId((current) =>
current || assurance.nodes[0]?.stable_id || ""
);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setLoading(false);
}
}, [canReview, settings]);
}, [canReadAssurance, canReview, settings]);
useEffect(() => {
void refresh();
@@ -125,6 +193,24 @@ export default function RiskCompliancePage({
};
}, [selectedCandidateId, settings]);
useEffect(() => {
if (!selectedAssuranceId || !canReadAssurance) {
setAssuranceGraph(null);
return;
}
let cancelled = false;
void getAssuranceGraph(settings, selectedAssuranceId)
.then((graph) => {
if (!cancelled) setAssuranceGraph(graph);
})
.catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
});
return () => {
cancelled = true;
};
}, [canReadAssurance, selectedAssuranceId, settings]);
async function importSnapshot(item: ConnectorSnapshot) {
setBusy(true);
setError("");
@@ -182,6 +268,16 @@ export default function RiskCompliancePage({
</>
),
disabled: !canReview
},
{
id: "assurance",
label: (
<>
<Network size={15} />
Assurance
</>
),
disabled: !canReadAssurance
}
]}
/>
@@ -250,6 +346,22 @@ export default function RiskCompliancePage({
settings={settings}
/>
)}
{view === "assurance" && (
<AssurancePane
nodes={assuranceNodes}
summary={assuranceSummary}
graph={assuranceGraph}
selectedId={selectedAssuranceId}
canWrite={canWriteAssurance}
busy={busy}
settings={settings}
onSelect={setSelectedAssuranceId}
onBusy={setBusy}
onError={setError}
onNotice={setNotice}
onRefresh={refresh}
/>
)}
</div>
</main>
);
@@ -754,6 +866,605 @@ function ReviewPane({
);
}
const ASSURANCE_STATES: Record<AssuranceNodeKind, string[]> = {
obligation: ["active", "suspended", "retired"],
governed_object: ["active", "inactive", "retired"],
risk: ["identified", "assessed", "accepted", "mitigated", "closed"],
control: [
"designed",
"implemented",
"effective",
"failed",
"suspended",
"retired"
],
evidence: ["current", "stale", "invalid", "superseded"],
finding: ["open", "accepted", "exception", "remediating", "resolved"],
corrective_measure: ["planned", "in_progress", "completed", "cancelled"],
effectiveness_review: ["pending", "effective", "ineffective", "inconclusive"]
};
const ASSURANCE_RELATIONS = [
{ id: "applies_to", source: "obligation", target: "governed_object" },
{ id: "exposes_risk", source: "governed_object", target: "risk" },
{ id: "mitigated_by", source: "risk", target: "control" },
{ id: "evidenced_by", source: "control", target: "evidence" },
{ id: "results_in", source: "evidence", target: "finding" },
{ id: "addressed_by", source: "finding", target: "corrective_measure" },
{
id: "reviewed_by",
source: "corrective_measure",
target: "effectiveness_review"
}
] as const;
type AssuranceNodeDraft = {
stableId: string;
kind: AssuranceNodeKind;
label: string;
description: string;
state: string;
ownerRef: string;
scopeRef: string;
governedObjectRef: string;
validFrom: string;
validTo: string;
classification: string;
legalBasisRefs: string;
policyRefs: string;
evidenceRefs: string;
};
function AssurancePane({
nodes,
summary,
graph,
selectedId,
canWrite,
busy,
settings,
onSelect,
onBusy,
onError,
onNotice,
onRefresh
}: {
nodes: AssuranceNode[];
summary: AssuranceSummary | null;
graph: { nodes: AssuranceNode[]; edges: AssuranceEdge[]; truncated: boolean } | null;
selectedId: string;
canWrite: boolean;
busy: boolean;
settings: PlatformRouteContext["settings"];
onSelect: (id: string) => void;
onBusy: (value: boolean) => void;
onError: (message: string) => void;
onNotice: (message: string) => void;
onRefresh: () => Promise<void>;
}) {
const [query, setQuery] = useState("");
const [kind, setKind] = useState("");
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
const [edgeDialogOpen, setEdgeDialogOpen] = useState(false);
const [editingNode, setEditingNode] = useState<AssuranceNode | null>(null);
const [nodeDraft, setNodeDraft] = useState<AssuranceNodeDraft>(
emptyAssuranceNodeDraft()
);
const [edgeRelation, setEdgeRelation] = useState("");
const [edgeTarget, setEdgeTarget] = useState("");
const selected = nodes.find((item) => item.stable_id === selectedId) ?? null;
const visibleNodes = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return nodes.filter(
(item) =>
(!kind || item.kind === kind) &&
(!needle ||
item.label.toLocaleLowerCase().includes(needle) ||
item.stable_id.toLocaleLowerCase().includes(needle) ||
(item.description || "").toLocaleLowerCase().includes(needle))
);
}, [kind, nodes, query]);
const availableRelations = selected
? ASSURANCE_RELATIONS.filter((item) => item.source === selected.kind)
: [];
const activeRelation = availableRelations.find(
(item) => item.id === edgeRelation
);
const edgeTargets = activeRelation
? nodes.filter((item) => item.kind === activeRelation.target)
: [];
function openNewNode() {
setEditingNode(null);
setNodeDraft(emptyAssuranceNodeDraft());
setNodeDialogOpen(true);
}
function openEditNode(item: AssuranceNode) {
setEditingNode(item);
setNodeDraft(nodeDraftFromItem(item));
setNodeDialogOpen(true);
}
async function submitNode(event: FormEvent) {
event.preventDefault();
onBusy(true);
onError("");
try {
const saved = await saveAssuranceNode(
settings,
assuranceNodeWrite(nodeDraft, editingNode?.provenance),
editingNode?.revision
);
setNodeDialogOpen(false);
onSelect(saved.stable_id);
onNotice(
editingNode
? `Recorded assurance revision ${saved.revision}.`
: "Created the assurance object."
);
await onRefresh();
} catch (reason) {
onError(errorMessage(reason));
} finally {
onBusy(false);
}
}
function openEdgeDialog() {
const first = availableRelations[0];
setEdgeRelation(first?.id ?? "");
setEdgeTarget(
first
? nodes.find((item) => item.kind === first.target)?.stable_id ?? ""
: ""
);
setEdgeDialogOpen(true);
}
async function submitEdge(event: FormEvent) {
event.preventDefault();
if (!selected || !activeRelation || !edgeTarget) return;
onBusy(true);
onError("");
const value: AssuranceEdgeWrite = {
stable_id: `edge-${crypto.randomUUID()}`,
source_node_ref: selected.stable_id,
target_node_ref: edgeTarget,
relation: activeRelation.id,
state: "active",
owner_ref: selected.owner_ref,
scope_ref: selected.scope_ref,
valid_from: new Date().toISOString(),
valid_to: null,
provenance: { source: "risk-compliance-ui" },
legal_basis_refs: selected.legal_basis_refs,
policy_refs: selected.policy_refs,
evidence_refs: []
};
try {
await saveAssuranceEdge(settings, value);
setEdgeDialogOpen(false);
onNotice("Connected the assurance objects.");
await onRefresh();
} catch (reason) {
onError(errorMessage(reason));
} finally {
onBusy(false);
}
}
return (
<section className="risk-assurance-layout">
<div className="risk-assurance-metrics">
<span><strong>{summary?.node_count ?? 0}</strong>objects</span>
<span><strong>{summary?.edge_count ?? 0}</strong>relationships</span>
<span><strong>{summary?.by_kind.risk ?? 0}</strong>risks</span>
<span><strong>{summary?.by_state.open ?? 0}</strong>open findings</span>
</div>
<div className="risk-assurance-columns">
<aside className="risk-panel">
<header>
<div>
<strong>Assurance objects</strong>
<span>Current effective revisions</span>
</div>
{canWrite && (
<IconButton
label="Add assurance object"
icon={<Plus size={16} />}
onClick={openNewNode}
/>
)}
</header>
<div className="risk-assurance-filter">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search assurance objects"
aria-label="Search assurance objects"
/>
<select
value={kind}
onChange={(event) => setKind(event.target.value)}
aria-label="Filter by assurance type"
>
<option value="">All types</option>
{Object.keys(ASSURANCE_STATES).map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</div>
<div className="risk-list">
{visibleNodes.map((item) => (
<button
type="button"
className={
item.stable_id === selectedId
? "risk-queue-row selected"
: "risk-queue-row"
}
key={item.stable_id}
onClick={() => onSelect(item.stable_id)}
>
<span className="risk-list-main">
<strong>{item.label}</strong>
<span>{formatToken(item.kind)} · revision {item.revision}</span>
</span>
<StatusBadge status={item.state} />
</button>
))}
{!visibleNodes.length && (
<div className="risk-empty">No assurance objects match.</div>
)}
</div>
</aside>
<div className="risk-panel risk-assurance-detail">
<header>
<div>
<strong>{selected?.label || "Assurance object"}</strong>
<span>{selected ? formatToken(selected.kind) : "Select an object"}</span>
</div>
{selected && canWrite && !selected.stable_id.startsWith("sanctions-") && (
<IconButton
label="Edit assurance object"
icon={<Pencil size={16} />}
onClick={() => openEditNode(selected)}
/>
)}
</header>
{!selected && (
<div className="risk-empty">Select an assurance object.</div>
)}
{selected && (
<div className="risk-assurance-detail-body">
<dl className="risk-assurance-properties">
<div><dt>State</dt><dd><StatusBadge status={selected.state} /></dd></div>
<div><dt>Owner</dt><dd>{selected.owner_ref}</dd></div>
<div><dt>Scope</dt><dd>{selected.scope_ref || "Tenant"}</dd></div>
<div><dt>Valid from</dt><dd>{formatDate(selected.valid_from)}</dd></div>
{selected.governed_object_ref && (
<div><dt>Governed object</dt><dd><code>{selected.governed_object_ref}</code></dd></div>
)}
<div><dt>Classification</dt><dd>{selected.classification}</dd></div>
</dl>
{selected.description && <p>{selected.description}</p>}
<div className="risk-assurance-links-header">
<div>
<strong>Relationships</strong>
<span>{graph?.edges.length ?? 0} in the bounded graph</span>
</div>
{canWrite && availableRelations.length > 0 && (
<Button onClick={openEdgeDialog} disabled={busy}>
<Plus size={16} />
Connect
</Button>
)}
</div>
<div className="risk-assurance-links">
{graph?.edges.map((edge) => (
<AssuranceEdgeRow
edge={edge}
nodes={graph.nodes}
selectedId={selected.stable_id}
onSelect={onSelect}
key={edge.stable_id}
/>
))}
{!graph?.edges.length && (
<div className="risk-empty">No relationships are recorded.</div>
)}
</div>
{graph?.truncated && (
<div className="risk-assurance-truncated">
The bounded graph contains additional relationships.
</div>
)}
</div>
)}
</div>
</div>
<AssuranceNodeDialog
open={nodeDialogOpen}
busy={busy}
editing={Boolean(editingNode)}
draft={nodeDraft}
onChange={setNodeDraft}
onClose={() => setNodeDialogOpen(false)}
onSubmit={submitNode}
/>
<Dialog
open={edgeDialogOpen}
title="Connect assurance objects"
onClose={() => setEdgeDialogOpen(false)}
closeDisabled={busy}
footer={
<>
<Button onClick={() => setEdgeDialogOpen(false)} disabled={busy}>Cancel</Button>
<Button
form="risk-assurance-edge-form"
type="submit"
variant="primary"
disabled={busy || !activeRelation || !edgeTarget}
>
Connect
</Button>
</>
}
>
<form id="risk-assurance-edge-form" className="risk-assurance-form" onSubmit={submitEdge}>
<FormField label="Relationship">
<select
value={edgeRelation}
onChange={(event) => {
const relation = event.target.value;
setEdgeRelation(relation);
const shape = availableRelations.find((item) => item.id === relation);
setEdgeTarget(
shape
? nodes.find((item) => item.kind === shape.target)?.stable_id ?? ""
: ""
);
}}
>
{availableRelations.map((item) => (
<option value={item.id} key={item.id}>{formatToken(item.id)}</option>
))}
</select>
</FormField>
<FormField label="Target object">
<select value={edgeTarget} onChange={(event) => setEdgeTarget(event.target.value)}>
{edgeTargets.map((item) => (
<option value={item.stable_id} key={item.stable_id}>{item.label}</option>
))}
</select>
</FormField>
</form>
</Dialog>
</section>
);
}
function AssuranceEdgeRow({
edge,
nodes,
selectedId,
onSelect
}: {
edge: AssuranceEdge;
nodes: AssuranceNode[];
selectedId: string;
onSelect: (id: string) => void;
}) {
const otherId = edge.source_node_ref === selectedId
? edge.target_node_ref
: edge.source_node_ref;
const other = nodes.find((item) => item.stable_id === otherId);
return (
<button type="button" onClick={() => onSelect(otherId)}>
<span>{formatToken(edge.relation)}</span>
<strong>{other?.label || otherId}</strong>
<StatusBadge status={edge.state} />
</button>
);
}
function AssuranceNodeDialog({
open,
busy,
editing,
draft,
onChange,
onClose,
onSubmit
}: {
open: boolean;
busy: boolean;
editing: boolean;
draft: AssuranceNodeDraft;
onChange: (value: AssuranceNodeDraft) => void;
onClose: () => void;
onSubmit: (event: FormEvent) => void;
}) {
const update = (values: Partial<AssuranceNodeDraft>) =>
onChange({ ...draft, ...values });
return (
<Dialog
open={open}
title={editing ? "Revise assurance object" : "Add assurance object"}
onClose={onClose}
closeDisabled={busy}
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
form="risk-assurance-node-form"
type="submit"
variant="primary"
disabled={busy}
>
{editing ? "Record revision" : "Add"}
</Button>
</>
}
>
<form id="risk-assurance-node-form" className="risk-assurance-form" onSubmit={onSubmit}>
<div className="risk-assurance-form-grid">
<FormField label="Stable ID">
<input
value={draft.stableId}
onChange={(event) => update({ stableId: event.target.value })}
disabled={editing}
required
maxLength={255}
/>
</FormField>
<FormField label="Type">
<select
value={draft.kind}
onChange={(event) => {
const nextKind = event.target.value as AssuranceNodeKind;
update({ kind: nextKind, state: ASSURANCE_STATES[nextKind][0] });
}}
disabled={editing}
>
{Object.keys(ASSURANCE_STATES).map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</FormField>
<FormField label="Name">
<input value={draft.label} onChange={(event) => update({ label: event.target.value })} required maxLength={500} />
</FormField>
<FormField label="State">
<select value={draft.state} onChange={(event) => update({ state: event.target.value })}>
{ASSURANCE_STATES[draft.kind].map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</FormField>
<FormField label="Owner reference">
<input value={draft.ownerRef} onChange={(event) => update({ ownerRef: event.target.value })} required maxLength={500} />
</FormField>
<FormField label="Scope reference">
<input value={draft.scopeRef} onChange={(event) => update({ scopeRef: event.target.value })} maxLength={500} />
</FormField>
{draft.kind === "governed_object" && (
<FormField label="Governed object reference">
<input value={draft.governedObjectRef} onChange={(event) => update({ governedObjectRef: event.target.value })} required maxLength={1000} />
</FormField>
)}
<FormField label="Classification">
<select value={draft.classification} onChange={(event) => update({ classification: event.target.value })}>
<option value="public">Public</option>
<option value="internal">Internal</option>
<option value="confidential">Confidential</option>
<option value="restricted">Restricted</option>
</select>
</FormField>
<FormField label="Valid from">
<input type="datetime-local" value={draft.validFrom} onChange={(event) => update({ validFrom: event.target.value })} required />
</FormField>
<FormField label="Valid to">
<input type="datetime-local" value={draft.validTo} onChange={(event) => update({ validTo: event.target.value })} />
</FormField>
</div>
<FormField label="Description">
<textarea value={draft.description} onChange={(event) => update({ description: event.target.value })} rows={4} maxLength={20000} />
</FormField>
<div className="risk-assurance-form-grid">
<FormField label="Legal basis references">
<textarea value={draft.legalBasisRefs} onChange={(event) => update({ legalBasisRefs: event.target.value })} rows={3} />
</FormField>
<FormField label="Policy references">
<textarea value={draft.policyRefs} onChange={(event) => update({ policyRefs: event.target.value })} rows={3} />
</FormField>
<FormField label="Evidence references">
<textarea value={draft.evidenceRefs} onChange={(event) => update({ evidenceRefs: event.target.value })} rows={3} />
</FormField>
</div>
</form>
</Dialog>
);
}
function emptyAssuranceNodeDraft(): AssuranceNodeDraft {
return {
stableId: "",
kind: "obligation",
label: "",
description: "",
state: "active",
ownerRef: "",
scopeRef: "",
governedObjectRef: "",
validFrom: dateTimeLocalValue(new Date()),
validTo: "",
classification: "internal",
legalBasisRefs: "",
policyRefs: "",
evidenceRefs: ""
};
}
function nodeDraftFromItem(item: AssuranceNode): AssuranceNodeDraft {
return {
stableId: item.stable_id,
kind: item.kind,
label: item.label,
description: item.description || "",
state: item.state,
ownerRef: item.owner_ref,
scopeRef: item.scope_ref || "",
governedObjectRef: item.governed_object_ref || "",
validFrom: dateTimeLocalValue(new Date(item.valid_from)),
validTo: item.valid_to ? dateTimeLocalValue(new Date(item.valid_to)) : "",
classification: item.classification,
legalBasisRefs: item.legal_basis_refs.join("\n"),
policyRefs: item.policy_refs.join("\n"),
evidenceRefs: item.evidence_refs.join("\n")
};
}
function assuranceNodeWrite(
draft: AssuranceNodeDraft,
provenance: Record<string, unknown> = { source: "risk-compliance-ui" }
): AssuranceNodeWrite {
return {
stable_id: draft.stableId.trim(),
kind: draft.kind,
label: draft.label.trim(),
description: draft.description.trim() || null,
state: draft.state,
owner_ref: draft.ownerRef.trim(),
scope_ref: draft.scopeRef.trim() || null,
governed_object_ref: draft.governedObjectRef.trim() || null,
valid_from: new Date(draft.validFrom).toISOString(),
valid_to: draft.validTo ? new Date(draft.validTo).toISOString() : null,
provenance,
legal_basis_refs: referenceLines(draft.legalBasisRefs),
policy_refs: referenceLines(draft.policyRefs),
evidence_refs: referenceLines(draft.evidenceRefs),
classification: draft.classification
};
}
function referenceLines(value: string) {
return Array.from(
new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))
);
}
function dateTimeLocalValue(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function formatToken(value: string) {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function EvidenceColumn({
title,
name,