feat(datasources): govern approvals and retention
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-08-22 19:37:44 +02:00
parent b54d1919e4
commit 7a7654cc0f
24 changed files with 2753 additions and 28 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/datasources-webui",
"version": "0.1.20",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+111
View File
@@ -35,6 +35,8 @@ export type DatasourceGovernance = {
transfer_agreement_ref?: string | null;
freshness_policy: Record<string, unknown>;
quality_policy: Record<string, unknown>;
approval_policy: Record<string, unknown>;
retention_policy: Record<string, unknown>;
known_limits: string[];
correction_procedure_ref?: string | null;
affected_refs: string[];
@@ -85,6 +87,8 @@ export type DatasourceMaterialization = {
frozen_label?: string | null;
source_timestamp?: string | null;
created_at?: string | null;
disposed_at?: string | null;
disposition: Record<string, unknown>;
provenance: Record<string, unknown>;
metadata: Record<string, unknown>;
governance: DatasourceGovernance;
@@ -142,6 +146,21 @@ export type DatasourceStage = {
row_count?: number | null;
byte_count?: number | null;
validation: DatasourceStageValidation;
approval: {
state?: "not_required" | "pending" | "approved" | "rejected" | "expired";
policy_hash?: string;
subject_digest?: string;
required_approvals?: number;
approval_count?: number;
expires_at?: string | null;
approvals?: Array<{
actor_ref?: string;
decision?: "approve" | "reject";
reason?: string;
decided_at?: string;
}>;
policy?: Record<string, unknown>;
};
created_at?: string | null;
promoted_at?: string | null;
promoted_materialization_ref?: string | null;
@@ -203,6 +222,38 @@ export type DatasourcePreview = {
}>;
};
export type DatasourceLifecycleEvidence = {
ref: string;
subject_ref: string;
event_type: string;
occurred_at: string;
actor_ref?: string | null;
policy_version?: string | null;
policy_hash?: string | null;
subject_digest: string;
previous_event_hash?: string | null;
event_hash: string;
details: Record<string, unknown>;
};
export type DatasourceRetentionCandidate = {
ref: string;
kind: "stage" | "materialization";
datasource_ref?: string | null;
disposition: "delete_stage" | "purge_materialization_payload";
eligible_at: string;
eligible: boolean;
blockers: string[];
policy_version: string;
policy_hash: string;
};
export type DatasourceRetentionPlan = {
as_of: string;
plan_hash: string;
candidates: DatasourceRetentionCandidate[];
};
export async function listDatasources(
settings: ApiSettings,
query = "",
@@ -293,6 +344,22 @@ export function promoteDatasourceStage(
});
}
export function decideDatasourceStage(
settings: ApiSettings,
stageRef: string,
payload: {
decision: "approve" | "reject";
reason: string;
expected_policy_hash: string;
expected_subject_digest: string;
}
): Promise<DatasourceStage> {
return apiFetch(settings, `/api/v1/datasources/stages/${refId(stageRef)}/decision`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function registerDatasourceOrigin(
settings: ApiSettings,
payload: {
@@ -319,6 +386,50 @@ export function refreshDatasource(
});
}
export function prepareDatasourceRefresh(
settings: ApiSettings,
datasourceRef: string
): Promise<DatasourceStage> {
return apiFetch(settings, `/api/v1/datasources/${refId(datasourceRef)}/refresh/stage`, {
method: "POST"
});
}
export async function listDatasourceLifecycleEvidence(
settings: ApiSettings,
subjectRef?: string
): Promise<DatasourceLifecycleEvidence[]> {
const params = new URLSearchParams();
if (subjectRef) params.set("subject_ref", subjectRef);
const suffix = params.size ? `?${params.toString()}` : "";
const response = await apiFetch<{ evidence: DatasourceLifecycleEvidence[] }>(
settings,
`/api/v1/datasources/lifecycle-evidence${suffix}`
);
return response.evidence;
}
export function previewDatasourceRetention(
settings: ApiSettings
): Promise<DatasourceRetentionPlan> {
return apiFetch(settings, "/api/v1/datasources/retention/plan");
}
export function applyDatasourceRetention(
settings: ApiSettings,
plan: DatasourceRetentionPlan,
targetRefs: string[]
): Promise<{ plan_hash: string; disposed_refs: string[]; evidence_hashes: string[] }> {
return apiFetch(settings, "/api/v1/datasources/retention/apply", {
method: "POST",
body: JSON.stringify({
as_of: plan.as_of,
plan_hash: plan.plan_hash,
target_refs: targetRefs
})
});
}
export function freezeDatasource(
settings: ApiSettings,
datasourceRef: string,
@@ -15,6 +15,7 @@ import {
Plus,
Snowflake,
ShieldCheck,
ShieldQuestion,
Trash2,
Upload
} from "lucide-react";
@@ -49,14 +50,18 @@ import { FormGrid, DialogSection, ActionToolbar,
type AuthInfo
} from "@govoplan/core-webui";
import {
applyDatasourceRetention,
createDatasourceStage,
decideDatasourceStage,
freezeDatasource,
listDatasourceMaterializations,
listDatasourceOrigins,
listDatasourceStages,
listDatasources,
previewDatasource,
previewDatasourceRetention,
promoteDatasourceStage,
prepareDatasourceRefresh,
refreshDatasource,
registerDatasourceOrigin,
retireDatasource,
@@ -66,6 +71,7 @@ import {
type DatasourceMaterialization,
type DatasourceOrigin,
type DatasourcePreview,
type DatasourceRetentionPlan,
type DatasourceSchemaChange,
type DatasourceStage,
type DatasourceValidationDiagnostic
@@ -113,12 +119,17 @@ export default function DatasourcesPage({
const [retireOpen, setRetireOpen] = useState(false);
const [promoteOpen, setPromoteOpen] = useState(false);
const [governanceOpen, setGovernanceOpen] = useState(false);
const [decisionOpen, setDecisionOpen] = useState(false);
const [retentionOpen, setRetentionOpen] = useState(false);
const { requestDiscard } = useUnsavedChanges();
const canManage = hasScope(auth, "datasources:source:write")
|| hasScope(auth, "datasources:source:admin");
const canStage = hasScope(auth, "datasources:stage:write")
|| hasScope(auth, "datasources:source:admin");
const canApprove = hasScope(auth, "datasources:stage:approve")
|| hasScope(auth, "datasources:source:admin");
const canAdmin = hasScope(auth, "datasources:source:admin");
const reload = useCallback(async (preferredDatasourceRef?: string) => {
setLoading(true);
@@ -228,6 +239,14 @@ export default function DatasourcesPage({
setWorking(true);
setError("");
try {
if (selectedDatasource.governance.approval_policy.required === true) {
const stage = await prepareDatasourceRefresh(settings, selectedDatasource.ref);
await reload(selectedDatasource.ref);
setSelectedStageRef(stage.ref);
setView("staging");
setSuccess(`Prepared refresh stage ${stage.name}. It must be approved before promotion.`);
return;
}
const result = await refreshDatasource(settings, selectedDatasource.ref);
setSuccess(`Refreshed ${result.datasource.name} as revision ${result.materialization.revision}.`);
await reload(result.datasource.ref);
@@ -425,7 +444,12 @@ export default function DatasourcesPage({
primaryActions={<>
{view === "catalogue" && selectedDatasource?.mode === "cached" ? (
<Button onClick={() => void refreshSelected()} disabled={!canManage || working} disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}>
<Download size={16} /> Refresh
<Download size={16} /> {selectedDatasource.governance.approval_policy.required === true ? "Stage refresh" : "Refresh"}
</Button>
) : null}
{view === "catalogue" && canAdmin ? (
<Button onClick={() => setRetentionOpen(true)} disabled={working}>
<Archive size={16} /> Retention
</Button>
) : null}
{view === "catalogue" && selectedDatasource ? (
@@ -452,6 +476,16 @@ export default function DatasourcesPage({
<Upload size={16} /> Promote
</Button>
) : null}
{view === "staging" && selectedStage?.state === "awaiting_approval" ? (
<Button
variant="primary"
onClick={() => setDecisionOpen(true)}
disabled={!canApprove || working}
disabledReason={working ? DATASOURCES_I18N.working : !canApprove ? "Datasource approval permission is required." : undefined}
>
<ShieldQuestion size={16} /> Decide
</Button>
) : null}
{view === "origins" && selectedOrigin ? (
<Button
variant="primary"
@@ -563,6 +597,28 @@ export default function DatasourcesPage({
await reload(updated.ref);
}}
/>
<StageDecisionDialog
open={decisionOpen}
settings={settings}
stage={selectedStage}
onClose={() => setDecisionOpen(false)}
onDecided={async (updated) => {
setDecisionOpen(false);
await reload();
setSelectedStageRef(updated.ref);
setSuccess(`Recorded ${updated.approval.state ?? "approval"} decision state for ${updated.name}.`);
}}
/>
<RetentionDialog
open={retentionOpen}
settings={settings}
onClose={() => setRetentionOpen(false)}
onApplied={async (count) => {
setRetentionOpen(false);
setSuccess(`Applied retention to ${count} eligible target${count === 1 ? "" : "s"}.`);
await reload(selectedDatasourceRef);
}}
/>
<Dialog
open={freezeOpen}
title="Freeze datasource state"
@@ -651,6 +707,8 @@ function DatasourceDetail({
<span><small>Steward</small><strong>{datasource.governance.steward_ref || "Not assigned"}</strong></span>
<span><small>Responsible organization</small><strong>{datasource.governance.responsible_organization_ref || "Not assigned"}</strong></span>
<span><small>Purposes</small><strong>{datasource.governance.purposes.join(", ") || "Not declared"}</strong></span>
<span><small>Approval gate</small><strong>{datasource.governance.approval_policy.required === true ? "Required" : "Not required"}</strong></span>
<span><small>Retention</small><strong>{datasource.governance.retention_policy.enabled === true ? "Configured" : "Disabled"}</strong></span>
</div>
{datasource.governance.semantic_definition ? (
<p className="datasources-dialog-copy">{datasource.governance.semantic_definition}</p>
@@ -692,7 +750,9 @@ function DatasourceDetail({
<td>{formatDate(item.created_at)}</td>
<td>{formatNumber(item.row_count)}</td>
<td>
{item.frozen_at ? (
{item.disposed_at ? (
<StatusBadge status="disposed" label="payload disposed" />
) : item.frozen_at ? (
<StatusBadge
status="frozen"
label={item.frozen_label || "frozen"}
@@ -773,6 +833,28 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
</div>
)}
</ContentSection>
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
<span><ShieldQuestion size={16} /> Promotion approval</span>
<StatusBadge status={stage.approval.state ?? "not_required"} label={readableToken(stage.approval.state ?? "not_required")} />
</ActionToolbar>
<div className="datasources-key-values">
<span><small>Progress</small><strong>{stage.approval.approval_count ?? 0} / {stage.approval.required_approvals ?? 1}</strong></span>
<span><small>Policy hash</small><strong>{shortFingerprint(stage.approval.policy_hash ?? "")}</strong></span>
<span><small>Subject digest</small><strong>{shortFingerprint(stage.approval.subject_digest ?? "")}</strong></span>
<span><small>Expires</small><strong>{formatDate(stage.approval.expires_at)}</strong></span>
</div>
{stage.approval.approvals?.length ? (
<ul className="datasources-diagnostic-list">
{stage.approval.approvals.map((item, index) => (
<li key={`${item.actor_ref ?? "actor"}-${index}`}>
<span>{item.actor_ref ?? "Unknown actor"} · {readableToken(item.decision ?? "decision")}</span>
<small>{item.reason ?? "No reason recorded"} · {formatDate(item.decided_at)}</small>
</li>
))}
</ul>
) : <div className="datasources-validation-ok">No approval decision has been recorded.</div>}
</ContentSection>
{schemaChanges.length ? (
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
@@ -797,6 +879,211 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
);
}
function StageDecisionDialog({
open,
settings,
stage,
onClose,
onDecided
}: {
open: boolean;
settings: ApiSettings;
stage: DatasourceStage | null;
onClose: () => void;
onDecided: (stage: DatasourceStage) => void | Promise<void>;
}) {
const [decision, setDecision] = useState<"approve" | "reject">("approve");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setDecision("approve");
setReason("");
setError("");
}, [open, stage?.ref]);
const submit = async () => {
if (!stage?.approval.policy_hash || !stage.approval.subject_digest) return;
setBusy(true);
setError("");
try {
const updated = await decideDatasourceStage(settings, stage.ref, {
decision,
reason,
expected_policy_hash: stage.approval.policy_hash,
expected_subject_digest: stage.approval.subject_digest
});
await onDecided(updated);
} catch (operationError) {
setError(apiErrorMessage(operationError));
} finally {
setBusy(false);
}
};
return (
<Dialog
open={open}
title="Decide datasource promotion"
onClose={() => !busy && onClose()}
footer={<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
variant={decision === "reject" ? "danger" : "primary"}
onClick={() => void submit()}
disabled={busy || reason.trim().length < 5 || !stage?.approval.policy_hash}
>
{decision === "approve" ? "Approve stage" : "Reject stage"}
</Button>
</>}
>
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<p className="datasources-dialog-copy">
The decision is bound to this exact stage, validation result, policy hash, and your account authority.
</p>
<div className="datasources-key-values">
<span><small>Stage</small><strong>{stage?.name ?? "Unavailable"}</strong></span>
<span><small>Approval progress</small><strong>{stage?.approval.approval_count ?? 0} / {stage?.approval.required_approvals ?? 1}</strong></span>
<span><small>Policy hash</small><strong>{shortFingerprint(stage?.approval.policy_hash ?? "")}</strong></span>
<span><small>Expires</small><strong>{formatDate(stage?.approval.expires_at)}</strong></span>
</div>
<SegmentedControl<"approve" | "reject">
ariaLabel="Approval decision"
width="fill"
size="equal"
options={[
{ id: "approve", label: "Approve" },
{ id: "reject", label: "Reject" }
]}
value={decision}
onChange={setDecision}
/>
<FormField label="Decision reason" interfaceId="datasources.action.approve" helpContextId="datasources.action.approve" helpModuleId="datasources">
<textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="Record what was reviewed and why this decision is justified." />
</FormField>
</Dialog>
);
}
function RetentionDialog({
open,
settings,
onClose,
onApplied
}: {
open: boolean;
settings: ApiSettings;
onClose: () => void;
onApplied: (count: number) => void | Promise<void>;
}) {
const [plan, setPlan] = useState<DatasourceRetentionPlan | null>(null);
const [selected, setSelected] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState("");
const loadPlan = useCallback(async () => {
setLoading(true);
setError("");
try {
const next = await previewDatasourceRetention(settings);
setPlan(next);
setSelected([]);
} catch (loadError) {
setError(apiErrorMessage(loadError));
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
if (open) void loadPlan();
}, [loadPlan, open]);
const apply = async () => {
if (!plan || !selected.length) return;
setBusy(true);
setError("");
try {
const result = await applyDatasourceRetention(settings, plan, selected);
setConfirming(false);
await onApplied(result.disposed_refs.length);
} catch (operationError) {
setConfirming(false);
setError(apiErrorMessage(operationError));
await loadPlan();
} finally {
setBusy(false);
}
};
const toggle = (ref: string) => setSelected((current) =>
current.includes(ref)
? current.filter((item) => item !== ref)
: [...current, ref]);
return <>
<Dialog
open={open}
title="Datasource retention preview"
size="wide"
onClose={() => !busy && onClose()}
footer={<>
<Button onClick={() => void loadPlan()} disabled={loading || busy}>Reload preview</Button>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy || loading || !selected.length}>
Apply selected dispositions
</Button>
</>}
>
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<p className="datasources-dialog-copy">
The preview is read-only. Legal holds, current materializations, pending approvals, and publication evidence remain blocked.
</p>
{plan ? (
<div className="datasources-key-values">
<span><small>As of</small><strong>{formatDate(plan.as_of)}</strong></span>
<span><small>Plan hash</small><strong>{shortFingerprint(plan.plan_hash)}</strong></span>
<span><small>Due targets</small><strong>{plan.candidates.length}</strong></span>
<span><small>Selected</small><strong>{selected.length}</strong></span>
</div>
) : null}
<LoadingFrame loading={loading}>
<div className="datasources-table-scroll">
<table>
<thead><tr><th className="datasources-retention-select-cell" aria-label="Select" /><th>Target</th><th>Disposition</th><th>Eligible since</th><th>Policy / blockers</th></tr></thead>
<tbody>
{plan?.candidates.map((candidate) => (
<tr key={candidate.ref}>
<td className="datasources-retention-select-cell"><input type="checkbox" aria-label={`Select ${candidate.ref}`} checked={selected.includes(candidate.ref)} disabled={!candidate.eligible} onChange={() => toggle(candidate.ref)} /></td>
<td><strong>{candidate.ref}</strong><small>{candidate.datasource_ref ?? "Unpromoted stage"}</small></td>
<td>{readableToken(candidate.disposition)}</td>
<td>{formatDate(candidate.eligible_at)}</td>
<td>{candidate.blockers.length ? candidate.blockers.map(readableToken).join(", ") : `v${candidate.policy_version} · eligible`}</td>
</tr>
))}
{!plan?.candidates.length ? <tr><td colSpan={5}>No retention targets are due.</td></tr> : null}
</tbody>
</table>
</div>
</LoadingFrame>
</Dialog>
<ConfirmDialog
open={confirming}
title="Apply datasource retention"
message={`Dispose ${selected.length} selected target${selected.length === 1 ? "" : "s"}? Payload rows or transient stages will be removed; lifecycle evidence remains.`}
confirmLabel="Apply retention"
tone="danger"
busy={busy}
onCancel={() => setConfirming(false)}
onConfirm={() => void apply()}
/>
</>;
}
function ValidationDiagnosticList({
diagnostics
}: {
@@ -900,6 +1187,8 @@ function GovernanceDialog({
const [freshness, setFreshness] = useState("{}");
const [quality, setQuality] = useState("{}");
const [visibility, setVisibility] = useState("{}");
const [approval, setApproval] = useState("{}");
const [retention, setRetention] = useState("{}");
const [baselineKey, setBaselineKey] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -911,15 +1200,19 @@ function GovernanceDialog({
const nextFreshness = JSON.stringify(datasource.governance.freshness_policy, null, 2);
const nextQuality = JSON.stringify(datasource.governance.quality_policy, null, 2);
const nextVisibility = JSON.stringify(datasource.governance.visibility_policy ?? {}, null, 2);
const nextApproval = JSON.stringify(datasource.governance.approval_policy ?? {}, null, 2);
const nextRetention = JSON.stringify(datasource.governance.retention_policy ?? {}, null, 2);
setDraft(nextDraft);
setFreshness(nextFreshness);
setQuality(nextQuality);
setVisibility(nextVisibility);
setBaselineKey(JSON.stringify({ draft: nextDraft, freshness: nextFreshness, quality: nextQuality, visibility: nextVisibility }));
setApproval(nextApproval);
setRetention(nextRetention);
setBaselineKey(JSON.stringify({ draft: nextDraft, freshness: nextFreshness, quality: nextQuality, visibility: nextVisibility, approval: nextApproval, retention: nextRetention }));
setError("");
}, [datasource, open]);
const dirty = Boolean(open && draft && JSON.stringify({ draft, freshness, quality, visibility }) !== baselineKey);
const dirty = Boolean(open && draft && JSON.stringify({ draft, freshness, quality, visibility, approval, retention }) !== baselineKey);
const save = async (): Promise<boolean> => {
if (!datasource || !draft) return false;
@@ -930,7 +1223,9 @@ function GovernanceDialog({
...draft,
freshness_policy: parseObject(freshness, "Freshness policy"),
quality_policy: parseObject(quality, "Quality policy"),
visibility_policy: parseObject(visibility, "Visibility policy")
visibility_policy: parseObject(visibility, "Visibility policy"),
approval_policy: parseObject(approval, "Approval policy"),
retention_policy: parseObject(retention, "Retention policy")
});
await onSaved(updated);
return true;
@@ -1058,6 +1353,14 @@ function GovernanceDialog({
<FormField label="Quality policy (JSON)" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<textarea value={quality} onChange={(event) => setQuality(event.target.value)} spellCheck={false} />
</FormField>
<FormField label="Approval policy (JSON)" interfaceId="datasources.field.approval-policy" helpContextId="datasources.field.approval-policy" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<textarea value={approval} onChange={(event) => setApproval(event.target.value)} spellCheck={false} />
<small>Configure a version, required approvals, separation of duties, and optional expiry.</small>
</FormField>
<FormField label="Retention policy (JSON)" interfaceId="datasources.field.retention-policy-contract" helpContextId="datasources.field.retention-policy-contract" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<textarea value={retention} onChange={(event) => setRetention(event.target.value)} spellCheck={false} />
<small>Configure explicit stage, materialization, and frozen-evidence durations; execution always starts with a preview.</small>
</FormField>
</FormGrid>
</DialogSection>
) : null}
+11
View File
@@ -287,6 +287,17 @@
max-height: min(860px, calc(100vh - 32px));
}
.datasources-retention-select-cell {
width: 2.5rem;
text-align: center;
}
.datasources-table-scroll td small {
display: block;
margin-top: 0.2rem;
color: var(--color-text-muted);
}
.datasources-governance-dialog .datasources-dialog-fields textarea {
min-height: 76px;
}