From f964ed7dc0bc51f9b98476fb6dbe5a20fed23b4c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 10:22:46 +0200 Subject: [PATCH] Migrate Policy retention interfaces --- README.md | 5 + docs/INTERFACE_PATTERN_MIGRATION.md | 19 +++ docs/POLICY_DECISION_PROVENANCE.md | 7 + src/govoplan_policy/backend/manifest.py | 19 ++- tests/test_policy_module_contract.py | 14 ++ webui/package.json | 3 + .../test-interface-pattern-language.mjs | 17 +++ .../policy/RetentionPoliciesPanel.tsx | 122 +++++++++++++++++- 8 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 docs/INTERFACE_PATTERN_MIGRATION.md create mode 100644 webui/scripts/test-interface-pattern-language.mjs diff --git a/README.md b/README.md index b2ccfb8..2559fe1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..72660e3 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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. diff --git a/docs/POLICY_DECISION_PROVENANCE.md b/docs/POLICY_DECISION_PROVENANCE.md index 9daa603..ce834ae 100644 --- a/docs/POLICY_DECISION_PROVENANCE.md +++ b/docs/POLICY_DECISION_PROVENANCE.md @@ -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. diff --git a/src/govoplan_policy/backend/manifest.py b/src/govoplan_policy/backend/manifest.py index 5227e66..01454f0 100644 --- a/src/govoplan_policy/backend/manifest.py +++ b/src/govoplan_policy/backend/manifest.py @@ -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( diff --git a/tests/test_policy_module_contract.py b/tests/test_policy_module_contract.py index 48a3fbf..49a89e9 100644 --- a/tests/test_policy_module_contract.py +++ b/tests/test_policy_module_contract.py @@ -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() diff --git a/webui/package.json b/webui/package.json index 218f5c0..5949bea 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs new file mode 100644 index 0000000..d6a700f --- /dev/null +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -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, /[] = [ + { 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 = { 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 ( <> - + + {(scopeType === "user" || scopeType === "group") && ( + + )} + + + } + > {scopeType === "system" && ( -
- +
+ } + >

Run the saved effective retention policy against retained platform data.

- - + +
- {retentionResult &&
{JSON.stringify(retentionResult.result, null, 2)}
}
+ {retentionResult && ( + +
+
+
Operation
+
+
+
Policy scope
{humanize(retentionResult.result.effective_policy_scope || "system")}
+
Reported outcomes
{resultRows.length}
+
+
+ row.id} + emptyText="No retained records currently match the effective policy." + /> +
+
+ )}
)} { + 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( current: TItem[], key: string,