Migrate Policy retention interfaces

This commit is contained in:
2026-08-03 10:22:46 +02:00
parent 344e15dea4
commit f964ed7dc0
8 changed files with 198 additions and 8 deletions
+5
View File
@@ -44,3 +44,8 @@ closed. The shared privacy-retention run calls the optional
`reporting.retention` capability to clear expired provider-report payloads
without importing Reporting models, while Reporting keeps hashes and bounded
provenance as audit evidence.
The retention administration interface follows the platform pattern language
documented in [docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
It uses Core's effective-policy editor and renders system retention runs as
typed outcome evidence rather than raw JSON.
+19
View File
@@ -0,0 +1,19 @@
# Policy Interface Pattern Migration
Policy contributes four retention-administration sections to the Access-owned
administration host. It does not own an independent route or shell.
| Surface | Archetype | Consequence and provenance | Evidence |
| --- | --- | --- | --- |
| System retention | Effective-policy editor plus destructive operation | Typed values show their effective source path. Applying retention can irreversibly redact or delete eligible content and therefore requires explicit confirmation; dry-run and applied outcomes are distinguished. | `RetentionPoliciesPanel.tsx`, Core `RetentionPolicyScopeManager`, interface-pattern structural test |
| Tenant retention | Effective-policy editor | A tenant may only narrow fields that system policy allows. Read-only authority and parent locks are explicit. | Core policy-source and blocker components |
| Group retention | Targeted effective-policy editor | Group selection is loaded through bounded delta requests; missing target, parent lock, and write authority remain distinct states. | Target loader plus Core retention editor |
| User retention | Targeted effective-policy editor | User labels expose only authorized account metadata; retained data itself is never returned by this administration surface. | Target loader plus Core retention editor |
The retention execution result is a typed, filterable outcome table. Raw JSON
is neither the primary policy editor nor the operator result view. The backend
remains authoritative for policy validation, narrowing rules, destructive
effects, redaction, and audit evidence.
Contextual help uses `policy.retention` and `privacy.retention`, which resolve
through the optional Docs module or the hosted documentation fallback.
+7
View File
@@ -120,6 +120,13 @@ System: Allow
When a parent disallows lower-level limits or changes, the UI should disable
the affected controls and avoid sending those fields in the save payload.
System retention execution is a separate high-consequence operation. The UI
must distinguish dry-run evidence from an applied run, render bounded counts as
typed rows rather than raw JSON, explain missing write authority, and require a
destructive confirmation that names deletion/redaction and recovery
expectations. The backend remains authoritative and records the mode plus
bounded outcome counts in audit evidence.
The shared core WebUI helper `PolicySourcePath` renders the source path shape
for module UIs. Modules may use their own field layout, but the data contract
should remain this shape.
+18 -1
View File
@@ -161,7 +161,24 @@ manifest = ModuleManifest(
"scheduling",
"reporting",
),
metadata={"kind": "reference"},
metadata={
"kind": "workflow",
"route": "/admin?section=system-retention",
"screen": "Retention administration",
"help_contexts": ["policy.retention", "privacy.retention"],
"prerequisites": [
"Policy and Access are enabled.",
"The actor may read policy settings at the selected scope.",
],
"steps": [
"Inspect the effective value and its policy source path.",
"Narrow only fields that the parent policy permits this scope to override.",
"Save the policy, then run a system dry run before applying retention.",
"Verify bounded outcome and audit evidence after an applied run.",
],
"outcome": "The selected scope has an explainable retention policy and any destructive application is preceded by a dry-run review.",
"verification": "Reload the policy, confirm its source path, and compare the dry-run or applied outcome table with audit evidence.",
},
),
),
migration_spec=MigrationSpec(
+14
View File
@@ -58,6 +58,20 @@ class PolicyModuleContractTests(unittest.TestCase):
set(manifest.capability_factories),
)
def test_retention_documentation_exposes_stable_help_contexts(self) -> None:
topic = next(
item
for item in manifest.documentation
if item.id == "policy.hierarchy-overrides-and-retention"
)
self.assertEqual(
["policy.retention", "privacy.retention"],
topic.metadata["help_contexts"],
)
self.assertEqual("workflow", topic.metadata["kind"])
self.assertIn("/admin", topic.metadata["route"])
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -12,6 +12,9 @@
"import": "./src/index.ts"
}
},
"scripts": {
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const panel = readFileSync(resolve(webuiRoot, "src/features/policy/RetentionPoliciesPanel.tsx"), "utf8");
assert.match(panel, /<RetentionPolicyScopeManager/, "Policy delegates effective-policy blockers and provenance to the shared Core contract");
assert.match(panel, /contextId: "policy\.retention"/, "retention exposes stable contextual documentation");
assert.match(panel, /disabledReason=\{actionDisabledReason\}/, "retention execution explains unavailable actions");
assert.match(panel, /<ConfirmDialog/, "destructive retention uses shared confirmation");
assert.match(panel, /<DataGrid/, "retention outcomes use the shared data-grid pattern");
assert.doesNotMatch(panel, /admin-json-preview/, "retention outcome is not presented as raw JSON");
assert.doesNotMatch(panel, /<pre/, "retention outcome is a typed projection");
console.log("Policy interface-pattern contracts passed.");
@@ -5,16 +5,21 @@ import {
Button,
Card,
ConfirmDialog,
DataGrid,
DocumentationHelpLink,
mergeDeltaRows,
RetentionPolicyScopeManager,
runRetentionPolicy,
StatusBadge,
useDeltaWatermarks,
type ApiSettings,
type DataGridColumn,
type DeltaDeletedItem,
type PrivacyRetentionPolicyScope,
type RetentionPolicyTargetOption,
type RetentionRunResponse
} from "@govoplan/core-webui";
import { RefreshCw } from "lucide-react";
import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets";
type Props = {
@@ -30,6 +35,28 @@ type DeltaResponse = {
full: boolean;
};
interface RetentionCountTree {
[key: string]: number | RetentionCountTree;
}
type RetentionCountRow = {
id: string;
area: string;
measure: string;
count: number;
};
const RETENTION_DOCUMENTATION = {
contextId: "policy.retention",
documentationType: "admin" as const
};
const RETENTION_RESULT_COLUMNS: DataGridColumn<RetentionCountRow>[] = [
{ id: "area", header: "Area", value: (row) => row.area, width: "minmax(180px, 1fr)", filterType: "list", sortable: true },
{ id: "measure", header: "Outcome", value: (row) => row.measure, width: "minmax(220px, 1.4fr)", filterType: "text", sortable: true },
{ id: "count", header: "Records", value: (row) => row.count, width: "120px", align: "right", sortable: true }
];
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
system: {
title: "System retention",
@@ -151,10 +178,38 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
}
const labels = copy[scopeType];
const resultRows = flattenRetentionCounts(retentionResult?.result.counts);
const actionDisabledReason = busy
? "A retention operation is already running."
: !canWrite
? "Your account may inspect retention policy but cannot run retention operations."
: undefined;
return (
<>
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
<AdminPageLayout
title={labels.title}
description={labels.description}
loading={loadingTargets}
error={targetError || runError}
success={success}
actions={
<>
{(scopeType === "user" || scopeType === "group") && (
<Button
title="Reload policy targets"
aria-label="Reload policy targets"
onClick={() => void loadTargets()}
disabled={loadingTargets}
disabledReason={loadingTargets ? "Policy targets are already loading." : undefined}
>
<RefreshCw size={16} />
</Button>
)}
<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
</>
}
>
<RetentionPolicyScopeManager
settings={settings}
scopeType={scopeType}
@@ -166,22 +221,46 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
/>
{scopeType === "system" && (
<div className="retention-run-card">
<Card title="Retention execution">
<div className="retention-run-section">
<Card
title="Retention execution"
actions={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
>
<p className="muted small-note">Run the saved effective retention policy against retained platform data.</p>
<div className="button-row compact-actions subsection-bottom-actions">
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
<Button onClick={() => void runRetention(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Dry run</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Apply retention</Button>
</div>
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
</Card>
{retentionResult && (
<Card title="Latest retention outcome">
<dl className="detail-list compact-detail-list">
<div>
<dt>Operation</dt>
<dd><StatusBadge status={retentionResult.result.dry_run ? "info" : "success"} label={retentionResult.result.dry_run ? "Dry run" : "Applied"} /></dd>
</div>
<div><dt>Policy scope</dt><dd>{humanize(retentionResult.result.effective_policy_scope || "system")}</dd></div>
<div><dt>Reported outcomes</dt><dd>{resultRows.length}</dd></div>
</dl>
<div className="admin-table-surface">
<DataGrid
id="policy-retention-outcomes"
rows={resultRows}
columns={RETENTION_RESULT_COLUMNS}
initialFit="container"
getRowKey={(row) => row.id}
emptyText="No retained records currently match the effective policy."
/>
</div>
</Card>
)}
</div>
)}
</AdminPageLayout>
<ConfirmDialog
open={confirmRetentionRun}
title="Apply retention policy"
message="This will redact or delete eligible retained data according to the saved policy."
message="This will redact or delete eligible retained data according to the saved policy. The application cannot restore deleted content; the run and bounded outcome counts remain in audit evidence. Run a dry run first and verify recovery evidence before continuing."
confirmLabel="Apply retention"
tone="danger"
busy={busy}
@@ -192,6 +271,35 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
);
}
function flattenRetentionCounts(
counts: RetentionRunResponse["result"]["counts"] | undefined
): RetentionCountRow[] {
const rows: RetentionCountRow[] = [];
const visit = (value: number | RetentionCountTree, path: string[]) => {
if (typeof value === "number") {
const [area = "retention", ...measureParts] = path;
rows.push({
id: path.join("."),
area: humanize(area),
measure: humanize(measureParts.join(" ") || "records"),
count: value
});
return;
}
for (const [key, child] of Object.entries(value)) visit(child, [...path, key]);
};
if (counts) visit(counts as unknown as RetentionCountTree, []);
return rows;
}
function humanize(value: string): string {
return value
.split(/[._\s-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
async function loadDeltaRows<TItem, TResponse extends DeltaResponse>(
current: TItem[],
key: string,