From 8dde4262ec73f5c866e5b6a2465b0a1709c0e60f Mon Sep 17 00:00:00 2001
From: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date: Wed, 10 Jun 2026 15:57:08 +0100
Subject: [PATCH] feat(policies): backend-driven policy enforcement (frontend)
(#6598)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Adds the **Policies** feature (proprietary, behind the
`POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed
tool pipeline on documents, docked in the right tool sidebar alongside
Tools.
## Highlights
- **Policy catalog** — 5 categories; **Security** is wired (redact PII +
sanitize), the others are marked "Coming soon".
- **Backend as source of truth** — policies persist via the Policies
engine (`/api/v1/policies`), one policy per category, with a local cache
+ offline fallback.
- **Auto-run** — enabled policies run on every uploaded file: dispatch →
poll → import outputs into the workspace.
- **Security redact config** — PII preset dropdown + custom word/regex
entry + advanced options; tool params map to the backend endpoint
fields.
- **Activity feed** with retry on failures; **file badges** showing
which policies ran on a file (sidebar + files page), tinted to the
policy colour.
- Reuses the **Watched Folders** engine for each policy's backing
folder; policy-owned folders are filtered out of the Watched Folders UI.
## Notes
- Gated by `POLICIES_ENABLED` (true in proprietary, false in core) —
unreachable in the open-source build.
- Frontend-only diff; depends on the backend Policies engine and the
merged Watched Folders feature.
---
frontend/.storybook/main.ts | 22 +-
.../public/locales/en-GB/translation.toml | 3 +
.../core/components/filesPage/FileGrid.tsx | 29 +
.../core/components/filesPage/FilesPage.css | 18 +
.../components/policies/PoliciesSidebar.tsx | 38 ++
.../policies/PolicyAutoRunController.tsx | 11 +
.../core/components/shared/FileSidebar.tsx | 5 +
.../components/shared/FileSidebarFileItem.css | 19 +
.../components/shared/FileSidebarFileItem.tsx | 32 +
.../shared/RainbowThemeProvider.tsx | 16 +-
.../core/components/tools/RightSidebar.tsx | 231 ++++---
.../src/core/components/tools/ToolPanel.css | 23 +
.../editor/src/core/constants/featureFlags.ts | 15 +-
.../src/core/hooks/useAllWatchedFolders.ts | 4 +-
.../src/core/hooks/usePolicyFileBadges.ts | 10 +
.../src/core/tests/helpers/api-stubs.ts | 8 +
.../editor/src/core/types/watchedFolders.ts | 3 +
.../components/policies/Policies.css | 438 +++++++++++++
.../components/policies/Policies.stories.tsx | 163 +++++
.../policies/PoliciesSidebar.test.tsx | 152 +++++
.../components/policies/PoliciesSidebar.tsx | 453 +++++++++++++
.../policies/PolicyAutoRunController.tsx | 11 +
.../policies/PolicyDeleteConfirmModal.tsx | 46 ++
.../components/policies/PolicyDetailPanel.tsx | 279 ++++++++
.../components/policies/PolicyFieldRow.tsx | 88 +++
.../components/policies/PolicyPiiField.tsx | 61 ++
.../policies/PolicyRedactConfig.tsx | 128 ++++
.../components/policies/PolicySetupWizard.tsx | 602 ++++++++++++++++++
.../components/policies/PolicyToolConfig.tsx | 96 +++
.../policies/PolicyToolConfigStep.tsx | 114 ++++
.../policies/PolicyWorkflowStep.tsx | 61 ++
.../proprietary/components/policies/README.md | 86 +++
.../policies/policyRunStore.test.ts | 81 +++
.../components/policies/policyRunStore.ts | 155 +++++
.../policies/policySelectionStore.ts | 71 +++
.../components/policies/policyStatus.ts | 27 +
.../components/policies/policyToolChains.ts | 18 +
.../components/policies/policyValues.ts | 16 +
.../components/policies/usePolicyAutoRun.ts | 205 ++++++
.../src/proprietary/constants/featureFlags.ts | 17 +-
.../data/policyDefinitions.test.ts | 23 +
.../proprietary/data/policyDefinitions.tsx | 365 +++++++++++
.../src/proprietary/hooks/usePolicies.test.ts | 155 +++++
.../src/proprietary/hooks/usePolicies.ts | 282 ++++++++
.../src/proprietary/hooks/usePolicyCatalog.ts | 16 +
.../proprietary/hooks/usePolicyFileBadges.ts | 43 ++
.../proprietary/hooks/useWatchedFolders.ts | 3 +-
.../src/proprietary/services/policyApi.ts | 99 +++
.../src/proprietary/services/policyBackend.ts | 95 +++
.../src/proprietary/services/policyCatalog.ts | 43 ++
.../services/policyFolders.test.ts | 85 +++
.../src/proprietary/services/policyFolders.ts | 137 ++++
.../services/policyLiveData.test.ts | 97 +++
.../proprietary/services/policyLiveData.ts | 100 +++
.../services/policyPipeline.test.ts | 157 +++++
.../proprietary/services/policyPipeline.ts | 291 +++++++++
.../services/policyStorage.test.ts | 72 +++
.../src/proprietary/services/policyStorage.ts | 111 ++++
.../editor/src/proprietary/types/policies.ts | 211 ++++++
frontend/editor/vite.config.ts | 9 +
frontend/editor/vitest.config.ts | 21 +
frontend/shared/components/Checkbox.css | 6 +
frontend/shared/components/Checkbox.tsx | 9 +-
frontend/shared/components/ChipFlow.css | 12 +
.../shared/components/ChipFlow.stories.tsx | 25 +
frontend/shared/components/ChipFlow.tsx | 47 ++
frontend/shared/components/DataRow.css | 23 +
.../shared/components/DataRow.stories.tsx | 38 ++
frontend/shared/components/DataRow.tsx | 41 ++
frontend/shared/components/IconBadge.css | 35 +
.../shared/components/IconBadge.stories.tsx | 42 ++
frontend/shared/components/IconBadge.tsx | 40 ++
frontend/shared/components/ListRow.css | 88 +++
.../shared/components/ListRow.stories.tsx | 80 +++
frontend/shared/components/ListRow.tsx | 79 +++
frontend/shared/components/MetricCard.css | 12 +
frontend/shared/components/MetricCard.tsx | 4 +
frontend/shared/components/NavItem.css | 45 ++
frontend/shared/components/NavItem.tsx | 18 +-
frontend/shared/components/PanelHeader.css | 16 +-
frontend/shared/components/PanelHeader.tsx | 15 +-
frontend/shared/components/SectionHeader.css | 33 +
.../components/SectionHeader.stories.tsx | 36 ++
frontend/shared/components/SectionHeader.tsx | 66 ++
frontend/shared/components/Select.css | 19 +
frontend/shared/components/SettingsRow.css | 30 +
.../shared/components/SettingsRow.stories.tsx | 82 +++
frontend/shared/components/SettingsRow.tsx | 39 ++
frontend/shared/components/StepIndicator.css | 27 +
.../components/StepIndicator.stories.tsx | 29 +
frontend/shared/components/StepIndicator.tsx | 43 ++
91 files changed, 7250 insertions(+), 99 deletions(-)
create mode 100644 frontend/editor/src/core/components/policies/PoliciesSidebar.tsx
create mode 100644 frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx
create mode 100644 frontend/editor/src/core/hooks/usePolicyFileBadges.ts
create mode 100644 frontend/editor/src/proprietary/components/policies/Policies.css
create mode 100644 frontend/editor/src/proprietary/components/policies/Policies.stories.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx
create mode 100644 frontend/editor/src/proprietary/components/policies/README.md
create mode 100644 frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts
create mode 100644 frontend/editor/src/proprietary/components/policies/policyRunStore.ts
create mode 100644 frontend/editor/src/proprietary/components/policies/policySelectionStore.ts
create mode 100644 frontend/editor/src/proprietary/components/policies/policyStatus.ts
create mode 100644 frontend/editor/src/proprietary/components/policies/policyToolChains.ts
create mode 100644 frontend/editor/src/proprietary/components/policies/policyValues.ts
create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
create mode 100644 frontend/editor/src/proprietary/data/policyDefinitions.test.ts
create mode 100644 frontend/editor/src/proprietary/data/policyDefinitions.tsx
create mode 100644 frontend/editor/src/proprietary/hooks/usePolicies.test.ts
create mode 100644 frontend/editor/src/proprietary/hooks/usePolicies.ts
create mode 100644 frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts
create mode 100644 frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts
create mode 100644 frontend/editor/src/proprietary/services/policyApi.ts
create mode 100644 frontend/editor/src/proprietary/services/policyBackend.ts
create mode 100644 frontend/editor/src/proprietary/services/policyCatalog.ts
create mode 100644 frontend/editor/src/proprietary/services/policyFolders.test.ts
create mode 100644 frontend/editor/src/proprietary/services/policyFolders.ts
create mode 100644 frontend/editor/src/proprietary/services/policyLiveData.test.ts
create mode 100644 frontend/editor/src/proprietary/services/policyLiveData.ts
create mode 100644 frontend/editor/src/proprietary/services/policyPipeline.test.ts
create mode 100644 frontend/editor/src/proprietary/services/policyPipeline.ts
create mode 100644 frontend/editor/src/proprietary/services/policyStorage.test.ts
create mode 100644 frontend/editor/src/proprietary/services/policyStorage.ts
create mode 100644 frontend/editor/src/proprietary/types/policies.ts
create mode 100644 frontend/shared/components/ChipFlow.css
create mode 100644 frontend/shared/components/ChipFlow.stories.tsx
create mode 100644 frontend/shared/components/ChipFlow.tsx
create mode 100644 frontend/shared/components/DataRow.css
create mode 100644 frontend/shared/components/DataRow.stories.tsx
create mode 100644 frontend/shared/components/DataRow.tsx
create mode 100644 frontend/shared/components/IconBadge.css
create mode 100644 frontend/shared/components/IconBadge.stories.tsx
create mode 100644 frontend/shared/components/IconBadge.tsx
create mode 100644 frontend/shared/components/ListRow.css
create mode 100644 frontend/shared/components/ListRow.stories.tsx
create mode 100644 frontend/shared/components/ListRow.tsx
create mode 100644 frontend/shared/components/SectionHeader.css
create mode 100644 frontend/shared/components/SectionHeader.stories.tsx
create mode 100644 frontend/shared/components/SectionHeader.tsx
create mode 100644 frontend/shared/components/SettingsRow.css
create mode 100644 frontend/shared/components/SettingsRow.stories.tsx
create mode 100644 frontend/shared/components/SettingsRow.tsx
create mode 100644 frontend/shared/components/StepIndicator.css
create mode 100644 frontend/shared/components/StepIndicator.stories.tsx
create mode 100644 frontend/shared/components/StepIndicator.tsx
diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
index 7a53d8e9c..0797a367a 100644
--- a/frontend/.storybook/main.ts
+++ b/frontend/.storybook/main.ts
@@ -1,12 +1,14 @@
import { resolve } from "node:path";
import type { StorybookConfig } from "@storybook/react-vite";
+import tsconfigPaths from "vite-tsconfig-paths";
/**
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
* addon list is just the extras we want: theme switching + a11y auditing.
*
- * Story files live next to their components in shared/ and portal/src/.
- * MDX docs pages live in portal/src/docs/.
+ * Story files live next to their components in shared/, portal/src/, and
+ * editor/src/ — the design system is shared by BOTH apps, so both surface
+ * their stories here. MDX docs pages live in portal/src/docs/.
*/
const config: StorybookConfig = {
stories: [
@@ -14,6 +16,7 @@ const config: StorybookConfig = {
"../portal/src/**/*.stories.@(ts|tsx)",
"../shared/**/*.mdx",
"../shared/**/*.stories.@(ts|tsx)",
+ "../editor/src/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
framework: {
@@ -28,13 +31,26 @@ const config: StorybookConfig = {
staticDirs: ["../portal/public"],
viteFinal: async (config) => {
// Wire @portal/* and @shared/* aliases directly on the Storybook bundler so
- // story imports resolve without needing the portal's vite config.
+ // portal story imports resolve without needing the portal's vite config.
config.resolve = config.resolve ?? {};
config.resolve.alias = {
...(config.resolve.alias ?? {}),
"@portal": resolve(__dirname, "../portal/src"),
"@shared": resolve(__dirname, "../shared"),
};
+ // Editor stories import via @app/* (proprietary→core fallback), @core/* and
+ // @proprietary/*. Resolve them exactly the way the editor's own build does —
+ // through vite-tsconfig-paths against the proprietary vite tsconfig — so the
+ // shared Storybook can host editor components without duplicating the alias
+ // map here.
+ config.plugins = config.plugins ?? [];
+ config.plugins.push(
+ tsconfigPaths({
+ projects: [
+ resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
+ ],
+ }),
+ );
return config;
},
};
diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml
index 388933960..d06fd55e4 100644
--- a/frontend/editor/public/locales/en-GB/translation.toml
+++ b/frontend/editor/public/locales/en-GB/translation.toml
@@ -8193,6 +8193,9 @@ confirm = "Extract"
message = "This ZIP contains {{count}} files. Extract anyway?"
title = "Large ZIP File"
+[policies]
+sidebarTitle = "Policies"
+
[watchedFolders]
alreadyInFolder = "Already in this folder"
deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected."
diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx
index 6249565ff..46644b1b7 100644
--- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx
+++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx
@@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
import MoreVertIcon from "@mui/icons-material/MoreVert";
+import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
@@ -17,6 +18,7 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import { FileId } from "@app/types/file";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
import { useFolders } from "@app/contexts/FolderContext";
+import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import { StirlingFileStub } from "@app/types/fileContext";
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
import {
@@ -572,6 +574,31 @@ function FolderCard({
);
}
+/** Shield badges for the policies that have run on a file. */
+function PolicyBadges({ fileId }: { fileId: string }) {
+ const badges = usePolicyFileBadges().get(fileId) ?? [];
+ if (badges.length === 0) return null;
+ return (
+
+ {badges.slice(0, 3).map((policy) => (
+
+
+
+
+
+ ))}
+
+ );
+}
+
interface FileCardProps {
file: StirlingFileStub;
isSelected: boolean;
@@ -731,6 +758,7 @@ function FileCard({
{fileSize}
·
{fileDate}
+
@@ -1315,6 +1343,7 @@ function FileRow({
)}
+
{isInWorkspace && (
diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css
index 9b3b7b206..f1e0e3db0 100644
--- a/frontend/editor/src/core/components/filesPage/FilesPage.css
+++ b/frontend/editor/src/core/components/filesPage/FilesPage.css
@@ -484,6 +484,24 @@
gap: 0.4rem;
}
+/* Policy activity badges (a shield per policy that has run on the file). */
+.files-page-policy-badges {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ flex-shrink: 0;
+}
+.files-page-policy-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 15px;
+ height: 15px;
+ border-radius: 4px;
+ /* `color` set inline to the policy accent; tint follows it. */
+ background: color-mix(in srgb, currentColor 16%, transparent);
+}
+
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
the user can tell which folder each hit lives in without navigating. */
.files-page-card-path {
diff --git a/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx
new file mode 100644
index 000000000..d787c6435
--- /dev/null
+++ b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx
@@ -0,0 +1,38 @@
+/**
+ * Core stubs for the right-rail Policies UI.
+ *
+ * The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx}
+ * and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
+ * build is active. Core builds render nothing, so the right rail shows only the
+ * tool list unchanged.
+ */
+
+import type { ReactNode } from "react";
+
+/** Whether the right rail should host the Policies section. False in core. */
+export function usePoliciesEnabled(): boolean {
+ return false;
+}
+
+/**
+ * Whether a policy is open (its detail should take over the rail). Always false
+ * in core; proprietary bridges to the policy-selection store.
+ */
+export function usePolicyDetailActive(): boolean {
+ return false;
+}
+
+/** Collapsible policy list rendered above the Tools section. Null in core. */
+export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) {
+ return null;
+}
+
+/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */
+export function PolicyDetailTakeover() {
+ return null;
+}
+
+/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */
+export function PoliciesCollapsedButton(_props: { onExpand: () => void }) {
+ return null;
+}
diff --git a/frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx b/frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx
new file mode 100644
index 000000000..b34fadc7a
--- /dev/null
+++ b/frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx
@@ -0,0 +1,11 @@
+/**
+ * Core stub for the policy auto-run controller.
+ *
+ * The real implementation lives in
+ * {@code proprietary/components/policies/PolicyAutoRunController.tsx} and shadows
+ * this stub via the {@code @app/*} alias cascade in the proprietary build. Core
+ * builds have no Policies feature, so this renders nothing and runs no policies.
+ */
+export function PolicyAutoRunController() {
+ return null;
+}
diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx
index fb02ebf09..ed4e6e75d 100644
--- a/frontend/editor/src/core/components/shared/FileSidebar.tsx
+++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx
@@ -42,6 +42,7 @@ import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import { useFolderMembership } from "@app/hooks/useFolderMembership";
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
+import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import {
setWatchedFolderDraggedFileIds,
clearWatchedFolderDraggedFileIds,
@@ -131,6 +132,7 @@ const FileSidebar = forwardRef(
// the workbench). The same map drives the per-file membership dots.
const folderMembership = useFolderMembership();
const allFolders = useAllWatchedFolders();
+ const policyFileBadges = usePolicyFileBadges();
const folderById = useMemo(
() => new Map(allFolders.map((f) => [f.id, f])),
[allFolders],
@@ -892,6 +894,9 @@ const FileSidebar = forwardRef(
onDragStart={handleWatchedFolderDragStart}
folders={memberFolders}
onFolderClick={openWatchedFolder}
+ policies={
+ policyFileBadges.get(stub.id as string) ?? []
+ }
/>
);
})}
diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css
index 3529f0165..81ca24ae0 100644
--- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css
+++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css
@@ -134,6 +134,25 @@
text-overflow: ellipsis;
}
+/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */
+.file-sidebar-policy-badges {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ flex-shrink: 0;
+}
+.file-sidebar-policy-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 15px;
+ height: 15px;
+ border-radius: 4px;
+ /* `color` is set inline to the policy's accent; the tint follows it. */
+ color: var(--text-secondary);
+ background: color-mix(in srgb, currentColor 16%, transparent);
+}
+
/* ---- Folder membership tags ---- */
.file-sidebar-folder-tags {
display: flex;
diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx
index c03930864..1b2f566f1 100644
--- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx
+++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx
@@ -4,6 +4,7 @@ import { Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
+import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
@@ -125,6 +126,14 @@ export interface FileItemFolderRef {
accentColor: string;
}
+/** A policy that has run on this file, used for the activity badges. */
+export interface FileItemPolicyRef {
+ id: string;
+ name: string;
+ /** CSS colour for the badge (matches the policy's accent). */
+ accentColor: string;
+}
+
export interface FileItemProps {
fileId: FileId;
name: string;
@@ -143,9 +152,12 @@ export interface FileItemProps {
folders?: FileItemFolderRef[];
/** Clicking a membership dot opens that folder. */
onFolderClick?: (folderId: string) => void;
+ /** Policies that have run on this file — rendered as small shield badges. */
+ policies?: FileItemPolicyRef[];
}
const MAX_VISIBLE_FOLDER_TAGS = 2;
+const MAX_VISIBLE_POLICY_BADGES = 3;
export function FileItem({
fileId,
@@ -162,6 +174,7 @@ export function FileItem({
onDragStart,
folders = [],
onFolderClick,
+ policies = [],
}: FileItemProps) {
const { t } = useTranslation();
const ext = getFileExtension(name);
@@ -237,6 +250,25 @@ export function FileItem({
{dateLabel && typeLabel ? " · " : ""}
{typeLabel}
+ {policies.length > 0 && (
+
+ {policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
+
+
+
+
+
+ ))}
+
+ )}
{folders.length > 0 && (
diff --git a/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx b/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx
index 25ddffaa7..0e6ba64a2 100644
--- a/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx
+++ b/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx
@@ -1,4 +1,4 @@
-import { createContext, useContext, ReactNode } from "react";
+import { createContext, useContext, useEffect, ReactNode } from "react";
import { MantineProvider } from "@mantine/core";
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
import { mantineTheme } from "@app/theme/mantineTheme";
@@ -7,6 +7,10 @@ import { ToastProvider } from "@app/components/toast";
import ToastRenderer from "@app/components/toast/ToastRenderer";
import { ToastPortalBinder } from "@app/components/toast";
import type { ThemeMode } from "@app/constants/theme";
+// SUI shared design-system tokens (used by @shared/components). Additive — its
+// var names don't clash with the editor's own theme.css. The effect below
+// bridges Mantine's color scheme to the `data-theme` attribute SUI keys on.
+import "@shared/tokens/tokens.css";
interface RainbowThemeContextType {
themeMode: ThemeMode;
@@ -40,6 +44,16 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
const mantineColorScheme =
rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode;
+ // Bridge the resolved scheme to the `data-theme` attribute SUI's tokens.css
+ // keys its dark palette on (the editor otherwise only sets Mantine's
+ // data-mantine-color-scheme), so @shared/components theme correctly.
+ useEffect(() => {
+ document.documentElement.setAttribute(
+ "data-theme",
+ mantineColorScheme === "dark" ? "dark" : "light",
+ );
+ }, [mantineColorScheme]);
+
return (
{
+ withViewTransition(() => {
+ if (readerMode) setReaderMode(false);
+ setLeftPanelView("toolPicker");
+ if (!sidebarsVisible) setSidebarsVisible(true);
+ setAllToolsView(false);
+ setSearchQuery("");
+ });
+ };
+
// The header shows [back] [search] when we have somewhere to go back to —
// i.e. the user is in a specific tool, or already in the all-tools/search view.
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
- // Show search input whenever there's a close button, or when agents are off and
- // we're in the default tool-picker view (search filters the full list inline).
+ // Policies sit above the tool list in the default tool-picker view.
+ const showPolicies =
+ policiesEnabled && !allToolsView && leftPanelView === "toolPicker";
+ // When Policies are shown, the search moves OUT of the header to sit between
+ // the Policies and Tools sections (separating them); otherwise it stays in the
+ // header. Show the header search when there's a close button, or when agents
+ // are off in the default tool-picker view (inline filtering).
+ const showInlineSearch = showPolicies && !showCloseButton;
const showHeaderSearch =
- showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
+ !showInlineSearch &&
+ (showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker"));
const handleHeaderBack = () => {
if (inToolView) {
@@ -201,11 +231,21 @@ export default function RightSidebar() {
const showAgents =
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
+ // The detail takeover replaces the tool list ONLY in the same default view —
+ // never over an open tool or the all-tools view (which must keep priority).
+ // A lingering selection is harmless: it stays hidden behind a tool and the
+ // list/takeover reappears on return to the picker (as in the prototype).
+ const policyDetailActive = rawPolicyDetailActive && showPolicies;
+
+ // The rail widens when a policy detail takes it over — the tool list is fine
+ // at 18.5rem, but the policy detail/wizard/settings need more breathing room.
+ const expandedWidth = policyDetailActive ? "25rem" : "18.5rem";
+
const computedWidth = () => {
if (isMobile) return "100%";
if (isChatOpen) return `${chatWidthPx}px`;
if (!isPanelVisible) return "3.5rem";
- return "18.5rem";
+ return expandedWidth;
};
// Collapsed rail: show favourites + recommended tools as icons.
@@ -248,6 +288,8 @@ export default function RightSidebar() {
...(isChatDragging ? { transition: "none" } : {}),
}}
>
+ {/* Headless: enforces enabled policies on every uploaded file. */}
+ {policiesEnabled && }
{!fullscreenExpanded && isChatOpen && (
@@ -288,6 +330,7 @@ export default function RightSidebar() {
+
{collapsedRailItems.map(({ id, tool }) => (
-
- {activeTool ? (
-
-
-
-
-
- {activeTool.name}
-
-
- ) : showHeaderSearch ? (
-
+ ) : (
+ <>
+ {!showPolicies && (
+
+ {activeTool ? (
+
+
+
+
+
+ {activeTool.name}
+
+
+ ) : showHeaderSearch ? (
+
+
+
+ ) : (
+ showAgents && (
+
+ {t("agents.section_title", "Agents")}
+
+ )
+ )}
+ {showCloseButton ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+ {showAgents &&
}
+
+ {showPolicies && (
+
+
+
+ }
/>
-
- ) : (
- showAgents && (
-
- {t("agents.section_title", "Agents")}
-
- )
- )}
- {showCloseButton ? (
-
-
-
- ) : (
-
-
-
- )}
-
+ )}
- {showAgents && }
+ {showInlineSearch && (
+
+
+
+ )}
-
+
+ >
+ )}
)}
diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css
index 592c83771..103a24ad9 100644
--- a/frontend/editor/src/core/components/tools/ToolPanel.css
+++ b/frontend/editor/src/core/components/tools/ToolPanel.css
@@ -201,6 +201,13 @@
border-color: var(--border-subtle) !important;
}
+/* The collapse/expand toggle keeps a stable identity across the collapsed strip
+ and the expanded header, so a view transition translates it between the two
+ (same size) instead of cross-fading/scaling it ("big then small"). */
+.tool-panel__toggle-vt {
+ view-transition-name: sidebar-toggle;
+}
+
.tool-panel__expand-btn svg {
color: var(--text-secondary) !important;
}
@@ -271,6 +278,22 @@
width: 100%;
}
+/* Search that separates the Policies section from the Tools list below it. */
+.tool-panel__between-search {
+ padding: 0.5rem 0.75rem 0.25rem;
+ border-top: 1px solid var(--border-subtle, var(--color-border));
+}
+
+.tool-panel__between-search .search-input-container {
+ width: 100%;
+ margin-bottom: 0;
+}
+
+/* Slightly recessed search field so it reads as distinct from the panel. */
+.tool-panel__between-search input {
+ background-color: var(--bg-muted);
+}
+
.tool-panel__active-tool-pill {
display: inline-flex;
align-items: center;
diff --git a/frontend/editor/src/core/constants/featureFlags.ts b/frontend/editor/src/core/constants/featureFlags.ts
index 3a2e3d7a2..0ada1cc67 100644
--- a/frontend/editor/src/core/constants/featureFlags.ts
+++ b/frontend/editor/src/core/constants/featureFlags.ts
@@ -4,11 +4,18 @@
*/
/**
- * Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the
- * custom workbench registration (seeding, URL sync, view) and the sidebar
- * entry point, so the feature is unreachable from the UI. Flip to `true` to
- * restore access.
+ * Watched Folders. Disabled for now — gates both the custom workbench
+ * registration (seeding, URL sync, view) and the sidebar entry point, so the
+ * feature is unreachable from the UI. Flip to `true` to restore access.
*/
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
// as constant/unreachable conditions by the type checker and linter.
export const WATCHED_FOLDERS_ENABLED: boolean = false;
+
+/**
+ * Policies — a proprietary, automation-backed feature (like Watched Folders but
+ * backend-driven, with non-folder triggers). The implementation lives under
+ * `proprietary/`; this core value stays `false` so the shared sidebar entry
+ * never appears in the open-source build.
+ */
+export const POLICIES_ENABLED: boolean = false;
diff --git a/frontend/editor/src/core/hooks/useAllWatchedFolders.ts b/frontend/editor/src/core/hooks/useAllWatchedFolders.ts
index 8abc81a46..b1dbc2f57 100644
--- a/frontend/editor/src/core/hooks/useAllWatchedFolders.ts
+++ b/frontend/editor/src/core/hooks/useAllWatchedFolders.ts
@@ -17,7 +17,9 @@ export function useAllWatchedFolders(): WatchedFolder[] {
useEffect(() => {
const load = async () => {
try {
- setFolders(await watchedFolderStorage.getAllFolders());
+ // Policy-owned folders are managed by Policies, not shown here.
+ const all = await watchedFolderStorage.getAllFolders();
+ setFolders(all.filter((f) => !f.policyCategoryId));
} catch (err) {
console.error("Failed to load smart folders:", err);
}
diff --git a/frontend/editor/src/core/hooks/usePolicyFileBadges.ts b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts
new file mode 100644
index 000000000..e3297f9bd
--- /dev/null
+++ b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts
@@ -0,0 +1,10 @@
+import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
+
+/**
+ * Policies that have run on each file, keyed by fileId — drives the shield
+ * badges in the file sidebar. Empty in core; the proprietary build shadows this
+ * with an implementation backed by the policy run store.
+ */
+export function usePolicyFileBadges(): Map {
+ return new Map();
+}
diff --git a/frontend/editor/src/core/tests/helpers/api-stubs.ts b/frontend/editor/src/core/tests/helpers/api-stubs.ts
index a8291a181..1618e8fa2 100644
--- a/frontend/editor/src/core/tests/helpers/api-stubs.ts
+++ b/frontend/editor/src/core/tests/helpers/api-stubs.ts
@@ -248,6 +248,14 @@ export async function mockAppApis(
await page.route("**/api/v1/info/wau", (route: Route) =>
route.fulfill({ json: { count: 0 } }),
);
+
+ // Policies (proprietary): the reconcile fires GET /api/v1/policies on app
+ // load. Return an empty list so the stubbed (backend-free) env stays clean —
+ // no failed request polluting the console, and the auto-run controller has
+ // nothing to dispatch.
+ await page.route("**/api/v1/policies", (route: Route) =>
+ route.fulfill({ json: [] }),
+ );
}
/**
diff --git a/frontend/editor/src/core/types/watchedFolders.ts b/frontend/editor/src/core/types/watchedFolders.ts
index adfdc1640..ce8f7374e 100644
--- a/frontend/editor/src/core/types/watchedFolders.ts
+++ b/frontend/editor/src/core/types/watchedFolders.ts
@@ -25,6 +25,9 @@ export interface WatchedFolder {
hasOutputDirectory?: boolean; // true when a local FS output folder is configured
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
inputSource?: "idb" | "local-folder";
+ /** Set when this folder is the backing store for a Policy (its category id).
+ * Policy-owned folders are filtered out of the Watched Folders UI. */
+ policyCategoryId?: string;
}
export interface FolderFileMetadata {
diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css
new file mode 100644
index 000000000..fc6e24324
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/Policies.css
@@ -0,0 +1,438 @@
+/* ============================ Policies ============================ */
+/* The Policies surface is docked in the right tool sidebar: a list section */
+/* above Tools, a detail takeover that replaces Tools when a policy is open, */
+/* and a collapsed-rail of policy icons. */
+/* */
+/* Chrome (headers, cards, buttons, badges, chips, lists, steps…) uses SUI */
+/* (@shared/components). The bespoke .pol-* bits below are thin layout */
+/* scaffolding + the collapsed rail; spacing snaps to the SUI --space-* */
+/* scale and colour to the SUI token set so it reads as one product. */
+
+/* ---- List ---- */
+.pol-list {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+}
+/* Header row: optional leading control (sidebar collapse) + the SectionHeader,
+ mirroring the back-button + title layout inside an open policy. */
+.pol-list-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+}
+.pol-list-head .sui-sectionhdr {
+ flex: 1;
+ min-width: 0;
+}
+/* Small "what is a policy?" info button on the header. */
+/* Bare info icon matching the tool-step affordance (LocalIcon, no chrome). */
+.pol-info-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+ transition: opacity var(--motion-fast);
+}
+.pol-info-btn:hover {
+ opacity: 0.7;
+}
+.pol-list-rows {
+ display: flex;
+ flex-direction: column;
+ padding: var(--space-1) var(--space-1_5);
+ gap: 0.0625rem;
+}
+/* A policy row: tinted icon tile + label + trailing status/CTA. */
+.pol-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ padding: var(--space-1_5) var(--space-2);
+ border: none;
+ border-radius: var(--radius-lg);
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ transition: background var(--motion-fast);
+}
+.pol-row:hover {
+ background: var(--color-bg-hover);
+}
+.pol-row:focus-visible {
+ outline: 2px solid var(--color-blue);
+ outline-offset: -2px;
+}
+.pol-row-label {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-1);
+}
+.pol-row-trail {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ margin-left: auto;
+}
+/* Unconfigured rows: a quiet blue "Set up" call-to-action instead of a status pill. */
+.pol-row-setup {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--color-blue);
+}
+/* Trailing drill-in chevron on each policy row (after the status / CTA). */
+.pol-row-chevron {
+ color: var(--color-text-4);
+ flex-shrink: 0;
+}
+
+/* Retry button on a failed activity row. */
+/* Expandable error text in the activity feed — long backend errors are clamped
+ and collapsed by default so they don't blow up the row. */
+.pol-activity-error {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.125rem;
+}
+.pol-activity-error__text {
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.pol-activity-error__text--clamped {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.pol-activity-error__toggle {
+ border: none;
+ background: none;
+ padding: 0;
+ font-size: inherit;
+ font-weight: 600;
+ color: var(--color-blue);
+ cursor: pointer;
+}
+.pol-activity-error__toggle:hover {
+ text-decoration: underline;
+}
+
+/* Locked "Coming soon" row — muted, not interactive. */
+.pol-row--soon {
+ opacity: 0.55;
+ cursor: default;
+}
+.pol-row--soon:hover {
+ background: transparent;
+}
+.pol-row-soon {
+ font-size: 0.6875rem;
+ font-weight: 500;
+ color: var(--color-text-4);
+ white-space: nowrap;
+}
+
+/* In-progress activity icon spins gently. */
+.pol-spin {
+ animation: pol-spin 1.2s linear infinite;
+}
+@keyframes pol-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ---- Locked, per-tool config (PolicyToolConfig): one section per tool ---- */
+.pol-tool-config {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+.pol-tool-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3);
+}
+.pol-tool-icon {
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-text-3);
+}
+.pol-tool-name {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+ margin-right: auto;
+}
+.pol-tool-body {
+ padding: 0 var(--space-3) var(--space-3);
+ border-top: 1px solid var(--color-border);
+ padding-top: var(--space-3);
+}
+
+/* ---- Detail container (wizard / narrative / settings share this) ---- */
+.pol-detail {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ max-width: 32rem;
+ width: 100%;
+}
+
+/* ---- Step indicator (wraps a SUI StepIndicator) ---- */
+.pol-steps {
+ padding: var(--space-3) var(--space-5);
+ border-bottom: 1px solid var(--color-border);
+}
+
+/* ---- Scroll body ---- */
+.pol-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: var(--space-3) var(--space-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+.pol-desc {
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: var(--color-text-4);
+ margin: 0;
+}
+.pol-section-label {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-4);
+ /* Bottom gap so the label doesn't hug its card/chips. */
+ margin: 0 0 var(--space-2);
+}
+
+/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
+.pol-field {
+ padding: 0.7rem 0.875rem;
+ background: var(--color-surface);
+}
+.pol-field:not([data-first]) {
+ border-top: 1px solid var(--color-border);
+}
+.pol-field-label {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-1);
+}
+.pol-field-count {
+ font-size: 0.6875rem;
+ color: var(--color-text-4);
+}
+.pol-field-chips-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-2);
+}
+.pol-field-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1_5);
+}
+
+/* ---- Sources ---- */
+.pol-source {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 0.7rem 0.875rem;
+ cursor: pointer;
+ background: var(--color-surface);
+}
+.pol-source:not([data-first]) {
+ border-top: 1px solid var(--color-border);
+}
+
+/* ---- Doc types ---- */
+.pol-link {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--color-blue);
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+}
+.pol-doctypes-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.7rem 0.875rem;
+}
+.pol-doctypes {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-2) 0.875rem var(--space-3);
+ border-top: 1px solid var(--color-border);
+}
+
+/* ---- Summary ---- */
+.pol-summary-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-bottom: var(--space-2);
+}
+.pol-summary-title {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+.pol-summary-rows {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1_5);
+}
+/* Muted placeholder for an unset summary value (e.g. no reviewer chosen). */
+.pol-muted {
+ color: var(--color-text-4);
+}
+
+/* ---- Enforces rule flow (wraps a SUI ChipFlow) ---- */
+.pol-rule-flow {
+ margin-bottom: var(--space-2);
+}
+
+/* ---- Meta / note ---- */
+.pol-meta-row {
+ display: flex;
+ gap: var(--space-3);
+ padding-top: var(--space-2);
+ border-top: 1px solid var(--color-border);
+}
+.pol-meta-item {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1_5);
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+.pol-note {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-top: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-lg);
+ background: var(--color-bg-muted);
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+
+/* ---- Stats: one grouped card with three divided columns. ---- */
+.pol-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+}
+.pol-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-0_5);
+ padding: var(--space-3) var(--space-2);
+ text-align: center;
+}
+.pol-stat:not(:first-child) {
+ border-left: 1px solid var(--color-border);
+}
+.pol-stat-value {
+ font-size: 1rem;
+ font-weight: 600;
+ line-height: 1.1;
+ color: var(--color-text-1);
+}
+.pol-stat-label {
+ font-size: 0.6875rem;
+ color: var(--color-text-4);
+}
+
+/* ---- Footer (hosts SUI Buttons) ---- */
+.pol-footer {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-5);
+ border-top: 1px solid var(--color-border);
+ flex-shrink: 0;
+}
+.pol-footer-end {
+ justify-content: flex-end;
+}
+
+/* ---- Detail takeover (fills the rail when a policy is open) ---- */
+.pol-takeover {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ---- Collapsed rail policy icons ---- */
+.pol-crail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-1);
+ flex-shrink: 0;
+}
+.pol-crail-btn {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ border: none;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--color-text-3);
+ cursor: pointer;
+ transition: background var(--motion-fast);
+}
+.pol-crail-btn:hover {
+ background: var(--color-blue-light);
+}
+.pol-crail-btn[data-status="active"] {
+ color: var(--color-blue);
+}
+.pol-crail-btn[data-status="paused"] {
+ color: var(--color-amber);
+}
+.pol-crail-dot {
+ position: absolute;
+ top: 0.1rem;
+ right: 0.1rem;
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 50%;
+ border: 2px solid var(--bg-toolbar, var(--color-surface));
+}
+.pol-crail-dot[data-status="active"] {
+ background: var(--color-green);
+}
+.pol-crail-dot[data-status="paused"] {
+ background: var(--color-amber);
+}
diff --git a/frontend/editor/src/proprietary/components/policies/Policies.stories.tsx b/frontend/editor/src/proprietary/components/policies/Policies.stories.tsx
new file mode 100644
index 000000000..863501000
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/Policies.stories.tsx
@@ -0,0 +1,163 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
+import { PoliciesSection } from "@app/components/policies/PoliciesSidebar";
+import { POLICY_CATEGORIES, POLICY_CONFIG } from "@app/data/policyDefinitions";
+import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
+import "@app/components/policies/Policies.css";
+
+/**
+ * The Policies surface lives in the editor's right tool sidebar. These stories
+ * render the three rich detail surfaces (narrative / setup wizard / settings)
+ * inside a frame the width of the rail when a policy is open (25rem), so the
+ * SUI composition can be reviewed in isolation — no app shell, login, or
+ * backend required. Toggle the Storybook theme switcher to check dark mode.
+ */
+const RAIL_WIDTH = "25rem";
+
+/** Frame that mimics the right rail's open width + surface so the panel reads true. */
+function RailFrame({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+const ingestion = POLICY_CATEGORIES.find((c) => c.id === "ingestion")!;
+const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
+
+// Illustrative activity/stats for the static stories. In the app these are
+// derived live from the user's real uploaded files (see policyLiveData).
+const sampleActivity: PolicyActivityItem[] = [
+ {
+ doc: "MSA_Acme_2026.pdf",
+ action: "1.2 MB • enforced on upload",
+ time: "2h ago",
+ status: "enforced",
+ },
+ {
+ doc: "scan_002.pdf",
+ action: "Low confidence • flagged for review",
+ time: "Yesterday",
+ status: "flagged",
+ },
+];
+const sampleStats: PolicyStats = {
+ enforced: 1284,
+ dataProcessed: "3.2 GB",
+ activeFor: "18d",
+};
+
+const noop = () => {};
+
+const meta: Meta = {
+ title: "Editor/Policies",
+ parameters: { layout: "centered" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+/** Configured policy, running — Enforces / Activity / Stats narrative. */
+export const DetailActive: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+/** Configured policy, paused — amber accent + warning badge. */
+export const DetailPaused: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+/** Read-only view for a member without configure permission. */
+export const DetailManaged: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+/** The policy list section (above Tools). */
+export const ListSection: Story = {
+ render: () => (
+
+ ),
+};
+
+// Note: the setup + edit wizard now embeds the Watch Folders automation builder
+// (its Workflow step), which needs the ToolWorkflow context — so the wizard is
+// exercised in-app, not via an isolated story here.
diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx
new file mode 100644
index 000000000..0eabd502f
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx
@@ -0,0 +1,152 @@
+import "fake-indexeddb/auto";
+import type { ReactNode } from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+
+// The shared Tooltip (used by the "what is a policy?" info button) pulls in
+// preferences/sidebar contexts we don't set up here — passthrough it.
+vi.mock("@app/components/shared/Tooltip", () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => children,
+}));
+
+// The wizard's Workflow step embeds the Watch Folders automation builder, which
+// needs the ToolWorkflow context. Mock it: a stub that wires the save trigger to
+// hand back the seed automation, so the wizard's submit still completes.
+vi.mock("@app/components/policies/PolicyWorkflowStep", () => ({
+ AutomationMode: { CREATE: "create", EDIT: "edit", SUGGESTED: "suggested" },
+ PolicyWorkflowStep: (props: {
+ automation: unknown;
+ saveTriggerRef: { current: (() => void) | null };
+ onComplete: (automation: unknown, toolRegistry: unknown) => void;
+ }) => {
+ props.saveTriggerRef.current = () => props.onComplete(props.automation, {});
+ return null;
+ },
+}));
+
+// The backend is the source of truth, but these UI tests run offline: list
+// rejects (so the mount reconcile keeps the local cache), while save/delete
+// resolve so the enable flow completes.
+vi.mock("@app/services/policyApi", () => ({
+ listPolicies: vi.fn().mockRejectedValue(new Error("offline")),
+ savePolicy: vi.fn().mockImplementation(async (p: { id?: string }) => ({
+ ...p,
+ id: p.id && p.id.length > 0 ? p.id : "be-test",
+ })),
+ getPolicy: vi.fn(),
+ deletePolicy: vi.fn().mockResolvedValue(undefined),
+ runStoredPolicy: vi.fn(),
+ runPolicyPipeline: vi.fn(),
+ getPolicyRun: vi.fn(),
+}));
+
+// Enabling a policy creates its backing Watched Folders WatchedFolder (IndexedDB);
+// jsdom's crypto lacks randomUUID, which watchedFolderStorage uses for folder ids.
+if (typeof globalThis.crypto?.randomUUID !== "function") {
+ const orig = globalThis.crypto;
+ vi.stubGlobal("crypto", {
+ getRandomValues: orig?.getRandomValues?.bind(orig),
+ randomUUID: () =>
+ `p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
+ });
+}
+import {
+ PoliciesSection,
+ PolicyDetailTakeover,
+ usePolicyDetailActive,
+} from "@app/components/policies/PoliciesSidebar";
+import { resetPolicySelection } from "@app/components/policies/policySelectionStore";
+
+/**
+ * Mirrors how RightSidebar swaps the policy list for the detail takeover: the
+ * list shows above Tools when nothing is open, the takeover replaces it when a
+ * policy is selected.
+ */
+function PoliciesHost() {
+ const active = usePolicyDetailActive();
+ return active ? : ;
+}
+
+function renderHost() {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("Policies right-sidebar surface", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ // Seed a configured Security policy (the only live category) in the local
+ // cache. The backend list is mocked to reject (offline), so the mount
+ // reconcile leaves it in place — giving the narrative tests a policy to open.
+ localStorage.setItem(
+ "stirling-policies-state",
+ JSON.stringify({
+ security: {
+ configured: true,
+ status: "active",
+ sources: ["editor"],
+ scopeTypes: [],
+ reviewerEmail: "",
+ fieldValues: {},
+ },
+ }),
+ );
+ resetPolicySelection();
+ });
+
+ it("renders the policy list with every category", () => {
+ renderHost();
+ expect(screen.getByText("Policies")).toBeInTheDocument();
+ for (const label of [
+ "Ingestion",
+ "Security",
+ "Compliance",
+ "Routing",
+ "Retention",
+ ]) {
+ expect(screen.getByText(label)).toBeInTheDocument();
+ }
+ });
+
+ it("shows Security as active and the unbuilt categories as Coming soon", () => {
+ renderHost();
+ expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1);
+ // Ingestion, Compliance, Routing, Retention are locked for this release.
+ expect(screen.getAllByText("Coming soon")).toHaveLength(4);
+ });
+
+ it("does not open a Coming soon policy when its row is clicked", () => {
+ renderHost();
+ fireEvent.click(screen.getByText("Ingestion"));
+ // The locked row isn't a button — we stay on the list, nothing opens.
+ expect(screen.getByText("Policies")).toBeInTheDocument();
+ expect(screen.queryByText("Enforces")).not.toBeInTheDocument();
+ });
+
+ it("opens the narrative view when a live policy is clicked", () => {
+ renderHost();
+ fireEvent.click(screen.getByText("Security"));
+ expect(screen.getByText("Enforces")).toBeInTheDocument();
+ expect(screen.getByText("Edit Settings")).toBeInTheDocument();
+ });
+
+ it("shows an honest empty activity feed when no files have been uploaded", async () => {
+ renderHost();
+ fireEvent.click(screen.getByText("Security"));
+ expect(screen.getByText("Recent Activity")).toBeInTheDocument();
+ // Activity is derived from real uploads; with none, the empty state shows.
+ expect(await screen.findByText("No activity yet")).toBeInTheDocument();
+ });
+
+ it("returns to the list via the back button", () => {
+ renderHost();
+ fireEvent.click(screen.getByText("Security"));
+ expect(screen.getByText("Enforces")).toBeInTheDocument();
+ fireEvent.click(screen.getByLabelText("Back"));
+ expect(screen.getByText("Policies")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx
new file mode 100644
index 000000000..2028063fe
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx
@@ -0,0 +1,453 @@
+/**
+ * Proprietary implementation of the right-sidebar Policies surface.
+ *
+ * Shadows the core stubs at {@code core/components/policies/PoliciesSidebar.tsx}
+ * via the {@code @app/*} alias cascade. Three slots, all driven by the shared
+ * {@link policySelectionStore} so they stay in sync:
+ * • {@link PoliciesSection} — the collapsible policy list, rendered above the
+ * Tools section in {@code RightSidebar} when no policy is open.
+ * • {@link PolicyDetailTakeover} — the detail / wizard / settings view, which
+ * replaces the Tools area when a policy is open.
+ * • {@link PoliciesCollapsedButton} — the icon rail shown when the sidebar is
+ * collapsed; clicking an icon selects the policy and expands the rail.
+ */
+
+import { useState, useEffect, useMemo, type ReactNode } from "react";
+import ChevronRightIcon from "@mui/icons-material/ChevronRight";
+import LocalIcon from "@app/components/shared/LocalIcon";
+import { usePolicies } from "@app/hooks/usePolicies";
+import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog";
+import { getPolicyAutomation } from "@app/services/policyFolders";
+import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
+import { runsToActivity, runsToStats } from "@app/services/policyLiveData";
+import { usePolicyRuns } from "@app/components/policies/policyRunStore";
+import { runPolicyOnFile } from "@app/components/policies/usePolicyAutoRun";
+import type { FileId } from "@app/types/file";
+import type {
+ AutomationConfig,
+ AutomationOperation,
+} from "@app/types/automation";
+import type { WatchedFolder } from "@app/types/watchedFolders";
+import { POLICIES_ENABLED } from "@app/constants/featureFlags";
+import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
+import { IconBadge } from "@shared/components/IconBadge";
+import {
+ deriveRowStatus,
+ STATUS_LABEL,
+ ROW_ACCENT,
+} from "@app/components/policies/policyStatus";
+import { StatusBadge } from "@shared/components/StatusBadge";
+import { SectionHeader } from "@shared/components/SectionHeader";
+import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard";
+import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
+import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal";
+import type { PolicyConfigResult } from "@app/types/policies";
+import {
+ usePolicySelection,
+ selectPolicy,
+ setPolicyDetailView,
+ closePolicy,
+} from "@app/components/policies/policySelectionStore";
+import "@app/components/policies/Policies.css";
+
+/** localStorage key persisting the Policies section's expand/collapse state. */
+const POLICIES_COLLAPSED_KEY = "stirling-policies-section-collapsed";
+
+/** Whether the right rail should host the Policies section. True in proprietary. */
+export function usePoliciesEnabled(): boolean {
+ return POLICIES_ENABLED;
+}
+
+/**
+ * Whether a policy is open — i.e. its detail view should take over the rail in
+ * place of the tool list. False when the feature is off or nothing is selected.
+ */
+export function usePolicyDetailActive(): boolean {
+ const { selectedId } = usePolicySelection();
+ return POLICIES_ENABLED && selectedId != null;
+}
+
+/** The collapsible policy list, rendered above the Tools section. */
+export function PoliciesSection({
+ leadingControl,
+}: {
+ /** Optional control rendered to the left of the header (e.g. the sidebar
+ * collapse button), mirroring the back-button + title in a policy. */
+ leadingControl?: ReactNode;
+} = {}) {
+ const pol = usePolicies();
+ const { categories } = usePolicyCatalog();
+ // Persist the expand/collapse state across refreshes.
+ const [expanded, setExpanded] = useState(() => {
+ try {
+ return localStorage.getItem(POLICIES_COLLAPSED_KEY) !== "1";
+ } catch {
+ return true;
+ }
+ });
+ const toggleExpanded = () =>
+ setExpanded((open) => {
+ const next = !open;
+ try {
+ localStorage.setItem(POLICIES_COLLAPSED_KEY, next ? "0" : "1");
+ } catch {
+ // Best-effort; ignore quota/availability failures.
+ }
+ return next;
+ });
+
+ if (!POLICIES_ENABLED) return null;
+
+ // The header tally counts every CONFIGURED policy (active + paused), not just
+ // the active ones.
+ const configuredCount = categories.filter(
+ (c) => pol.policies[c.id]?.configured,
+ ).length;
+
+ return (
+
+
+ {leadingControl}
+
+
+
+
+
+
+
+
+ {expanded && (
+ <>
+
+ {categories.map((cat) => {
+ if (cat.comingSoon) {
+ return (
+
+
+ {cat.icon}
+
+ {cat.label}
+
+ Coming soon
+
+
+ );
+ }
+ const status = deriveRowStatus(pol.policies[cat.id]);
+ return (
+
selectPolicy(cat.id)}
+ >
+
+ {cat.icon}
+
+ {cat.label}
+
+ {status === "setup" ? (
+ Set up
+ ) : (
+
+ {STATUS_LABEL[status]}
+
+ )}
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+}
+
+/**
+ * The open-policy view — narrative detail, setup wizard, or edit-settings —
+ * which replaces the Tools area while a policy is selected.
+ */
+export function PolicyDetailTakeover() {
+ const pol = usePolicies();
+ const { categories, configs, sources, docTypes } = usePolicyCatalog();
+ const { selectedId, detailView } = usePolicySelection();
+
+ // The configured policy's backing folder + automation (its real, editable
+ // pipeline). `reloadKey` bumps after the edit modal saves so the detail
+ // reflects the new steps. Falls back to the preset's rules when unconfigured.
+ const folderId = selectedId ? pol.policies[selectedId]?.folderId : undefined;
+ const [steps, setSteps] = useState([]);
+ const [backingFolder, setBackingFolder] = useState(
+ null,
+ );
+ const [backingAutomation, setBackingAutomation] =
+ useState(null);
+ const [reloadKey, setReloadKey] = useState(0);
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
+ useEffect(() => {
+ if (!folderId) {
+ setSteps([]);
+ setBackingFolder(null);
+ setBackingAutomation(null);
+ return;
+ }
+ let cancelled = false;
+ void (async () => {
+ const [folder, automation] = await Promise.all([
+ watchedFolderStorage.getFolder(folderId),
+ getPolicyAutomation(folderId),
+ ]);
+ if (cancelled) return;
+ setBackingFolder(folder);
+ setBackingAutomation(automation);
+ setSteps(automation?.operations ?? []);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [folderId, reloadKey]);
+
+ // Activity/stats come from the real backend runs the auto-run controller fires
+ // on every upload (policyRunStore), filtered to this policy's category. The
+ // store is reactive, so the feed updates live as runs progress — no polling
+ // here (the controller does the run-status polling).
+ const allRuns = usePolicyRuns();
+ const categoryRuns = useMemo(
+ () => allRuns.filter((r) => r.categoryId === selectedId),
+ [allRuns, selectedId],
+ );
+
+ if (!POLICIES_ENABLED || selectedId == null) return null;
+
+ const category = categories.find((c) => c.id === selectedId);
+ const state = pol.policies[selectedId];
+ const config = configs[selectedId];
+ if (!category || !state || !config) return null;
+ // Coming-soon categories can't be opened (the list row is locked anyway).
+ if (category.comingSoon) return null;
+
+ const status = deriveRowStatus(state);
+
+ const onSetupClassification = () => {
+ const classifier = categories.find((c) => c.providesClassification);
+ if (classifier) selectPolicy(classifier.id);
+ };
+
+ // Preset (tool-chain) policies configure via the wizard's locked tool-config
+ // step instead of the add/remove builder; the wizard fires onCommitConfig for
+ // them (and onComplete for builder-based categories). One commit path serves
+ // both first-time configure and edits.
+ const commitConfig = (result: PolicyConfigResult) =>
+ pol.commitPolicyConfig(selectedId, result).then(() => {
+ setReloadKey((k) => k + 1);
+ setPolicyDetailView("detail");
+ });
+
+ // Setup: the shared wizard in create mode.
+ if (!state.configured) {
+ return (
+ closePolicy()}
+ onComplete={(result) =>
+ pol
+ .enablePolicy(selectedId, result)
+ .then(() => setPolicyDetailView("detail"))
+ }
+ onCommitConfig={commitConfig}
+ onSetupClassification={onSetupClassification}
+ />
+ );
+ }
+
+ // Edit: the same wizard in edit mode, pre-filled — so editing has the settings
+ // steps (not just the workflow). Wait for the backing automation to load so
+ // the workflow step edits the real pipeline.
+ if (detailView === "settings" && pol.canConfigure) {
+ if (!backingAutomation) {
+ return (
+
+ );
+ }
+ return (
+ setPolicyDetailView("detail")}
+ onComplete={(result) =>
+ pol.savePolicyConfig(selectedId, result).then(() => {
+ setReloadKey((k) => k + 1);
+ setPolicyDetailView("detail");
+ })
+ }
+ onCommitConfig={commitConfig}
+ onSetupClassification={onSetupClassification}
+ />
+ );
+ }
+
+ return (
+ <>
+ closePolicy()}
+ onEditSettings={() => {
+ // Seeded/active policies may have no backing folder yet — create one
+ // from the preset so there's a workflow to edit, then open settings.
+ void pol
+ .ensurePolicyFolder(selectedId)
+ .then(() => setPolicyDetailView("settings"));
+ }}
+ onTogglePause={() =>
+ status === "paused"
+ ? pol.resumePolicy(selectedId)
+ : pol.pausePolicy(selectedId)
+ }
+ onDelete={() => setConfirmingDelete(true)}
+ onRetry={(item) => {
+ if (item.fileId && state.backendId) {
+ void runPolicyOnFile(
+ selectedId,
+ state.backendId,
+ item.fileId as FileId,
+ item.doc,
+ );
+ }
+ }}
+ />
+ {confirmingDelete && (
+ setConfirmingDelete(false)}
+ onConfirm={() => {
+ setConfirmingDelete(false);
+ closePolicy();
+ void pol.deletePolicy(selectedId);
+ }}
+ />
+ )}
+ >
+ );
+}
+
+/**
+ * Collapsed-rail policy icons. Each tints blue when active and carries a small
+ * status dot (green active / amber paused). Clicking selects the policy and
+ * expands the rail.
+ */
+export function PoliciesCollapsedButton({
+ onExpand,
+}: {
+ onExpand: () => void;
+}) {
+ const pol = usePolicies();
+ const { categories } = usePolicyCatalog();
+
+ if (!POLICIES_ENABLED) return null;
+
+ return (
+ <>
+
+ {categories
+ .filter((cat) => !cat.comingSoon)
+ .map((cat) => {
+ const status = deriveRowStatus(pol.policies[cat.id]);
+ const suffix =
+ status === "active"
+ ? " (Active)"
+ : status === "paused"
+ ? " (Paused)"
+ : "";
+ return (
+
+ {
+ selectPolicy(cat.id);
+ onExpand();
+ }}
+ >
+ {cat.icon}
+ {(status === "active" || status === "paused") && (
+
+ )}
+
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx b/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx
new file mode 100644
index 000000000..7c4e92194
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx
@@ -0,0 +1,11 @@
+import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
+
+/**
+ * Headless controller that drives policy auto-run (enforce every enabled policy
+ * on every uploaded file). Mounted once wherever the editor is open so runs fire
+ * regardless of whether the policy panel is visible. Renders nothing.
+ */
+export function PolicyAutoRunController() {
+ usePolicyAutoRun();
+ return null;
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx
new file mode 100644
index 000000000..02b46928c
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx
@@ -0,0 +1,46 @@
+import { Modal, Text, Button, Stack, Group } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+
+interface PolicyDeleteConfirmModalProps {
+ opened: boolean;
+ /** The policy's display label (the category name). */
+ label: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+/** Confirm before deleting a policy — removing it discards its backing workflow. */
+export function PolicyDeleteConfirmModal({
+ opened,
+ label,
+ onConfirm,
+ onCancel,
+}: PolicyDeleteConfirmModalProps) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t(
+ "policies.deleteConfirmBody",
+ "This removes the policy and its workflow. Documents already processed are not affected.",
+ )}
+
+
+
+ {t("cancel", "Cancel")}
+
+
+ {t("delete", "Delete")}
+
+
+
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx
new file mode 100644
index 000000000..bda878299
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx
@@ -0,0 +1,279 @@
+import { useState } from "react";
+import PublicIcon from "@mui/icons-material/Public";
+import ScheduleIcon from "@mui/icons-material/Schedule";
+import HistoryIcon from "@mui/icons-material/History";
+import DescriptionIcon from "@mui/icons-material/Description";
+import CheckCircleIcon from "@mui/icons-material/CheckCircle";
+import WarningAmberIcon from "@mui/icons-material/WarningAmber";
+import AutorenewIcon from "@mui/icons-material/Autorenew";
+import LockIcon from "@mui/icons-material/Lock";
+import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
+import { PanelHeader } from "@shared/components/PanelHeader";
+import { Card } from "@shared/components/Card";
+import { ChipFlow } from "@shared/components/ChipFlow";
+import { StatusBadge } from "@shared/components/StatusBadge";
+import { EmptyState } from "@shared/components/EmptyState";
+import { Button } from "@shared/components/Button";
+import { Banner } from "@shared/components/Banner";
+import { ListRow } from "@shared/components/ListRow";
+import type {
+ PolicyActivityItem,
+ PolicyCategory,
+ PolicyConfigDef,
+ PolicyRowStatus,
+ PolicyStats,
+} from "@app/types/policies";
+import type { AutomationOperation } from "@app/types/automation";
+
+interface PolicyDetailPanelProps {
+ category: PolicyCategory;
+ config: PolicyConfigDef;
+ /** Derived display status. */
+ status: PolicyRowStatus;
+ /**
+ * The policy's real configured steps (from its backing automation). When
+ * present these drive the Enforces flow; otherwise the preset's decorative
+ * `rules` are shown (e.g. before configuration).
+ */
+ steps?: AutomationOperation[];
+ /** Activity feed derived from the user's files; empty until files exist. */
+ activity?: PolicyActivityItem[];
+ /** Summary stats derived from the user's files. */
+ stats?: PolicyStats;
+ canConfigure: boolean;
+ /** Default (built-in) policies aren't deletable, so the Delete action hides. */
+ canDelete: boolean;
+ onBack: () => void;
+ onEditSettings: () => void;
+ onTogglePause: () => void;
+ onDelete: () => void;
+ /** Re-run a failed activity item's policy on its file. */
+ onRetry?: (item: PolicyActivityItem) => void;
+}
+
+/** "addWatermark" → "Add Watermark" — a light humanisation of op ids for display. */
+function humanizeOperation(op: string): string {
+ return op
+ .replace(/([A-Z])/g, " $1")
+ .replace(/^./, (c) => c.toUpperCase())
+ .trim();
+}
+
+/**
+ * A failed run's error in the activity feed. Backend errors can be long (or
+ * multi-line stack traces) and would otherwise blow up the row, so anything
+ * lengthy is clamped and collapsed by default with a Show more/less toggle.
+ * Short messages (e.g. "Enforcement failed") render plainly with no toggle.
+ */
+function ActivityError({ message }: { message: string }) {
+ const [expanded, setExpanded] = useState(false);
+ const needsToggle = message.length > 80 || message.includes("\n");
+ if (!needsToggle) return <>{message}>;
+ return (
+
+
+ {message}
+
+ setExpanded((v) => !v)}
+ >
+ {expanded ? "Show less" : "Show more"}
+
+
+ );
+}
+
+/** Narrative view for a configured policy (Enforces / Activity / Stats). */
+export function PolicyDetailPanel({
+ category,
+ config,
+ status,
+ steps,
+ activity,
+ stats,
+ canConfigure,
+ canDelete,
+ onBack,
+ onEditSettings,
+ onTogglePause,
+ onDelete,
+ onRetry,
+}: PolicyDetailPanelProps) {
+ const isPaused = status === "paused";
+ // Real configured steps drive the flow; fall back to the preset's rule labels.
+ const enforceItems =
+ steps && steps.length > 0
+ ? steps.map((s) => humanizeOperation(s.operation))
+ : config.rules;
+ // Activity + stats are derived from the user's real files; until they load (or
+ // if none exist) show an honest empty feed / zeroed stats.
+ const activityItems = activity ?? [];
+ const statValues = stats ?? {
+ enforced: 0,
+ dataProcessed: "0 B",
+ activeFor: "—",
+ };
+ return (
+
+
+ {isPaused ? "Paused" : "Active"}
+
+ }
+ />
+
+
+ {/* Enforces */}
+
+
Enforces
+
+
+
+
+
+
+
+ {config.scopeLabel}
+
+
+
+ On every upload
+
+
+
+
+ Originals stay untouched • Enforced version saved alongside
+
+
+
+
+ {/* Recent Activity */}
+
+
Recent Activity
+ {activityItems.length > 0 ? (
+
+ {activityItems.map((item, i) => (
+ 0}
+ leadingTone={
+ item.status === "flagged"
+ ? "warning"
+ : item.status === "processing"
+ ? "info"
+ : "success"
+ }
+ leading={
+ item.status === "flagged" ? (
+
+ ) : item.status === "processing" ? (
+
+ ) : (
+
+ )
+ }
+ title={item.doc}
+ description={
+ item.status === "flagged" ? (
+
+ ) : (
+ item.action
+ )
+ }
+ meta={item.time}
+ trailing={
+ item.status === "flagged" && onRetry ? (
+ onRetry(item)}
+ >
+ Retry
+
+ ) : undefined
+ }
+ />
+ ))}
+
+ ) : (
+
+ }
+ title="No activity yet"
+ description="Documents will appear here once this policy runs."
+ />
+
+ )}
+
+
+ {/* Stats — one grouped card with divided columns, intentionally
+ unlabelled for a quiet summary footer. */}
+
+
+
+
+ {statValues.enforced.toLocaleString()}
+
+ Docs enforced
+
+
+ {statValues.dataProcessed}
+ Data processed
+
+
+ {statValues.activeFor}
+ Active
+
+
+
+
+ {!canConfigure && (
+
}
+ description="Managed by your organization. Contact an admin to change settings."
+ />
+ )}
+
+
+ {canConfigure && (
+
+ {canDelete && (
+ }
+ onClick={onDelete}
+ style={{ marginRight: "auto" }}
+ >
+ Delete
+
+ )}
+
+ {isPaused ? "Resume" : "Pause"}
+
+
+ Edit Settings
+
+
+ )}
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx
new file mode 100644
index 000000000..ff848100c
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx
@@ -0,0 +1,88 @@
+import { ToggleSwitch } from "@shared/components/ToggleSwitch";
+import { Select } from "@shared/components/Select";
+import { Input } from "@shared/components/Input";
+import { Chip } from "@shared/components/Chip";
+import { SettingsRow } from "@shared/components/SettingsRow";
+import type { PolicyField } from "@app/types/policies";
+
+interface PolicyFieldRowProps {
+ field: PolicyField;
+ /** Effective current value (override or definition default). */
+ value: boolean | string | string[];
+ onChange: (value: boolean | string | string[]) => void;
+ /** First row in a group omits the top divider. */
+ first?: boolean;
+}
+
+/**
+ * Renders one policy setting: toggle, select, multi-select chips, or text.
+ * Controlled — the parent owns the value. Uses SUI controls (ToggleSwitch /
+ * Select / Input / Chip) so it matches the rest of the policy surface.
+ */
+export function PolicyFieldRow({
+ field,
+ value,
+ onChange,
+ first,
+}: PolicyFieldRowProps) {
+ if (field.type === "chips") {
+ const selected = Array.isArray(value) ? value : [];
+ const toggle = (opt: string) =>
+ onChange(
+ selected.includes(opt)
+ ? selected.filter((o) => o !== opt)
+ : [...selected, opt],
+ );
+ return (
+
+
+ {field.label}
+ {selected.length} selected
+
+
+ {(field.options ?? []).map((opt) => (
+ toggle(opt)}
+ >
+ {opt}
+
+ ))}
+
+
+ );
+ }
+
+ const control =
+ field.type === "toggle" ? (
+ onChange(checked)}
+ aria-label={field.label}
+ />
+ ) : field.type === "select" ? (
+ ({ value: o, label: o }))}
+ value={typeof value === "string" ? value : ""}
+ onChange={(e) => onChange(e.target.value)}
+ aria-label={field.label}
+ />
+ ) : (
+ onChange(e.target.value)}
+ aria-label={field.label}
+ />
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx
new file mode 100644
index 000000000..ed09a0d1d
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx
@@ -0,0 +1,61 @@
+import { MultiSelect } from "@mantine/core";
+import { PII_PRESETS } from "@app/data/policyDefinitions";
+
+/** The set of preset regexes — used to separate preset words from custom ones. */
+export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
+const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
+const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
+
+interface PolicyPiiFieldProps {
+ parameters: Record;
+ onChange: (parameters: Record) => void;
+ disabled?: boolean;
+}
+
+/**
+ * The redact step's PII preset picker — a dropdown of common PII types that
+ * writes the matching regexes into `wordsToRedact` (automatic + regex). It only
+ * manages the preset entries; any custom patterns the user added separately are
+ * left untouched, so this sits alongside the custom-entry field rather than
+ * owning the whole list.
+ */
+export function PolicyPiiField({
+ parameters,
+ onChange,
+ disabled,
+}: PolicyPiiFieldProps) {
+ const words = Array.isArray(parameters.wordsToRedact)
+ ? (parameters.wordsToRedact as string[])
+ : [];
+ const selected = words
+ .map((w) => VALUE_BY_PATTERN.get(w))
+ .filter((v): v is string => Boolean(v));
+
+ const handleChange = (values: string[]) => {
+ const presetPatterns = values
+ .map((v) => PATTERN_BY_VALUE.get(v))
+ .filter((p): p is string => Boolean(p));
+ // Keep the user's custom patterns; only swap the preset selection.
+ const customWords = words.filter((w) => !PRESET_PATTERNS.has(w));
+ onChange({
+ ...parameters,
+ mode: "automatic",
+ useRegex: true,
+ wordsToRedact: [...presetPatterns, ...customWords],
+ });
+ };
+
+ return (
+ ({ value: p.value, label: p.label }))}
+ value={selected}
+ onChange={handleChange}
+ disabled={disabled}
+ clearable
+ checkIconPosition="right"
+ />
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx
new file mode 100644
index 000000000..0d203d20d
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx
@@ -0,0 +1,128 @@
+import { useEffect } from "react";
+import {
+ Stack,
+ Divider,
+ ColorInput,
+ NumberInput,
+ Checkbox,
+} from "@mantine/core";
+import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput";
+import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
+import {
+ PolicyPiiField,
+ PRESET_PATTERNS,
+} from "@app/components/policies/PolicyPiiField";
+
+interface PolicyRedactConfigProps {
+ parameters: Record;
+ onChange: (parameters: Record) => void;
+ disabled?: boolean;
+}
+
+/**
+ * Redact configuration for a policy: a PII preset dropdown plus a separate field
+ * for the user's own words / regexes, then the advanced redact options. The two
+ * lists are kept disjoint — the dropdown owns the preset patterns, the custom
+ * field owns everything else — so selecting presets and typing custom patterns
+ * don't clobber each other. Regex matching is always on (a plain word is a
+ * literal regex), so the Use-Regex toggle is omitted. Mode stays automatic since
+ * policies run headless and manual redaction needs the canvas.
+ */
+export function PolicyRedactConfig({
+ parameters,
+ onChange,
+ disabled,
+}: PolicyRedactConfigProps) {
+ const words = Array.isArray(parameters.wordsToRedact)
+ ? (parameters.wordsToRedact as string[])
+ : [];
+ // Split the stored list: presets are driven by the dropdown, the rest by the
+ // custom field below. Each editor only ever rewrites its own half.
+ const presetWords = words.filter((w) => PRESET_PATTERNS.has(w));
+ const customWords = words.filter((w) => !PRESET_PATTERNS.has(w));
+
+ const redactColor =
+ typeof parameters.redactColor === "string"
+ ? parameters.redactColor
+ : "#000000";
+ const customPadding =
+ typeof parameters.customPadding === "number"
+ ? parameters.customPadding
+ : 0.1;
+ const wholeWordSearch = parameters.wholeWordSearch === true;
+ const convertPDFToImage = parameters.convertPDFToImage !== false; // default on
+
+ // Regex is always on for policies; heal any older/default-false value once on
+ // mount (empty deps — we only need to normalise the persisted flag).
+ useEffect(() => {
+ if (parameters.useRegex !== true) {
+ onChange({ ...parameters, useRegex: true });
+ }
+ }, []);
+
+ // Every edit keeps mode automatic + regex on (the two policy invariants).
+ const patch = (next: Record) =>
+ onChange({ ...parameters, mode: "automatic", useRegex: true, ...next });
+
+ return (
+
+
+
+
+
+
+ patch({ wordsToRedact: [...presetWords, ...next] })
+ }
+ disabled={disabled}
+ />
+
+
+
+ patch({ redactColor: value })}
+ disabled={disabled}
+ size="sm"
+ format="hex"
+ popoverProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
+ />
+
+
+ patch({ customPadding: typeof value === "number" ? value : 0.1 })
+ }
+ min={0}
+ max={10}
+ step={0.1}
+ disabled={disabled}
+ size="sm"
+ placeholder="0.1"
+ />
+
+ patch({ wholeWordSearch: e.currentTarget.checked })}
+ disabled={disabled}
+ size="sm"
+ />
+
+ patch({ convertPDFToImage: e.currentTarget.checked })}
+ disabled={disabled}
+ size="sm"
+ />
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx
new file mode 100644
index 000000000..7078277af
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx
@@ -0,0 +1,602 @@
+import { useState, useMemo, useRef } from "react";
+import CloseIcon from "@mui/icons-material/Close";
+import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
+import { PanelHeader } from "@shared/components/PanelHeader";
+import { Card } from "@shared/components/Card";
+import { Button } from "@shared/components/Button";
+import { ChipFlow } from "@shared/components/ChipFlow";
+import { DataRow } from "@shared/components/DataRow";
+import { Input } from "@shared/components/Input";
+import { Select } from "@shared/components/Select";
+import { SettingsRow } from "@shared/components/SettingsRow";
+import { FormField } from "@shared/components/FormField";
+import { Checkbox } from "@shared/components/Checkbox";
+import { Banner } from "@shared/components/Banner";
+import { EmptyState } from "@shared/components/EmptyState";
+import { StepIndicator } from "@shared/components/StepIndicator";
+import { IconBadge } from "@shared/components/IconBadge";
+import type {
+ PolicyCategory,
+ PolicyConfigDef,
+ PolicyConfigResult,
+ PolicySource,
+ PolicyState,
+ PolicyWizardResult,
+} from "@app/types/policies";
+import type {
+ AutomationConfig,
+ AutomationOperation,
+} from "@app/types/automation";
+import type { ToolRegistry } from "@app/data/toolsTaxonomy";
+import type { WatchedFolder } from "@app/types/watchedFolders";
+import { buildPipelineDefinition } from "@app/services/policyPipeline";
+import { useAuth } from "@app/auth/UseSession";
+import { PolicyFieldRow } from "@app/components/policies/PolicyFieldRow";
+import { resolveFieldValues } from "@app/components/policies/policyValues";
+import {
+ PolicyWorkflowStep,
+ AutomationMode,
+} from "@app/components/policies/PolicyWorkflowStep";
+import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep";
+import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
+
+// Sources are always "editor" for this release, so the Sources step is dropped
+// from the flow (its panel code is kept below for when other sources return).
+const SOURCES_IN_FLOW = false;
+const TOTAL_STEPS = SOURCES_IN_FLOW ? 4 : 3;
+
+interface PolicySetupWizardProps {
+ category: PolicyCategory;
+ config: PolicyConfigDef;
+ initial: PolicyState;
+ /** Sources a policy can run over (catalog-supplied). */
+ sources: PolicySource[];
+ /** Document types scope can be narrowed to (catalog-supplied). */
+ docTypes: string[];
+ canConfigure: boolean;
+ /** Whether the Classification (ingestion) policy is active — gates doc-type narrowing. */
+ classificationEnabled: boolean;
+ /** "create" seeds the workflow from the preset; "edit" loads the backing automation. */
+ mode?: "create" | "edit";
+ /** The backing automation to edit (edit mode). */
+ existingAutomation?: AutomationConfig;
+ /** The backing folder, to pre-fill output + retry settings (edit mode). */
+ initialFolder?: WatchedFolder;
+ onCancel: () => void;
+ /**
+ * Fires on submit with the saved workflow + collected settings. May be async;
+ * if the returned promise rejects, the wizard re-enables submit and surfaces
+ * the failure rather than hanging on a permanently-disabled button.
+ */
+ onComplete: (result: PolicyWizardResult) => void | Promise;
+ /**
+ * For preset (tool-chain) policies whose Workflow step is the locked tool
+ * config: fires instead of `onComplete`, carrying the configured tools as
+ * operations + mapped pipeline steps. When absent the wizard uses the
+ * add/remove builder + `onComplete`.
+ */
+ onCommitConfig?: (result: PolicyConfigResult) => void | Promise;
+ onSetupClassification: () => void;
+}
+
+/**
+ * The shared policy wizard, used for both setup and edit. Four steps:
+ * Workflow (the tool pipeline, reusing the Watch Folders builder) → Settings
+ * (the policy fields) → Sources → Review. The workflow builder is kept mounted
+ * across steps so the final action can trigger its save.
+ */
+export function PolicySetupWizard({
+ category,
+ config,
+ initial,
+ sources: sourceDefs,
+ docTypes,
+ canConfigure,
+ classificationEnabled,
+ mode = "create",
+ existingAutomation,
+ initialFolder,
+ onCancel,
+ onComplete,
+ onCommitConfig,
+ onSetupClassification,
+}: PolicySetupWizardProps) {
+ const isEdit = mode === "edit";
+ // Preset (tool-chain) policies render the locked tool config as their Workflow
+ // step instead of the add/remove builder.
+ const toolChain = getPolicyToolChain(category.id);
+ const { user } = useAuth();
+ const [step, setStep] = useState(1);
+ const [fieldValues, setFieldValues] = useState(() =>
+ resolveFieldValues(config, initial),
+ );
+ const [sources, setSources] = useState(
+ initial.sources.length ? initial.sources : ["editor"],
+ );
+ const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0);
+ const [scopeTypes, setScopeTypes] = useState(initial.scopeTypes);
+ // Default flagged-document reviewer to the signed-in user.
+ const [reviewerEmail, setReviewerEmail] = useState(
+ initial.reviewerEmail || user?.email || "",
+ );
+ // Output + retry settings — the real, working folder settings (the engine
+ // applies them). Pre-filled from the backing folder in edit mode.
+ const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
+ initialFolder?.outputMode ?? "new_file",
+ );
+ const [outputName, setOutputName] = useState(initialFolder?.outputName ?? "");
+ const [outputNamePosition, setOutputNamePosition] = useState<
+ "prefix" | "suffix" | "auto-number"
+ >(initialFolder?.outputNamePosition ?? "prefix");
+ const [maxRetries, setMaxRetries] = useState(initialFolder?.maxRetries ?? 3);
+ const [retryDelayMinutes, setRetryDelayMinutes] = useState(
+ initialFolder?.retryDelayMinutes ?? 5,
+ );
+ const workflowSave = useRef<(() => void) | null>(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ // Seed the workflow builder: the backing automation in edit, else a synthetic
+ // config carrying the category preset's operations (created on save).
+ const seedAutomation = useMemo(
+ () =>
+ existingAutomation ?? {
+ id: "",
+ name: `${category.label} Policy`,
+ description: `${category.label} policy workflow`,
+ icon: "WorkIcon",
+ operations: config.defaultOperations,
+ createdAt: "",
+ updatedAt: "",
+ },
+ [existingAutomation, category.label, config.defaultOperations],
+ );
+
+ if (!canConfigure) {
+ return (
+
+ );
+ }
+
+ const back = () =>
+ step > 1 ? setStep((s) => Math.max(1, s - 1)) : onCancel();
+
+ const toggleSource = (id: string) =>
+ setSources((prev) =>
+ prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
+ );
+
+ // Once the builder persists the workflow, map its operations to backend
+ // endpoint paths (the registry only lives in the Workflow step) and hand the
+ // automation + built steps + settings to the host (which closes the wizard on
+ // success). If the host's async save rejects, recover so the submit button
+ // doesn't stay disabled forever.
+ const handleWorkflowSaved = (
+ automation: AutomationConfig,
+ toolRegistry: Partial,
+ ) => {
+ const { definition, unresolved } = buildPipelineDefinition(
+ automation,
+ toolRegistry,
+ );
+ Promise.resolve(
+ onComplete({
+ automation,
+ fieldValues,
+ sources,
+ scopeTypes: scopeNarrow ? scopeTypes : [],
+ reviewerEmail,
+ folder: {
+ outputMode,
+ outputName: outputName.trim(),
+ outputNamePosition,
+ maxRetries,
+ retryDelayMinutes,
+ },
+ pipelineSteps: definition.steps,
+ unresolvedOps: unresolved,
+ }),
+ ).catch(() => {
+ setSubmitting(false);
+ setSaveError("Couldn't save the policy. Please try again.");
+ });
+ };
+
+ // Tool-chain policies: the locked config step emits its enabled tools as
+ // operations + mapped steps; hand them to the host's commit path.
+ const handleToolConfigSaved = (
+ operations: AutomationOperation[],
+ pipelineSteps: { operation: string; parameters: Record }[],
+ unresolvedOps: string[],
+ ) => {
+ Promise.resolve(
+ onCommitConfig?.({
+ operations,
+ pipelineSteps,
+ unresolvedOps,
+ fieldValues,
+ sources,
+ scopeTypes: scopeNarrow ? scopeTypes : [],
+ reviewerEmail,
+ folder: {
+ outputMode,
+ outputName: outputName.trim(),
+ outputNamePosition,
+ maxRetries,
+ retryDelayMinutes,
+ },
+ }),
+ ).catch(() => {
+ setSubmitting(false);
+ setSaveError("Couldn't save the policy. Please try again.");
+ });
+ };
+
+ // Final submit: guard against double-submit (the step stays mounted, so a
+ // second click would persist twice), then trigger the step's save.
+ const submit = () => {
+ if (submitting) return;
+ setSaveError(null);
+ setSubmitting(true);
+ workflowSave.current?.();
+ };
+
+ // The builder couldn't save (e.g. no configured tools) — surface it and send
+ // the user back to the Workflow step to fix it.
+ const handleSaveFailed = () => {
+ setSubmitting(false);
+ setSaveError("Add at least one configured tool to the workflow first.");
+ setStep(1);
+ };
+
+ return (
+
+
}
+ />
+ }
+ />
+
+
+
+
+
+
+ {saveError && (
+
}
+ description={saveError}
+ />
+ )}
+ {/* Step 1 — Workflow. Kept mounted (hidden on other steps) so the final
+ submit can trigger its save. Preset (tool-chain) policies show the
+ locked, per-tool config; the rest show the add/remove builder. */}
+
+ {toolChain ? (
+ <>
+
+ Configure the tools this policy runs on each document.
+
+
+ >
+ ) : (
+ <>
+
+ Build the sequence of tools this policy runs on each document.
+
+
+ >
+ )}
+
+
+ {step === 2 && (
+ <>
+
{category.desc}
+
+ {config.fields.map((f, i) => (
+
+ setFieldValues((prev) => ({ ...prev, [f.key]: v }))
+ }
+ />
+ ))}
+
+
+ {/* Real, working output + retry settings (applied by the engine). */}
+
Output & retries
+
+
+
+ setOutputMode(
+ e.target.value as "new_file" | "new_version",
+ )
+ }
+ aria-label="Output mode"
+ options={[
+ { value: "new_file", label: "New file" },
+ { value: "new_version", label: "New version" },
+ ]}
+ />
+ }
+ />
+
+
+ setOutputName(e.target.value)}
+ placeholder="optional"
+ aria-label="Output name"
+ />
+ }
+ />
+
+
+
+ setOutputNamePosition(
+ e.target.value as "prefix" | "suffix" | "auto-number",
+ )
+ }
+ aria-label="Name position"
+ options={[
+ { value: "prefix", label: "Prefix" },
+ { value: "suffix", label: "Suffix" },
+ { value: "auto-number", label: "Auto-number" },
+ ]}
+ />
+ }
+ />
+
+
+
+ setMaxRetries(Math.max(0, Number(e.target.value) || 0))
+ }
+ aria-label="Max retries"
+ />
+ }
+ />
+
+
+
+ setRetryDelayMinutes(
+ Math.max(0, Number(e.target.value) || 0),
+ )
+ }
+ aria-label="Retry delay minutes"
+ />
+ }
+ />
+
+
+ >
+ )}
+
+ {/* Sources step — kept in code, out of the flow for this release
+ (SOURCES_IN_FLOW), since sources are always "editor" for now. */}
+ {SOURCES_IN_FLOW && step === 3 && (
+ <>
+
+ Choose where this policy runs and which document types it applies
+ to.
+
+
Sources
+
+ {sourceDefs.map((src, i) => (
+
+ toggleSource(src.id)}
+ leadingIcon={src.icon}
+ label={src.label}
+ description={src.desc}
+ />
+
+ ))}
+
+
+
Document types
+ {!classificationEnabled ? (
+
}
+ title="All document types"
+ description="Enable the Classification policy to filter by document type."
+ action={
+
+ Set up Classification
+
+ }
+ />
+ ) : (
+
+
+
+ {scopeTypes.length === 0
+ ? "All document types"
+ : `${scopeTypes.length} types selected`}
+
+ setScopeNarrow((v) => !v)}
+ >
+ {scopeNarrow ? "Clear" : "Edit"}
+
+
+ {scopeNarrow && (
+
+ {docTypes.map((dt) => (
+
+ setScopeTypes((prev) =>
+ prev.includes(dt)
+ ? prev.filter((d) => d !== dt)
+ : [...prev, dt],
+ )
+ }
+ label={dt}
+ />
+ ))}
+
+ )}
+
+ )}
+ >
+ )}
+
+ {step === TOTAL_STEPS && (
+ <>
+
+ When Stirling has low confidence in an enforcement action, it will
+ send the document for human review.
+
+
Reviewer
+
+
+ setReviewerEmail(e.target.value)}
+ placeholder="email@company.com"
+ />
+
+
+
+
Summary
+
+
+
+ {category.icon}
+
+
+ {category.label} Policy
+
+
+
+
+
+
+ {SOURCES_IN_FLOW && (
+ {sources.length} selected
+ )}
+
+ {reviewerEmail || Not set }
+
+
+
+ >
+ )}
+
+
+
+
+ {step > 1 ? "Back" : "Cancel"}
+
+ {step < TOTAL_STEPS ? (
+ setStep((s) => Math.min(TOTAL_STEPS, s + 1))}
+ >
+ Continue
+
+ ) : (
+
+ {isEdit ? "Save Changes" : "Enable Policy"}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx
new file mode 100644
index 000000000..497cad780
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx
@@ -0,0 +1,96 @@
+import { Suspense } from "react";
+import { Loader } from "@mantine/core";
+import { ToggleSwitch } from "@shared/components/ToggleSwitch";
+import { Card } from "@shared/components/Card";
+import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
+import type { ToolRegistry } from "@app/data/toolsTaxonomy";
+import type { ToolId } from "@app/types/toolId";
+
+/** One tool in a policy's fixed chain: whether it runs + its configured params. */
+export interface PolicyToolState {
+ /** Frontend tool-registry id (also the registry key + the thing we map to an endpoint). */
+ operation: string;
+ /** Whether this tool runs as part of the policy (the per-tool on/off). */
+ enabled: boolean;
+ /** Tool-specific parameters (the shape its endpoint accepts). */
+ parameters: Record;
+}
+
+interface PolicyToolConfigProps {
+ /** The policy's fixed tool chain — locked (no add/remove), only configurable. */
+ tools: PolicyToolState[];
+ toolRegistry: Partial;
+ onChange: (tools: PolicyToolState[]) => void;
+ /** Read-only when the policy is managed / the user can't configure. */
+ editable?: boolean;
+}
+
+/**
+ * Locked, configure-only tool panel for a policy. The chain is fixed (you can't
+ * add or remove tools); each tool is a section that renders its OWN settings form
+ * from the tool registry (`automationSettings`) — the same forms the automation
+ * builder uses — so the config is generated from the tools in the workflow, not
+ * hardcoded per policy. The parameters produced here are exactly what the backend
+ * engine POSTs to each tool's endpoint.
+ */
+export function PolicyToolConfig({
+ tools,
+ toolRegistry,
+ onChange,
+ editable = true,
+}: PolicyToolConfigProps) {
+ const patchTool = (index: number, patch: Partial) =>
+ onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t)));
+
+ return (
+
+ {tools.map((tool, index) => {
+ const entry = toolRegistry[tool.operation as ToolId];
+ const Settings = entry?.automationSettings ?? null;
+ return (
+
+
+ {entry?.icon}
+
+ {entry?.name ?? tool.operation}
+
+ patchTool(index, { enabled: checked })}
+ aria-label={`Enable ${entry?.name ?? tool.operation}`}
+ />
+
+ {tool.enabled &&
+ (tool.operation === "redact" ? (
+ // Redact has a bespoke config: PII preset dropdown + a custom
+ // word/regex field + advanced options, mode + regex locked on.
+
+
patchTool(index, { parameters })}
+ disabled={!editable}
+ />
+
+ ) : Settings ? (
+
+ }>
+
+ patchTool(index, {
+ parameters: { ...tool.parameters, [key]: value },
+ })
+ }
+ disabled={!editable}
+ />
+
+
+ ) : null)}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx b/frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx
new file mode 100644
index 000000000..472d1852f
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx
@@ -0,0 +1,114 @@
+import { useState, useEffect, type MutableRefObject } from "react";
+import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
+import { buildPipelineDefinition } from "@app/services/policyPipeline";
+import {
+ PolicyToolConfig,
+ type PolicyToolState,
+} from "@app/components/policies/PolicyToolConfig";
+import type { ToolId } from "@app/types/toolId";
+import type { AutomationOperation } from "@app/types/automation";
+
+/**
+ * Seed a tool's parameters: start from the tool's own registry defaults, overlay
+ * the preset's defaults (e.g. the PII patterns), then apply only the saved
+ * values the user actually changed from the tool default. This means a policy
+ * saved while a param was at its default (an empty redact list) still inherits
+ * the preset value, while genuine user edits are preserved.
+ */
+function seedToolParameters(
+ registryDefaults: Record,
+ presetParams: Record,
+ savedParams: Record,
+): Record {
+ const merged: Record = {
+ ...registryDefaults,
+ ...presetParams,
+ };
+ const eq = (a: unknown, b: unknown) =>
+ JSON.stringify(a) === JSON.stringify(b);
+ for (const [key, value] of Object.entries(savedParams)) {
+ if (!eq(value, registryDefaults[key])) merged[key] = value;
+ }
+ return merged;
+}
+
+interface PolicyToolConfigStepProps {
+ /** The fixed, configurable tool chain (locked set) for this policy. */
+ chainIds: string[];
+ /** Operations to seed enabled/params from (saved ops, or preset defaults). */
+ initialOperations: AutomationOperation[];
+ /**
+ * The preset's default operations. Their params seed any tool whose saved
+ * value is still at the tool's own default — so e.g. a policy saved before the
+ * PII patterns existed inherits them rather than running with an empty list.
+ */
+ presetOperations: AutomationOperation[];
+ /** Used to name the built pipeline definition. */
+ categoryLabel: string;
+ /** The wizard triggers this on its final submit (mirrors PolicyWorkflowStep). */
+ saveTriggerRef: MutableRefObject<(() => void) | null>;
+ /** Emits the enabled tools as operations + the endpoint-mapped backend steps. */
+ onComplete: (
+ operations: AutomationOperation[],
+ pipelineSteps: { operation: string; parameters: Record }[],
+ unresolvedOps: string[],
+ ) => void;
+}
+
+/**
+ * The wizard's Workflow step for preset (tool-chain) policies: the locked,
+ * per-tool config ({@link PolicyToolConfig}) instead of the add/remove builder.
+ * Isolates the ToolWorkflow dependency (so it's mockable in the rail tests) and
+ * wires the wizard's submit trigger to emit the configured tools as operations
+ * + the endpoint-mapped pipeline steps.
+ */
+export function PolicyToolConfigStep({
+ chainIds,
+ initialOperations,
+ presetOperations,
+ categoryLabel,
+ saveTriggerRef,
+ onComplete,
+}: PolicyToolConfigStepProps) {
+ const { toolRegistry } = useToolWorkflow();
+
+ const [tools, setTools] = useState(() =>
+ chainIds.map((op) => {
+ const saved = initialOperations.find((o) => o.operation === op);
+ const preset = presetOperations.find((o) => o.operation === op);
+ const defaults = (toolRegistry[op as ToolId]?.operationConfig
+ ?.defaultParameters ?? {}) as Record;
+ return {
+ operation: op,
+ enabled: Boolean(saved),
+ parameters: seedToolParameters(
+ defaults,
+ (preset?.parameters ?? {}) as Record,
+ (saved?.parameters ?? {}) as Record,
+ ),
+ };
+ }),
+ );
+
+ // Re-wire the submit trigger whenever the tools change so it emits the latest.
+ useEffect(() => {
+ saveTriggerRef.current = () => {
+ const operations: AutomationOperation[] = tools
+ .filter((t) => t.enabled)
+ .map((t) => ({ operation: t.operation, parameters: t.parameters }));
+ const { definition, unresolved } = buildPipelineDefinition(
+ { name: `${categoryLabel} Policy`, operations },
+ toolRegistry,
+ );
+ onComplete(operations, definition.steps, unresolved);
+ };
+ }, [tools, toolRegistry, categoryLabel, saveTriggerRef, onComplete]);
+
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx
new file mode 100644
index 000000000..842bf07e8
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx
@@ -0,0 +1,61 @@
+import type { MutableRefObject } from "react";
+import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
+import { AutomationMode } from "@app/types/automation";
+import type { AutomationConfig } from "@app/types/automation";
+import type { ToolRegistry } from "@app/data/toolsTaxonomy";
+import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
+
+interface PolicyWorkflowStepProps {
+ /**
+ * The automation to seed/edit. For setup this is a synthetic config carrying
+ * the category preset's operations; for edit it's the policy's backing
+ * automation.
+ */
+ automation: AutomationConfig;
+ /** SUGGESTED seeds-then-creates (setup); EDIT updates in place (settings). */
+ mode: AutomationMode;
+ /** The host (wizard) triggers the builder's save imperatively from its footer. */
+ saveTriggerRef: MutableRefObject<(() => void) | null>;
+ /**
+ * Called with the saved automation once the builder persists it, plus the tool
+ * registry (which lives here) so the wizard can map operations to backend
+ * endpoint paths without depending on the ToolWorkflow context itself.
+ */
+ onComplete: (
+ automation: AutomationConfig,
+ toolRegistry: Partial,
+ ) => void;
+ /** Called when save is triggered but the workflow isn't in a saveable state. */
+ onSaveFailed?: () => void;
+}
+
+/**
+ * The policy wizard's "Workflow" step: the Watch Folders automation builder
+ * ({@link AutomationCreation}) reused to define a policy's tool pipeline. Kept
+ * as its own component so the heavy builder + its ToolWorkflow dependency are
+ * isolated (and mockable in the rail tests).
+ */
+export function PolicyWorkflowStep({
+ automation,
+ mode,
+ saveTriggerRef,
+ onComplete,
+ onSaveFailed,
+}: PolicyWorkflowStepProps) {
+ const { toolRegistry } = useToolWorkflow();
+ return (
+ {}}
+ onComplete={(saved) => onComplete(saved, toolRegistry)}
+ onSaveFailed={onSaveFailed}
+ />
+ );
+}
+
+export { AutomationMode };
diff --git a/frontend/editor/src/proprietary/components/policies/README.md b/frontend/editor/src/proprietary/components/policies/README.md
new file mode 100644
index 000000000..df6535ba1
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/README.md
@@ -0,0 +1,86 @@
+# Policies (frontend)
+
+Automation-backed document-enforcement policies — conceptually like Watch
+Folders but backend-driven, with non-folder triggers (editor save/export,
+device sweeps, cloud connectors). **This is the frontend only**: per-policy
+state is persisted locally (localStorage) and activity + stats are derived from
+your real uploaded files; server persistence + real enforcement land in a
+follow-up. It ships behind the `POLICIES_ENABLED` feature flag (proprietary
+build = on while in development, core build = off).
+
+## Layout
+
+| Path | Role |
+|------|------|
+| `types/policies.ts` | Type model (category, fields, state). |
+| `data/policyDefinitions.tsx` | Static preset definitions for the catalog: 5 categories (with the `providesClassification` data flag), per-category config fields, sources, doc types, and each category's default tool pipeline. Read it through `policyCatalog`, not directly. |
+| `services/policyCatalog.ts` | **The definitions seam.** `loadPolicyCatalog()` returns categories/configs/sources/doc-types. Components reach definitions only through here (via `usePolicyCatalog`) — swap this one function for a backend fetch to go live without touching a component. |
+| `hooks/usePolicyCatalog.ts` | Hook over the catalog seam (memoised; where loading/error state lands when it becomes async). |
+| `services/policyStorage.ts` | Local persistence (localStorage) of per-policy **state** + change events. Swap this layer for the real API. |
+| `hooks/usePolicies.ts` | State + lifecycle actions + permission flag. |
+| `services/policyLiveData.ts` | Derives the detail view's activity feed + stats from the user's real uploaded files. |
+| `components/policies/PoliciesSidebar.tsx` | The three right-rail slots: list section, detail takeover, collapsed-rail icons (+ `usePoliciesEnabled` / `usePolicyDetailActive`). Shadows the core stub. |
+| `components/policies/policySelectionStore.ts` | Shared selected-policy / detail-view store the three slots sync through. |
+| `components/policies/PolicySetupWizard.tsx` | 3-step setup (operations → sources/types → reviewer/confirm). |
+| `components/policies/PolicyDetailPanel.tsx` | Configured "narrative" view (Enforces / Activity / Stats). |
+| `components/policies/PolicySettingsForm.tsx` | Edit-settings sub-view. |
+| `components/policies/PolicyFieldRow.tsx` | toggle / select / chips / text field renderer (SUI `SettingsRow` + `ToggleSwitch`/`Select`/`Input`/`Chip`). |
+
+The core build gets a no-op stub at `core/components/policies/PoliciesSidebar.tsx`;
+`RightSidebar` (core) consumes the seam, so the section appears only in
+proprietary builds.
+
+## Design system (SUI + Mantine)
+
+The surface is composed almost entirely from the shared SUI design system
+(`@shared/components`), mixed with Mantine only where SUI has no equivalent.
+SUI components used here: `PanelHeader` (+ leading `IconBadge`), `Card`,
+`Button`, `Chip`, `ChipFlow`, `StatusBadge`, `Banner`, `EmptyState`,
+`MetricCard`, `Input`, `Select`, `ToggleSwitch`, `Checkbox`, `FormField`,
+`NavItem` (status `accent`), `ListRow`, `DataRow`, `SectionHeader`,
+`StepIndicator`. Several of those (`IconBadge`, `ListRow`, `DataRow`,
+`SectionHeader`, `StepIndicator`, `ChipFlow`, `SettingsRow`, plus the `NavItem`
+accent / `PanelHeader` icon slot / `Checkbox` leadingIcon / `MetricCard size`)
+were **built up in SUI** as part of this work — each has a Storybook story.
+
+Bootstrapping: the editor loads `@shared/tokens/tokens.css` globally via
+`RainbowThemeProvider`, which also mirrors the Mantine colour scheme onto
+`` (SUI's dark palette keys on `data-theme`). A global
+`@shared` alias in `editor/vite.config.ts` + `vitest.config.ts` resolves the
+shared components' own `@shared/*.css` self-imports.
+
+The bespoke `.pol-*` CSS in `Policies.css` is now only thin layout scaffolding
+(detail/scroll/footer wrappers, the collapsed rail, row insets that match SUI
+`ListRow`); spacing snaps to the SUI `--space-*` scale and colour to the SUI
+token set.
+
+**Status-colour convention (locked):** blue = accent/identity (NavItem accent
+bar, rail icon, detail Card accent — the prototype's blue); green `success`
+StatusBadge = the "Active" pill/dot everywhere (list + detail + rail dot);
+amber = paused. Configured rows render as raised cards (surface + border).
+
+## Faithful to the prototype
+
+5 categories (Ingestion, Security, Compliance, Routing, Retention), their full
+field sets, the 3-step wizard (incl. the doc-type step gated behind the
+Classification/ingestion policy), the configured narrative view (Enforces /
+recent-activity feed / three-up stats), settings, the permission model
+(owner/admin/member + solo), and the
+docked right-sidebar placement: a collapsible **Policies** list above Tools, a
+detail view that takes over the rail when a policy is open, and a collapsed-rail
+of policy icons with active/paused status dots.
+
+## Deviations / follow-ups
+
+- **Billing upgrade flows.** The prototype's free → pay-as-you-go → enterprise
+ upgrade/commit/bespoke modals live in the Settings billing tab — a
+ billing-integration surface (Stripe/org state that doesn't exist yet),
+ deferred. The in-rail surface shows only the spend-limit warning chip, as in
+ the prototype's policy section.
+- **Backend.** All persistence, enforcement, activity, and stats are mock. To
+ go live, replace `services/policyStorage.ts` and feed real activity/stats.
+
+## Tests
+
+`services/policyStorage.test.ts` (seed/update/reset/heal/events) and
+`data/policyDefinitions.test.ts` (permission matrix + definition integrity).
diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts
new file mode 100644
index 000000000..8a62cbfcc
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import {
+ dispatchKey,
+ isDispatched,
+ markDispatched,
+ recordRunStart,
+ updateRun,
+ resetPolicyRuns,
+ type PolicyRunRecord,
+} from "@app/components/policies/policyRunStore";
+
+function rec(over: Partial): PolicyRunRecord {
+ return {
+ runId: "r1",
+ categoryId: "security",
+ fileId: "f1",
+ fileName: "f.pdf",
+ fileSize: 10,
+ status: "PENDING",
+ outputs: [],
+ error: null,
+ startedAt: 1,
+ ...over,
+ };
+}
+
+// The store reads localStorage at import; reset state + storage per test.
+function read(key: string) {
+ return JSON.parse(localStorage.getItem(key) ?? "{}");
+}
+
+describe("policyRunStore", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ resetPolicyRuns();
+ });
+
+ it("records a run start and marks the (policy, file) pair dispatched", () => {
+ expect(isDispatched("security", "f1")).toBe(false);
+ recordRunStart(rec({}));
+ expect(isDispatched("security", "f1")).toBe(true);
+ const stored = read("stirling-policy-runs");
+ expect(stored.runs).toHaveLength(1);
+ expect(stored.dispatched).toContain(dispatchKey("security", "f1"));
+ });
+
+ it("markDispatched is idempotent and independent of a run record", () => {
+ markDispatched("routing", "f9");
+ markDispatched("routing", "f9");
+ expect(isDispatched("routing", "f9")).toBe(true);
+ expect(read("stirling-policy-runs").dispatched).toHaveLength(1);
+ });
+
+ it("updateRun patches an in-flight run's status + outputs", () => {
+ recordRunStart(rec({ runId: "abc" }));
+ updateRun("abc", {
+ status: "COMPLETED",
+ outputs: [{ fileId: "out-1", fileName: "redacted.pdf" }],
+ });
+ const run = read("stirling-policy-runs").runs[0];
+ expect(run.status).toBe("COMPLETED");
+ expect(run.outputs).toEqual([
+ { fileId: "out-1", fileName: "redacted.pdf" },
+ ]);
+ });
+
+ it("updateRun ignores an unknown run id", () => {
+ recordRunStart(rec({ runId: "abc", status: "PENDING" }));
+ updateRun("nope", { status: "FAILED" });
+ expect(read("stirling-policy-runs").runs[0].status).toBe("PENDING");
+ });
+
+ it("caps stored runs at 50, newest first", () => {
+ for (let i = 0; i < 55; i++) {
+ recordRunStart(rec({ runId: `r${i}`, fileId: `f${i}`, startedAt: i }));
+ }
+ const runs = read("stirling-policy-runs").runs;
+ expect(runs).toHaveLength(50);
+ expect(runs[0].runId).toBe("r54"); // most recent
+ });
+});
diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts
new file mode 100644
index 000000000..77eb931b5
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts
@@ -0,0 +1,155 @@
+/**
+ * External store for real backend policy runs (Phase B: auto-run on upload).
+ *
+ * The auto-run controller fires a backend run for each enabled policy × each
+ * newly-uploaded file and records it here; the detail view's activity feed reads
+ * from it. `dispatched` keys (`categoryId:fileId`) ensure a given file is only
+ * ever run once per policy, surviving remounts via localStorage.
+ *
+ * Read with {@code useSyncExternalStore}; mutated by the controller.
+ */
+
+import { useSyncExternalStore } from "react";
+import type { PolicyRunStatus } from "@app/services/policyPipeline";
+
+export interface PolicyRunRecord {
+ runId: string;
+ categoryId: string;
+ fileId: string;
+ fileName: string;
+ fileSize: number;
+ status: PolicyRunStatus;
+ /** Output files (downloadable via /api/v1/general/files/{id}) once done. */
+ outputs: { fileId: string; fileName: string }[];
+ /** True once ALL outputs have been imported into the workspace. */
+ imported?: boolean;
+ /** Output fileIds already imported — tracked per-file so a partial failure
+ * retries only the missing ones and never re-adds the ones that succeeded. */
+ importedFileIds?: string[];
+ error: string | null;
+ /** Epoch ms when the run was dispatched. */
+ startedAt: number;
+}
+
+interface RunState {
+ runs: PolicyRunRecord[];
+ dispatched: string[];
+}
+
+const STORAGE_KEY = "stirling-policy-runs";
+/** Cap stored runs so the activity log can't grow without bound. */
+const MAX_RUNS = 50;
+
+function read(): RunState {
+ try {
+ const raw =
+ typeof localStorage !== "undefined"
+ ? localStorage.getItem(STORAGE_KEY)
+ : null;
+ if (raw) {
+ const parsed = JSON.parse(raw) as Partial;
+ return {
+ // Normalise older persisted records (which predate the `outputs` field)
+ // so consumers can always rely on `outputs` being an array.
+ runs: Array.isArray(parsed.runs)
+ ? parsed.runs.map((r) => ({
+ ...r,
+ outputs: Array.isArray(r.outputs) ? r.outputs : [],
+ importedFileIds: Array.isArray(r.importedFileIds)
+ ? r.importedFileIds
+ : [],
+ }))
+ : [],
+ dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [],
+ };
+ }
+ } catch {
+ // Corrupt/unavailable storage — start empty.
+ }
+ return { runs: [], dispatched: [] };
+}
+
+let state: RunState = read();
+const listeners = new Set<() => void>();
+
+function emit() {
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ }
+ } catch {
+ // Best-effort persistence.
+ }
+ for (const l of listeners) l();
+}
+
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot(): RunState {
+ return state;
+}
+
+const SERVER_SNAPSHOT: RunState = { runs: [], dispatched: [] };
+function getServerSnapshot(): RunState {
+ return SERVER_SNAPSHOT;
+}
+
+/** Key identifying a single (policy, file) run attempt. */
+export function dispatchKey(categoryId: string, fileId: string): string {
+ return `${categoryId}:${fileId}`;
+}
+
+/** Whether this (policy, file) pair has already been dispatched. */
+export function isDispatched(categoryId: string, fileId: string): boolean {
+ return state.dispatched.includes(dispatchKey(categoryId, fileId));
+}
+
+/** Record a newly-dispatched run (marks it dispatched + adds the record). */
+export function recordRunStart(record: PolicyRunRecord) {
+ const key = dispatchKey(record.categoryId, record.fileId);
+ state = {
+ runs: [record, ...state.runs].slice(0, MAX_RUNS),
+ dispatched: state.dispatched.includes(key)
+ ? state.dispatched
+ : [...state.dispatched, key],
+ };
+ emit();
+}
+
+/** Mark a (policy, file) pair dispatched without a run (e.g. dispatch failed). */
+export function markDispatched(categoryId: string, fileId: string) {
+ const key = dispatchKey(categoryId, fileId);
+ if (state.dispatched.includes(key)) return;
+ state = { ...state, dispatched: [...state.dispatched, key] };
+ emit();
+}
+
+/** Patch an in-flight run's status/outputs/error as it progresses. */
+export function updateRun(runId: string, patch: Partial) {
+ let changed = false;
+ const runs = state.runs.map((r) => {
+ if (r.runId !== runId) return r;
+ changed = true;
+ return { ...r, ...patch };
+ });
+ if (!changed) return;
+ state = { ...state, runs };
+ emit();
+}
+
+/** Reset the store — used by tests to isolate it. */
+export function resetPolicyRuns() {
+ state = { runs: [], dispatched: [] };
+ emit();
+}
+
+export function usePolicyRuns(): PolicyRunRecord[] {
+ return useSyncExternalStore(
+ subscribe,
+ () => getSnapshot().runs,
+ () => getServerSnapshot().runs,
+ );
+}
diff --git a/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts
new file mode 100644
index 000000000..451582b90
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts
@@ -0,0 +1,71 @@
+/**
+ * Tiny external store for the currently-selected policy and its detail sub-view.
+ *
+ * The Policies surface is split across two slots in the right tool sidebar — the
+ * list section (above Tools) and the detail takeover (which replaces Tools when a
+ * policy is open) — plus the collapsed-rail icons. They live in different parts of
+ * {@code RightSidebar}'s tree, so selection can't be component-local useState.
+ * This module-level store (read via {@code useSyncExternalStore}) lets all three
+ * stay in sync without threading a context through the core sidebar.
+ */
+
+import { useSyncExternalStore } from "react";
+import type { PolicyDetailView } from "@app/types/policies";
+
+interface PolicySelection {
+ selectedId: string | null;
+ detailView: PolicyDetailView;
+}
+
+let state: PolicySelection = { selectedId: null, detailView: "detail" };
+const listeners = new Set<() => void>();
+
+function emit() {
+ for (const l of listeners) l();
+}
+
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot(): PolicySelection {
+ return state;
+}
+
+/** Deterministic initial snapshot for SSR/hydration (never the mutable store). */
+const SERVER_SNAPSHOT: PolicySelection = {
+ selectedId: null,
+ detailView: "detail",
+};
+function getServerSnapshot(): PolicySelection {
+ return SERVER_SNAPSHOT;
+}
+
+/** Open a policy's detail (resets the sub-view to the narrative). */
+export function selectPolicy(id: string | null) {
+ state = { selectedId: id, detailView: "detail" };
+ emit();
+}
+
+/** Switch the open policy between its narrative and edit-settings sub-views. */
+export function setPolicyDetailView(view: PolicyDetailView) {
+ if (state.detailView === view) return;
+ state = { ...state, detailView: view };
+ emit();
+}
+
+/** Close the open policy and return to the list. */
+export function closePolicy() {
+ selectPolicy(null);
+}
+
+/** Reset to the initial state — used by tests to isolate the module store. */
+export function resetPolicySelection() {
+ state = { selectedId: null, detailView: "detail" };
+ emit();
+}
+
+export function usePolicySelection(): PolicySelection {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
diff --git a/frontend/editor/src/proprietary/components/policies/policyStatus.ts b/frontend/editor/src/proprietary/components/policies/policyStatus.ts
new file mode 100644
index 000000000..d3c3d8ea7
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/policyStatus.ts
@@ -0,0 +1,27 @@
+import type { IconBadgeAccent } from "@shared/components/IconBadge";
+import type { PolicyRowStatus, PolicyState } from "@app/types/policies";
+
+/** Derive a single row/detail status from a policy's persisted state. */
+export function deriveRowStatus(
+ state: PolicyState | undefined,
+): PolicyRowStatus {
+ if (!state?.configured) return "setup";
+ if (state.status === "paused") return "paused";
+ return "active";
+}
+
+/** Human label for each row status. */
+export const STATUS_LABEL: Record = {
+ active: "Active",
+ paused: "Paused",
+ setup: "Set up",
+};
+
+/** A soft tinted icon tile per category — gives each policy a calm identity colour. */
+export const ROW_ACCENT: Record = {
+ ingestion: "blue",
+ security: "purple",
+ compliance: "green",
+ routing: "amber",
+ retention: "red",
+};
diff --git a/frontend/editor/src/proprietary/components/policies/policyToolChains.ts b/frontend/editor/src/proprietary/components/policies/policyToolChains.ts
new file mode 100644
index 000000000..9af2fff4d
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/policyToolChains.ts
@@ -0,0 +1,18 @@
+/**
+ * The fixed, configurable tool chain per policy category — the locked set the
+ * config page shows (one section per tool). Tools can be configured + toggled
+ * on/off, but not added or removed. Each id is a frontend tool-registry key (and
+ * maps to that tool's backend endpoint via the registry's operationConfig).
+ *
+ * Only Security is wired today; other categories follow.
+ */
+export const POLICY_TOOL_CHAINS: Record = {
+ // Security: redact PII + watermark + sanitize (strips JS). Which are enabled
+ // by default comes from the preset's defaultOperations, not this list.
+ security: ["redact", "watermark", "sanitize"],
+};
+
+/** The configurable tool chain for a category, or null if it has none yet. */
+export function getPolicyToolChain(categoryId: string): string[] | null {
+ return POLICY_TOOL_CHAINS[categoryId] ?? null;
+}
diff --git a/frontend/editor/src/proprietary/components/policies/policyValues.ts b/frontend/editor/src/proprietary/components/policies/policyValues.ts
new file mode 100644
index 000000000..844156656
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/policyValues.ts
@@ -0,0 +1,16 @@
+import type { PolicyConfigDef, PolicyState } from "@app/types/policies";
+
+/**
+ * Resolve each field's effective value for a policy: the saved override from
+ * state, falling back to the definition's default.
+ */
+export function resolveFieldValues(
+ config: PolicyConfigDef,
+ state: PolicyState,
+): Record {
+ const out: Record = {};
+ for (const f of config.fields) {
+ out[f.key] = state.fieldValues[f.key] ?? f.value;
+ }
+ return out;
+}
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
new file mode 100644
index 000000000..a9407a6d0
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
@@ -0,0 +1,205 @@
+/**
+ * Auto-run controller (Phase B): every enabled policy enforces on every uploaded
+ * file. Watches the session's files and, for each (active policy × not-yet-run
+ * file), fires a real backend run (`POST /api/v1/policies/{id}/run`) and polls it
+ * to completion, recording progress in {@link policyRunStore} for the activity
+ * feed.
+ *
+ * Headless — call it from {@link PolicyAutoRunController}, which is mounted once
+ * wherever the editor is open so enforcement happens regardless of whether the
+ * policy panel is on screen. Each (policy, file) pair runs exactly once (tracked
+ * in the run store), so re-renders and remounts don't re-fire.
+ */
+
+import { useEffect, useRef } from "react";
+import { useAllFiles, useFileManagement } from "@app/contexts/FileContext";
+import { fileStorage } from "@app/services/fileStorage";
+import { POLICIES_ENABLED } from "@app/constants/featureFlags";
+import {
+ runStoredPolicy,
+ getPolicyRun,
+ downloadPolicyOutput,
+} from "@app/services/policyApi";
+import type { PolicyRunStatus } from "@app/services/policyPipeline";
+import type { FileId } from "@app/types/file";
+import { usePolicies } from "@app/hooks/usePolicies";
+import {
+ isDispatched,
+ markDispatched,
+ recordRunStart,
+ updateRun,
+ usePolicyRuns,
+ type PolicyRunRecord,
+} from "@app/components/policies/policyRunStore";
+
+/** Poll cadence + cap for a single run's status (≈2.5 min worst case). */
+const POLL_MS = 2000;
+const MAX_POLLS = 75;
+
+function isTerminal(status: PolicyRunStatus): boolean {
+ return (
+ status === "COMPLETED" || status === "FAILED" || status === "CANCELLED"
+ );
+}
+
+const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export function usePolicyAutoRun(): void {
+ const { fileStubs } = useAllFiles();
+ const { addFiles } = useFileManagement();
+ const { policies } = usePolicies();
+ const runs = usePolicyRuns();
+ // Run ids currently being polled / imported, so the effects never double-fire.
+ const polling = useRef>(new Set());
+ const importing = useRef>(new Set());
+
+ // Dispatch: for each active policy × each session file not yet run, fire a run.
+ useEffect(() => {
+ if (!POLICIES_ENABLED) return;
+ const active = Object.entries(policies).filter(
+ ([, s]) => s.configured && s.status === "active" && s.backendId,
+ );
+ for (const [categoryId, s] of active) {
+ for (const stub of fileStubs) {
+ if (isDispatched(categoryId, stub.id)) continue;
+ // runPolicyOnFile marks dispatched synchronously before its first await.
+ void runPolicyOnFile(
+ categoryId,
+ s.backendId as string,
+ stub.id,
+ stub.name,
+ );
+ }
+ }
+ }, [fileStubs, policies]);
+
+ // Poll each in-flight run to a terminal state.
+ useEffect(() => {
+ if (!POLICIES_ENABLED) return;
+ for (const run of runs) {
+ if (isTerminal(run.status) || polling.current.has(run.runId)) continue;
+ polling.current.add(run.runId);
+ void poll(run.runId).finally(() => polling.current.delete(run.runId));
+ }
+ }, [runs]);
+
+ // Import each completed run's outputs into the workspace (each output once),
+ // so the enforced file appears in the app rather than only on the backend.
+ useEffect(() => {
+ if (!POLICIES_ENABLED) return;
+ for (const run of runs) {
+ if (
+ run.status !== "COMPLETED" ||
+ run.imported ||
+ !run.outputs?.length ||
+ importing.current.has(run.runId)
+ ) {
+ continue;
+ }
+ importing.current.add(run.runId);
+ void importOutputs(run, addFiles).finally(() =>
+ importing.current.delete(run.runId),
+ );
+ }
+ }, [runs, addFiles]);
+}
+
+/**
+ * Fetch a completed run's not-yet-imported output files and add them to the
+ * workspace. Per-output, via allSettled: each output is tracked once imported,
+ * so a partial failure retries only the missing files on a later tick and the
+ * ones that succeeded are never added twice. `imported` flips true only once
+ * every output has landed.
+ */
+async function importOutputs(
+ run: PolicyRunRecord,
+ addFiles: (files: File[]) => Promise,
+): Promise {
+ const done = new Set(run.importedFileIds ?? []);
+ const pending = run.outputs.filter((out) => !done.has(out.fileId));
+ if (pending.length === 0) {
+ updateRun(run.runId, { imported: true });
+ return;
+ }
+
+ const results = await Promise.allSettled(
+ pending.map(async (out) => {
+ const blob = await downloadPolicyOutput(out.fileId);
+ return {
+ fileId: out.fileId,
+ file: new File([blob], out.fileName || run.fileName, {
+ type: blob.type || "application/pdf",
+ }),
+ };
+ }),
+ );
+ const fetched = results
+ .filter(
+ (r): r is PromiseFulfilledResult<{ fileId: string; file: File }> =>
+ r.status === "fulfilled",
+ )
+ .map((r) => r.value);
+ if (fetched.length === 0) return; // all failed — retry the lot on a later tick
+
+ // Add the freshly-fetched files, then mark exactly those imported. If addFiles
+ // throws we don't mark them, so they retry (without having been added).
+ await addFiles(fetched.map((f) => f.file));
+ const importedFileIds = [...done, ...fetched.map((f) => f.fileId)];
+ updateRun(run.runId, {
+ importedFileIds,
+ imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)),
+ });
+}
+
+/**
+ * Resolve the file's bytes, fire a backend run, and record it. Exported so the
+ * activity feed's Retry action can re-run a policy on a previously-failed file.
+ */
+export async function runPolicyOnFile(
+ categoryId: string,
+ backendId: string,
+ fileId: FileId,
+ fileName: string,
+): Promise {
+ // Mark synchronously, before any await, so neither the dispatch effect nor a
+ // rapid Retry click can double-fire while the file bytes load.
+ markDispatched(categoryId, fileId);
+ try {
+ const file = await fileStorage.getStirlingFile(fileId);
+ if (!file) return; // file gone; nothing to run (already marked above).
+ const runId = await runStoredPolicy(backendId, [file]);
+ recordRunStart({
+ runId,
+ categoryId,
+ fileId,
+ fileName,
+ fileSize: file.size,
+ status: "PENDING",
+ outputs: [],
+ error: null,
+ startedAt: Date.now(),
+ });
+ } catch {
+ // Dispatch failed (offline / backend error). Already marked dispatched so we
+ // don't hammer; the absent run simply won't appear in the activity feed.
+ }
+}
+
+/** Poll a run's status until it reaches a terminal state (or the cap). */
+async function poll(runId: string): Promise {
+ for (let i = 0; i < MAX_POLLS; i++) {
+ await delay(POLL_MS);
+ let view;
+ try {
+ view = await getPolicyRun(runId);
+ } catch {
+ continue; // transient — keep trying within the cap.
+ }
+ updateRun(runId, {
+ status: view.status,
+ outputs: view.outputs,
+ error: view.error,
+ });
+ if (isTerminal(view.status)) return;
+ }
+}
diff --git a/frontend/editor/src/proprietary/constants/featureFlags.ts b/frontend/editor/src/proprietary/constants/featureFlags.ts
index fdb5912b4..e6dd00f6e 100644
--- a/frontend/editor/src/proprietary/constants/featureFlags.ts
+++ b/frontend/editor/src/proprietary/constants/featureFlags.ts
@@ -6,10 +6,17 @@
*/
/**
- * Watched Folders (a.k.a. Watched Folders) — a proprietary feature whose
- * implementation lives under `proprietary/`. Still disabled for now; flip to
- * `true` to surface it in the proprietary build only. The core override stays
- * `false`, so the shared sidebar entry point never appears in the open-source
- * build (which has no Watched Folders implementation to navigate to).
+ * Watched Folders — a proprietary feature whose implementation lives under
+ * `proprietary/`. Still disabled for now; flip to `true` to surface it in the
+ * proprietary build only. The core override stays `false`, so the shared
+ * sidebar entry point never appears in the open-source build (which has no
+ * Watched Folders implementation to navigate to).
*/
export const WATCHED_FOLDERS_ENABLED: boolean = false;
+
+/**
+ * Policies — proprietary, automation-backed policy enforcement. Enabled in the
+ * proprietary build so it's reachable while in active development (frontend is
+ * mock/stub-backed; no real server yet). Core stays `false`.
+ */
+export const POLICIES_ENABLED: boolean = true;
diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.test.ts b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts
new file mode 100644
index 000000000..c3e1e6003
--- /dev/null
+++ b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from "vitest";
+import { POLICY_CATEGORIES, POLICY_CONFIG } from "@app/data/policyDefinitions";
+
+describe("policy definitions integrity", () => {
+ it("every category has a matching config entry", () => {
+ for (const cat of POLICY_CATEGORIES) {
+ expect(POLICY_CONFIG[cat.id], `config for ${cat.id}`).toBeDefined();
+ expect(POLICY_CONFIG[cat.id].fields.length).toBeGreaterThan(0);
+ expect(POLICY_CONFIG[cat.id].rules.length).toBeGreaterThan(0);
+ // Every preset seeds a real, non-empty pipeline (the category→steps map).
+ expect(
+ POLICY_CONFIG[cat.id].defaultOperations.length,
+ `defaultOperations for ${cat.id}`,
+ ).toBeGreaterThan(0);
+ }
+ });
+ it("field keys are unique within each category", () => {
+ for (const cat of POLICY_CATEGORIES) {
+ const keys = POLICY_CONFIG[cat.id].fields.map((f) => f.key);
+ expect(new Set(keys).size).toBe(keys.length);
+ }
+ });
+});
diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx
new file mode 100644
index 000000000..5151c2c96
--- /dev/null
+++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx
@@ -0,0 +1,365 @@
+/**
+ * Static preset definitions for Policies — the categories, their editable
+ * settings fields, scope labels, and the default tool pipeline each category
+ * seeds a new policy with. Runtime activity + stats are derived live from the
+ * user's real files (see policyLiveData), not defined here.
+ */
+
+import LayersIcon from "@mui/icons-material/Layers";
+import ShieldIcon from "@mui/icons-material/Shield";
+import CheckCircleIcon from "@mui/icons-material/CheckCircle";
+import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
+import StorageIcon from "@mui/icons-material/Storage";
+import DescriptionIcon from "@mui/icons-material/Description";
+import ComputerIcon from "@mui/icons-material/Computer";
+import PublicIcon from "@mui/icons-material/Public";
+import CloudIcon from "@mui/icons-material/Cloud";
+import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
+import FolderOpenIcon from "@mui/icons-material/FolderOpen";
+import type {
+ PolicyCategory,
+ PolicyConfigDef,
+ PolicySource,
+} from "@app/types/policies";
+
+const ICON_SX = { fontSize: "1rem" } as const;
+
+/** The 5 policy categories, in the prototype's narrative order. */
+export const POLICY_CATEGORIES: PolicyCategory[] = [
+ {
+ id: "ingestion",
+ label: "Ingestion",
+ icon: ,
+ desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.",
+ // The classifier the wizard's "Set up Classification" action routes to.
+ providesClassification: true,
+ // Needs the classify agent + RAG, which aren't built yet.
+ comingSoon: true,
+ },
+ {
+ id: "security",
+ label: "Security",
+ icon: ,
+ desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
+ },
+ {
+ id: "compliance",
+ label: "Compliance",
+ icon: ,
+ desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.",
+ comingSoon: true,
+ },
+ {
+ id: "routing",
+ label: "Routing",
+ icon: ,
+ desc: "Auto-route documents to the right team, folder, or system.",
+ comingSoon: true,
+ },
+ {
+ id: "retention",
+ label: "Retention",
+ icon: ,
+ desc: "Set how long documents are kept, when to archive, and when to delete.",
+ comingSoon: true,
+ },
+];
+
+/**
+ * PII presets for the redact step: a label + the regex the /auto-redact endpoint
+ * matches (via `wordsToRedact` + `useRegex`). Patterns are precise — validated
+ * (SSN areas, card IINs, ABA prefixes), context- or separator-anchored — to keep
+ * false positives down and avoid the backtracking the old broad patterns risked.
+ */
+export const PII_PRESETS: { value: string; label: string; pattern: string }[] =
+ [
+ {
+ value: "ssn",
+ label: "Social Security numbers",
+ // 123-45-6789 or 123 45 6789; rejects invalid areas (000/666/9xx),
+ // group 00, serial 0000, and mixed separators (backreference).
+ pattern:
+ "\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b",
+ },
+ {
+ value: "card",
+ label: "Credit / debit cards",
+ // Solid runs anchored to real IINs (Visa 13/16, MC 51–55 + 2221–2720,
+ // Amex 34/37, Discover 6011/65xx) + grouped 4-4-4-4 and Amex 4-6-5
+ // with a consistent separator enforced by backreference.
+ pattern:
+ "\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|(?:222[1-9]|22[3-9]\\d|2[3-6]\\d{2}|27[01]\\d|2720)\\d{12}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12}|[2-6]\\d{3}([ -])\\d{4}\\1\\d{4}\\1\\d{4}|3[47]\\d{2}([ -])\\d{6}\\2\\d{5})\\b",
+ },
+ {
+ value: "iban",
+ label: "IBANs",
+ // Solid (GB29NWBK…) or space-grouped (GB29 NWBK 6016 …) form.
+ pattern:
+ "\\b[A-Z]{2}\\d{2}(?:[A-Z0-9]{11,30}|(?: [A-Z0-9]{4}){2,7}(?: [A-Z0-9]{1,4})?)\\b",
+ },
+ {
+ value: "routing",
+ label: "US routing numbers (ABA)",
+ // 9 digits constrained to valid Federal Reserve prefix ranges.
+ pattern: "\\b(?:0[1-9]|1[0-2]|2[1-9]|3[0-2]|6[1-9]|7[0-2]|80)\\d{7}\\b",
+ },
+ {
+ value: "account",
+ label: "Account numbers (labelled)",
+ // Context-anchored: only digits preceded by Account / Acct / A/C.
+ pattern:
+ "\\b(?:[Aa]cc(?:oun)?t|[Aa]/[Cc])(?:\\s+(?:[Nn]o\\.?|[Nn]umber|#))?\\s*[:#]?\\s*\\d{6,17}\\b",
+ },
+ {
+ value: "email",
+ label: "Email addresses",
+ // Requires a real TLD (≥2 letters); won't swallow a sentence-ending period.
+ pattern:
+ "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}\\b",
+ },
+ {
+ value: "phone",
+ label: "Phone numbers",
+ // (555) 123-4567 · 555-123-4567 (consistent separator) · +E.164 solid or
+ // grouped · UK 0-prefixed grouped formats. Bare 10-digit runs excluded.
+ pattern:
+ "\\(\\d{3}\\)[ .-]?\\d{3}[ .-]?\\d{4}\\b|\\b\\d{3}([ .-])\\d{3}\\1\\d{4}\\b|\\+\\d{1,3}[ .-]?\\d{6,12}\\b|\\+\\d{1,3}(?:[ .-]\\d{2,4}){2,5}\\b|\\b0\\d{2,4}[ -]\\d{3,4}[ -]?\\d{3,4}\\b",
+ },
+ ];
+
+/**
+ * Defaults seeded into a fresh Security policy's redact step — only the two
+ * strictest, precise patterns (SSN + cards). Users add the rest (IBAN, routing,
+ * account, email, phone) from the PII dropdown.
+ */
+export const DEFAULT_PII_PATTERNS: string[] = [
+ PII_PRESETS[0].pattern, // SSN
+ PII_PRESETS[1].pattern, // cards
+];
+
+/** Per-category narrative + editable fields (from the prototype's POLICY_CONFIG). */
+export const POLICY_CONFIG: Record = {
+ ingestion: {
+ summary:
+ "Classifies documents, extracts structured data, enforces naming, and normalizes pages.",
+ rules: ["Classify", "Extract", "Name", "Normalize"],
+ defaultOperations: [
+ { operation: "ocr", parameters: {} },
+ { operation: "flatten", parameters: {} },
+ ],
+ scopeLabel: "All PDFs on this device",
+ // Policy-level controls only — the per-tool params (OCR level, extract
+ // tables, naming, normalize, rotate…) now live in the Workflow step.
+ fields: [
+ {
+ label: "Min confidence",
+ key: "minConfidence",
+ type: "select",
+ value: "80%",
+ options: ["60%", "70%", "80%", "90%", "95%"],
+ },
+ {
+ label: "Below threshold",
+ key: "belowThreshold",
+ type: "select",
+ value: "Flag for review",
+ options: ["Flag for review", "Route to bucket", "Hold"],
+ },
+ ],
+ },
+ security: {
+ summary:
+ "Detects PII, encrypts, verifies authenticity, controls access, and certifies documents.",
+ rules: ["Redact PII", "Remove JavaScript"],
+ // Default chain: redact PII + remove JavaScript (via sanitize) on; watermark
+ // is offered in the config page but off by default (not seeded here). Redact
+ // ships with the high-risk PII regexes so it works out of the box.
+ defaultOperations: [
+ {
+ operation: "redact",
+ parameters: {
+ mode: "automatic",
+ useRegex: true,
+ wordsToRedact: DEFAULT_PII_PATTERNS,
+ },
+ },
+ { operation: "sanitize", parameters: {} },
+ ],
+ scopeLabel: "All PDFs on this device",
+ // Policy-level controls only — detection/encryption/signing/watermark are
+ // per-tool and now live in the Workflow step.
+ fields: [
+ {
+ label: "Default PII response",
+ key: "defaultResponse",
+ type: "select",
+ value: "Highlight & tag",
+ options: [
+ "Highlight & tag",
+ "Prompt on export",
+ "Auto-redact on export",
+ "Block export",
+ ],
+ },
+ {
+ label: "User can override",
+ key: "userOverride",
+ type: "toggle",
+ value: true,
+ },
+ {
+ label: "Default access level",
+ key: "defaultAccess",
+ type: "select",
+ value: "Restricted",
+ options: ["Open", "Restricted", "Confidential", "Top Secret"],
+ },
+ {
+ label: "Block external sharing",
+ key: "blockExternal",
+ type: "toggle",
+ value: false,
+ },
+ ],
+ },
+ compliance: {
+ summary:
+ "Validates documents against regulatory frameworks before they leave the system.",
+ rules: ["Framework scan", "Enforce action", "Audit trail"],
+ defaultOperations: [
+ { operation: "sanitize", parameters: {} },
+ { operation: "flatten", parameters: {} },
+ ],
+ scopeLabel: "All PDFs on this device",
+ fields: [
+ {
+ label: "Frameworks",
+ key: "frameworks",
+ type: "chips",
+ value: ["HIPAA"],
+ options: [
+ "HIPAA",
+ "GDPR",
+ "SOC 2",
+ "FedRAMP",
+ "PCI DSS",
+ "CCPA",
+ "ISO 27001",
+ ],
+ },
+ {
+ label: "When non-compliant",
+ key: "onViolation",
+ type: "select",
+ value: "Flag for review",
+ options: [
+ "Flag for review",
+ "Block export",
+ "Auto-redact PHI",
+ "Quarantine document",
+ ],
+ },
+ { label: "Audit trail", key: "auditTrail", type: "toggle", value: true },
+ { label: "Access log", key: "accessLog", type: "toggle", value: true },
+ ],
+ },
+ routing: {
+ summary:
+ "Routes documents to the right destination based on type and classification.",
+ rules: ["Auto-classify", "Route to folder", "Webhook notify"],
+ defaultOperations: [{ operation: "compress", parameters: {} }],
+ scopeLabel: "All PDFs on this device",
+ fields: [
+ {
+ label: "Destination",
+ key: "destination",
+ type: "select",
+ value: "Documents",
+ options: ["Documents", "S3 bucket", "SharePoint", "Webhook"],
+ },
+ { label: "Webhook URL", key: "webhookUrl", type: "text", value: "" },
+ { label: "Notify on route", key: "notify", type: "toggle", value: false },
+ ],
+ },
+ retention: {
+ summary:
+ "Enforces how long documents are kept, when to archive, and when to delete.",
+ rules: ["Retention hold", "Auto-archive", "Deletion block"],
+ defaultOperations: [{ operation: "compress", parameters: {} }],
+ scopeLabel: "All PDFs on this device",
+ fields: [
+ {
+ label: "Keep for",
+ key: "keepFor",
+ type: "select",
+ value: "7 years",
+ options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"],
+ },
+ {
+ label: "Archive after",
+ key: "archiveAfter",
+ type: "select",
+ value: "Never",
+ options: ["30 days", "90 days", "1 year", "Never"],
+ },
+ {
+ label: "Immutable hold",
+ key: "immutableHold",
+ type: "toggle",
+ value: false,
+ },
+ ],
+ },
+};
+
+/** Sources a policy can run over (wizard step 2). */
+export const POLICY_SOURCES: PolicySource[] = [
+ {
+ id: "editor",
+ label: "Editor",
+ desc: "Documents you save or export in Stirling",
+ icon: ,
+ },
+ {
+ id: "device",
+ label: "Entire device",
+ desc: "All PDFs on this machine, retroactively",
+ icon: ,
+ },
+ {
+ id: "sharepoint",
+ label: "SharePoint",
+ desc: "Connected SharePoint libraries",
+ icon: ,
+ },
+ {
+ id: "dropbox",
+ label: "Dropbox",
+ desc: "Connected Dropbox folders",
+ icon: ,
+ },
+ {
+ id: "gmail",
+ label: "Gmail",
+ desc: "PDF attachments in email",
+ icon: ,
+ },
+ {
+ id: "gdrive",
+ label: "Google Drive",
+ desc: "Connected Drive folders",
+ icon: ,
+ },
+];
+
+/** Document types selectable when narrowing scope (gated behind classification). */
+export const POLICY_DOC_TYPES: string[] = [
+ "Contracts",
+ "Invoices",
+ "Tax documents",
+ "HR records",
+ "Insurance",
+ "Medical / PHI",
+ "Legal filings",
+ "Financial reports",
+];
diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.test.ts b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts
new file mode 100644
index 000000000..6563c48b9
--- /dev/null
+++ b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts
@@ -0,0 +1,155 @@
+import "fake-indexeddb/auto";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { act, renderHook, waitFor } from "@testing-library/react";
+
+// Enable/delete create + remove the backing Watched Folders WatchedFolder
+// (IndexedDB); jsdom's crypto lacks randomUUID, used for folder ids.
+if (typeof globalThis.crypto?.randomUUID !== "function") {
+ const orig = globalThis.crypto;
+ vi.stubGlobal("crypto", {
+ getRandomValues: orig?.getRandomValues?.bind(orig),
+ randomUUID: () =>
+ `p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
+ });
+}
+
+// In-memory stand-in for the backend policy store, so the hook's persistence
+// path is exercised without a real server.
+const api = vi.hoisted(() => ({
+ store: new Map(),
+ seq: 0,
+}));
+vi.mock("@app/services/policyApi", () => ({
+ listPolicies: vi.fn(async () => [...api.store.values()]),
+ savePolicy: vi.fn(async (p: { id?: string }) => {
+ const id = p.id && p.id.length > 0 ? p.id : `be-${++api.seq}`;
+ const saved = { ...p, id };
+ api.store.set(id, saved);
+ return saved;
+ }),
+ getPolicy: vi.fn(async (id: string) => api.store.get(id)),
+ deletePolicy: vi.fn(async (id: string) => {
+ api.store.delete(id);
+ }),
+ runStoredPolicy: vi.fn(),
+ runPolicyPipeline: vi.fn(),
+ getPolicyRun: vi.fn(),
+}));
+
+import { usePolicies } from "@app/hooks/usePolicies";
+
+// A minimal wizard result (workflow already saved + mapped by the builder).
+const wizardResult = {
+ automation: {
+ id: "auto-1",
+ name: "Test",
+ operations: [{ operation: "compress", parameters: {} }],
+ createdAt: "",
+ updatedAt: "",
+ },
+ fieldValues: {},
+ sources: ["editor"],
+ scopeTypes: [],
+ reviewerEmail: "reviewer@x.com",
+ folder: {
+ outputMode: "new_file" as const,
+ outputName: "",
+ outputNamePosition: "prefix" as const,
+ maxRetries: 3,
+ retryDelayMinutes: 5,
+ },
+ pipelineSteps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
+ unresolvedOps: [],
+};
+
+describe("usePolicies", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ api.store.clear();
+ api.seq = 0;
+ });
+
+ it("starts with every category unconfigured (no seed)", async () => {
+ const { result } = renderHook(() => usePolicies());
+ // Flush the async mount reconcile (empty backend ⇒ all stay unconfigured).
+ await act(async () => {});
+ expect(result.current.policies.ingestion.configured).toBe(false);
+ expect(result.current.policies.security.configured).toBe(false);
+ });
+
+ it("enabling a policy persists it to the backend + marks it configured", async () => {
+ const { result } = renderHook(() => usePolicies());
+ await act(async () => {
+ await result.current.enablePolicy("security", wizardResult);
+ });
+ await waitFor(() =>
+ expect(result.current.policies.security.configured).toBe(true),
+ );
+ expect(result.current.policies.security.status).toBe("active");
+ expect(result.current.policies.security.folderId).toBeTruthy();
+ expect(result.current.policies.security.backendId).toBeTruthy();
+ expect(result.current.policies.security.reviewerEmail).toBe(
+ "reviewer@x.com",
+ );
+ // The mapped pipeline (endpoint path) reached the backend store.
+ const stored = [...api.store.values()][0] as unknown as {
+ steps: unknown[];
+ };
+ expect(stored.steps).toHaveLength(1);
+ });
+
+ it("reconciles configured policies from the backend on mount", async () => {
+ // Enable on one instance (persists to the backend store)...
+ const first = renderHook(() => usePolicies());
+ await act(async () => {
+ await first.result.current.enablePolicy("security", wizardResult);
+ });
+ // ...a fresh instance should pick it up from the backend.
+ const second = renderHook(() => usePolicies());
+ await waitFor(() =>
+ expect(second.result.current.policies.security.configured).toBe(true),
+ );
+ expect(second.result.current.policies.security.backendId).toBeTruthy();
+ });
+
+ it("pausing then resuming flips status", async () => {
+ const { result } = renderHook(() => usePolicies());
+ await act(async () => {
+ await result.current.enablePolicy("ingestion", wizardResult);
+ });
+ await act(async () => {
+ await result.current.pausePolicy("ingestion");
+ });
+ expect(result.current.policies.ingestion.status).toBe("paused");
+ await act(async () => {
+ await result.current.resumePolicy("ingestion");
+ });
+ expect(result.current.policies.ingestion.status).toBe("active");
+ });
+
+ it("deleting a policy reverts it + removes it from the backend", async () => {
+ const { result } = renderHook(() => usePolicies());
+ await act(async () => {
+ await result.current.enablePolicy("routing", wizardResult);
+ });
+ await waitFor(() =>
+ expect(result.current.policies.routing.configured).toBe(true),
+ );
+ await act(async () => {
+ await result.current.deletePolicy("routing");
+ });
+ expect(result.current.policies.routing.configured).toBe(false);
+ expect(result.current.policies.routing.status).toBe("default");
+ expect(result.current.policies.routing.folderId).toBeUndefined();
+ expect(result.current.policies.routing.backendId).toBeUndefined();
+ expect(api.store.size).toBe(0);
+ });
+
+ it("ensurePolicyFolder creates a backing folder for a folderless policy", async () => {
+ const { result } = renderHook(() => usePolicies());
+ await act(async () => {
+ await result.current.ensurePolicyFolder("ingestion");
+ });
+ expect(result.current.policies.ingestion.folderId).toBeTruthy();
+ });
+});
diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts
new file mode 100644
index 000000000..f5fee714e
--- /dev/null
+++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts
@@ -0,0 +1,282 @@
+/**
+ * State + actions for Policies. The backend (`/api/v1/policies`) is the source
+ * of truth: on mount we reconcile the local cache against the stored policies,
+ * and every lifecycle action (enable/save/pause/resume/delete) is mirrored to
+ * the backend. localStorage is a fast-render cache + offline fallback; the
+ * IndexedDB backing folder still holds the editable automation + run state.
+ */
+
+import { useState, useEffect, useCallback } from "react";
+import {
+ loadPolicies,
+ onPoliciesChange,
+ updatePolicy,
+ resetPolicy,
+} from "@app/services/policyStorage";
+import { loadPolicyCatalog } from "@app/services/policyCatalog";
+import {
+ createPolicyFolder,
+ createPolicyFolderForAutomation,
+ deletePolicyFolder,
+ getPolicyAutomation,
+ setPolicyFolderPaused,
+ updatePolicyFolderSettings,
+ updatePolicyOperations,
+} from "@app/services/policyFolders";
+import {
+ fetchPoliciesByCategory,
+ decodedToState,
+ findBackendId,
+ persistPolicy,
+ setPolicyEnabled,
+ removePolicy,
+} from "@app/services/policyBackend";
+import type { PolicyToStore } from "@app/services/policyPipeline";
+import type {
+ PoliciesByCategory,
+ PolicyConfigResult,
+ PolicyWizardResult,
+} from "@app/types/policies";
+
+/** Build the backend store-request for a category from a wizard result. */
+function toStoreRequest(
+ categoryId: string,
+ categoryLabel: string,
+ result: PolicyWizardResult,
+ enabled: boolean,
+ backendId: string | undefined,
+): PolicyToStore {
+ return {
+ id: backendId,
+ categoryId,
+ name: `${categoryLabel} Policy`,
+ enabled,
+ automation: result.automation,
+ pipelineSteps: result.pipelineSteps,
+ sources: result.sources,
+ scopeTypes: result.scopeTypes,
+ reviewerEmail: result.reviewerEmail,
+ fieldValues: result.fieldValues,
+ folder: result.folder,
+ };
+}
+
+export function usePolicies() {
+ const [policies, setPolicies] = useState(loadPolicies);
+
+ useEffect(() => onPoliciesChange(() => setPolicies(loadPolicies())), []);
+
+ // Reconcile the local cache against the backend (the source of truth) on
+ // mount. Backend config wins; the locally-cached folderId (which the backend
+ // doesn't track) is preserved. If the backend is unreachable we keep the
+ // local cache as-is, so the surface still works offline.
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ let byCategory;
+ try {
+ byCategory = await fetchPoliciesByCategory();
+ } catch {
+ return; // offline / backend down — local cache stands.
+ }
+ if (cancelled) return;
+ const local = loadPolicies();
+ const reconciled: PoliciesByCategory = {};
+ for (const cat of loadPolicyCatalog().categories) {
+ const decoded = byCategory.get(cat.id);
+ reconciled[cat.id] = decoded
+ ? decodedToState(decoded, local[cat.id]?.folderId)
+ : { ...local[cat.id], configured: false, status: "default" };
+ }
+ for (const [id, state] of Object.entries(reconciled)) {
+ updatePolicy(id, state);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ /**
+ * Enable a new policy from the wizard result: persist it to the backend (the
+ * source of truth), then create the backing folder holding its editable
+ * automation, and cache the result locally. Throws (surfacing in the wizard)
+ * if the category is unknown or the backend save fails.
+ */
+ const enablePolicy = useCallback(
+ async (id: string, result: PolicyWizardResult) => {
+ const category = loadPolicyCatalog().categories.find((c) => c.id === id);
+ if (!category) throw new Error(`Unknown policy category: ${id}`);
+ // One policy per category, ever: reuse any existing backend record.
+ const existingBackendId =
+ loadPolicies()[id]?.backendId ??
+ (await findBackendId(id).catch(() => undefined));
+ const backendId = await persistPolicy(
+ toStoreRequest(id, category.label, result, true, existingBackendId),
+ );
+ const folder = await createPolicyFolderForAutomation(
+ category,
+ result.automation.id,
+ );
+ await updatePolicyFolderSettings(folder.id, result.folder);
+ updatePolicy(id, {
+ configured: true,
+ status: "active",
+ folderId: folder.id,
+ backendId,
+ fieldValues: result.fieldValues,
+ sources: result.sources,
+ scopeTypes: result.scopeTypes,
+ reviewerEmail: result.reviewerEmail,
+ });
+ },
+ [],
+ );
+
+ /**
+ * Save edits from the wizard. The workflow automation is updated in place by
+ * the builder; persist the updated policy to the backend and the folder's
+ * output/retry settings + the rest of the settings locally.
+ */
+ const savePolicyConfig = useCallback(
+ async (id: string, result: PolicyWizardResult) => {
+ const current = loadPolicies()[id];
+ const category = loadPolicyCatalog().categories.find((c) => c.id === id);
+ if (!category) throw new Error(`Unknown policy category: ${id}`);
+ const backendId = await persistPolicy(
+ toStoreRequest(
+ id,
+ category.label,
+ result,
+ current?.status !== "paused",
+ current?.backendId,
+ ),
+ );
+ if (current?.folderId) {
+ await updatePolicyFolderSettings(current.folderId, result.folder);
+ }
+ updatePolicy(id, {
+ backendId,
+ fieldValues: result.fieldValues,
+ sources: result.sources,
+ scopeTypes: result.scopeTypes,
+ reviewerEmail: result.reviewerEmail,
+ });
+ },
+ [],
+ );
+
+ /**
+ * Create-or-update a policy from the locked tool-config page. Unlike the
+ * wizard path this works straight from the tool `operations` (the config page
+ * owns the chain): it creates the backing folder + automation on first
+ * configure, or updates the existing automation's operations on edit, then
+ * mirrors the whole policy to the backend. One method serves both because a
+ * preset policy has no separate "create" — you're just configuring it.
+ */
+ const commitPolicyConfig = useCallback(
+ async (id: string, result: PolicyConfigResult) => {
+ const category = loadPolicyCatalog().categories.find((c) => c.id === id);
+ if (!category) throw new Error(`Unknown policy category: ${id}`);
+ const current = loadPolicies()[id];
+ // One policy per category, ever: reuse the existing backend record (even
+ // if the local link was lost) so a save never creates a duplicate.
+ const existingBackendId =
+ current?.backendId ?? (await findBackendId(id).catch(() => undefined));
+ let folderId = current?.folderId;
+ if (folderId) {
+ await updatePolicyOperations(folderId, result.operations);
+ } else {
+ const folder = await createPolicyFolder(category, result.operations);
+ folderId = folder.id;
+ }
+ await updatePolicyFolderSettings(folderId, result.folder);
+ // The saved automation (with its id) is the lossless round-trip blob.
+ const automation = await getPolicyAutomation(folderId);
+ const store: PolicyToStore = {
+ id: existingBackendId,
+ categoryId: id,
+ name: `${category.label} Policy`,
+ enabled: current?.status !== "paused",
+ automation: automation ?? {
+ id: "",
+ name: `${category.label} Policy`,
+ operations: result.operations,
+ createdAt: "",
+ updatedAt: "",
+ },
+ pipelineSteps: result.pipelineSteps,
+ sources: result.sources,
+ scopeTypes: result.scopeTypes,
+ reviewerEmail: result.reviewerEmail,
+ fieldValues: result.fieldValues,
+ folder: result.folder,
+ };
+ const backendId = await persistPolicy(store);
+ updatePolicy(id, {
+ configured: true,
+ status: current?.status === "paused" ? "paused" : "active",
+ folderId,
+ backendId,
+ fieldValues: result.fieldValues,
+ sources: result.sources,
+ scopeTypes: result.scopeTypes,
+ reviewerEmail: result.reviewerEmail,
+ });
+ },
+ [],
+ );
+
+ const pausePolicy = useCallback(async (id: string) => {
+ const current = loadPolicies()[id];
+ if (current?.backendId) await setPolicyEnabled(current.backendId, false);
+ if (current?.folderId) await setPolicyFolderPaused(current.folderId, true);
+ updatePolicy(id, { status: "paused" });
+ }, []);
+
+ const resumePolicy = useCallback(async (id: string) => {
+ const current = loadPolicies()[id];
+ if (current?.backendId) await setPolicyEnabled(current.backendId, true);
+ if (current?.folderId) await setPolicyFolderPaused(current.folderId, false);
+ updatePolicy(id, { status: "active" });
+ }, []);
+
+ const deletePolicy = useCallback(async (id: string) => {
+ const current = loadPolicies()[id];
+ if (current?.backendId) await removePolicy(current.backendId);
+ if (current?.folderId) await deletePolicyFolder(current.folderId);
+ resetPolicy(id);
+ }, []);
+
+ /**
+ * Ensure a configured policy has a backing folder (its editable pipeline),
+ * creating one from the preset if missing. Returns the folder id.
+ */
+ const ensurePolicyFolder = useCallback(async (id: string) => {
+ const existing = loadPolicies()[id]?.folderId;
+ if (existing) return existing;
+ const catalog = loadPolicyCatalog();
+ const category = catalog.categories.find((c) => c.id === id);
+ const config = catalog.configs[id];
+ if (!category || !config) return undefined;
+ const folder = await createPolicyFolder(category, config.defaultOperations);
+ updatePolicy(id, { folderId: folder.id });
+ return folder.id;
+ }, []);
+
+ // Configuration is open to signed-in users; real per-org gating is a backend
+ // concern (the mock owner/admin/member permission model has been removed).
+ const canConfigure = true;
+
+ return {
+ policies,
+ canConfigure,
+ enablePolicy,
+ savePolicyConfig,
+ commitPolicyConfig,
+ pausePolicy,
+ resumePolicy,
+ deletePolicy,
+ ensurePolicyFolder,
+ };
+}
diff --git a/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts b/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts
new file mode 100644
index 000000000..9d2bfbfa2
--- /dev/null
+++ b/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts
@@ -0,0 +1,16 @@
+import { useMemo } from "react";
+import {
+ loadPolicyCatalog,
+ type PolicyCatalog,
+} from "@app/services/policyCatalog";
+
+/**
+ * Policy definitions (categories / fields / sources / doc types) delivered
+ * through the catalog seam, so components never import the static definitions
+ * directly. Memoised; when the catalog becomes a backend fetch, this hook is
+ * where loading/error state would be introduced — its consumers already treat
+ * it as the single source of definitions.
+ */
+export function usePolicyCatalog(): PolicyCatalog {
+ return useMemo(() => loadPolicyCatalog(), []);
+}
diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts
new file mode 100644
index 000000000..5f076225f
--- /dev/null
+++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts
@@ -0,0 +1,43 @@
+import { useMemo } from "react";
+import { usePolicyRuns } from "@app/components/policies/policyRunStore";
+import { loadPolicyCatalog } from "@app/services/policyCatalog";
+import { ROW_ACCENT } from "@app/components/policies/policyStatus";
+import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
+
+/** Policy accent name (ROW_ACCENT) → the CSS colour var the badge uses. */
+const ACCENT_VAR: Record = {
+ blue: "var(--color-blue)",
+ purple: "var(--color-purple)",
+ green: "var(--color-green)",
+ amber: "var(--color-amber)",
+ red: "var(--color-red)",
+};
+
+/**
+ * Distinct policies that have run on each file, keyed by fileId, derived from the
+ * reactive policy run store. Drives the file sidebar's shield badges. Shadows the
+ * core stub via the {@code @app/*} alias cascade.
+ */
+export function usePolicyFileBadges(): Map {
+ const runs = usePolicyRuns();
+ return useMemo(() => {
+ const labelById = new Map(
+ loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
+ );
+ const byFile = new Map();
+ for (const run of runs) {
+ const name = labelById.get(run.categoryId);
+ if (!name) continue;
+ const list = byFile.get(run.fileId) ?? [];
+ if (!list.some((p) => p.id === run.categoryId)) {
+ list.push({
+ id: run.categoryId,
+ name,
+ accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"],
+ });
+ byFile.set(run.fileId, list);
+ }
+ }
+ return byFile;
+ }, [runs]);
+}
diff --git a/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts b/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts
index 4f67f4e20..3e599ab89 100644
--- a/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts
+++ b/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts
@@ -33,8 +33,9 @@ export function useWatchedFolders(): UseWatchedFoldersReturn {
const refreshFolders = useCallback(async () => {
try {
+ // Policy-owned folders are managed by Policies, not shown here.
const all = await watchedFolderStorage.getAllFolders();
- setFolders(all);
+ setFolders(all.filter((f) => !f.policyCategoryId));
} catch (error) {
console.error("Failed to load smart folders:", error);
} finally {
diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts
new file mode 100644
index 000000000..0cbcb4964
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyApi.ts
@@ -0,0 +1,99 @@
+/**
+ * Client for the backend Policies engine (`/api/v1/policies`). Runs a
+ * pipeline on the server — the "backend automation" path — and polls its status.
+ * Outputs are downloaded via the existing `/api/v1/general/files/{id}` endpoint
+ * using the file ids in the run view.
+ */
+
+import apiClient from "@app/services/apiClient";
+import type {
+ BackendPipelineDefinition,
+ BackendPolicy,
+ PolicyRunView,
+} from "@app/services/policyPipeline";
+
+interface JobResponse {
+ async: boolean;
+ jobId: string;
+ result: unknown;
+}
+
+// --- Policy config persistence (server-side store, JPA-backed) ---
+
+/** Create or update a policy; the backend assigns a blank id and returns it. */
+export async function savePolicy(
+ policy: BackendPolicy,
+): Promise {
+ const res = await apiClient.post("/api/v1/policies", policy);
+ return res.data;
+}
+
+/** List all stored policies. */
+export async function listPolicies(): Promise {
+ const res = await apiClient.get("/api/v1/policies");
+ return res.data;
+}
+
+/** Fetch a stored policy by id. */
+export async function getPolicy(id: string): Promise {
+ const res = await apiClient.get(
+ `/api/v1/policies/${encodeURIComponent(id)}`,
+ );
+ return res.data;
+}
+
+/** Delete a stored policy by id. */
+export async function deletePolicy(id: string): Promise {
+ await apiClient.delete(`/api/v1/policies/${encodeURIComponent(id)}`);
+}
+
+/** Run a stored policy by id on the supplied files; returns the run id. */
+export async function runStoredPolicy(
+ id: string,
+ files: File[],
+): Promise {
+ const form = new FormData();
+ for (const file of files) form.append("fileInput", file);
+ const res = await apiClient.post(
+ `/api/v1/policies/${encodeURIComponent(id)}/run`,
+ form,
+ { headers: { "Content-Type": "multipart/form-data" } },
+ );
+ return res.data.jobId;
+}
+
+// --- Ad-hoc pipeline runs (no stored policy) ---
+
+/**
+ * Run an ad-hoc pipeline on the backend over the given documents. Returns the
+ * run id; poll {@link getPolicyRun} for status + output file ids.
+ */
+export async function runPolicyPipeline(
+ definition: BackendPipelineDefinition,
+ files: File[],
+): Promise {
+ const form = new FormData();
+ for (const file of files) form.append("fileInput", file);
+ form.append("json", JSON.stringify(definition));
+ const res = await apiClient.post("/api/v1/policies/run", form, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ return res.data.jobId;
+}
+
+/** Download a run's output file by id (via the shared general-files endpoint). */
+export async function downloadPolicyOutput(fileId: string): Promise {
+ const res = await apiClient.get(
+ `/api/v1/general/files/${encodeURIComponent(fileId)}`,
+ { responseType: "blob" },
+ );
+ return res.data;
+}
+
+/** Current status, step cursor and output files of a run. */
+export async function getPolicyRun(runId: string): Promise {
+ const res = await apiClient.get(
+ `/api/v1/policies/run/${encodeURIComponent(runId)}`,
+ );
+ return res.data;
+}
diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts
new file mode 100644
index 000000000..4fd9694d9
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyBackend.ts
@@ -0,0 +1,95 @@
+/**
+ * Backend source-of-truth layer for Policies. Wraps the raw `policyApi` client +
+ * the `policyPipeline` mapper into category-shaped operations the hook can use:
+ * fetch the stored policies (grouped by catalog category), persist one, flip its
+ * enabled flag, and delete it.
+ *
+ * The frontend is category-keyed (one policy per catalog category); the backend
+ * is a flat list with assigned ids. The bridge is `trigger.options.categoryId`,
+ * which `policyPipeline` encodes on save and decodes on read.
+ */
+
+import * as policyApi from "@app/services/policyApi";
+import {
+ buildBackendPolicy,
+ fromBackendPolicy,
+ type DecodedPolicy,
+ type PolicyToStore,
+} from "@app/services/policyPipeline";
+import type { PolicyState } from "@app/types/policies";
+
+/**
+ * Fetch every stored policy and decode it, keyed by its catalog category. If two
+ * stored policies share a category (shouldn't happen — one per category), the
+ * last one wins; policies with no recognised categoryId are skipped.
+ */
+export async function fetchPoliciesByCategory(): Promise<
+ Map
+> {
+ const stored = await policyApi.listPolicies();
+ const byCategory = new Map();
+ for (const policy of stored) {
+ const decoded = fromBackendPolicy(policy);
+ if (decoded.categoryId) byCategory.set(decoded.categoryId, decoded);
+ }
+ return byCategory;
+}
+
+/**
+ * Project a decoded backend policy onto the frontend per-category state. The
+ * locally-cached `folderId` (the editable-automation link, which the backend
+ * doesn't track) is preserved by the caller via `localFolderId`.
+ */
+export function decodedToState(
+ decoded: DecodedPolicy,
+ localFolderId: string | undefined,
+): PolicyState {
+ return {
+ configured: true,
+ status: decoded.enabled ? "active" : "paused",
+ sources: decoded.sources,
+ scopeTypes: decoded.scopeTypes,
+ reviewerEmail: decoded.reviewerEmail,
+ fieldValues: decoded.fieldValues,
+ folderId: localFolderId,
+ backendId: decoded.id,
+ // Catalog-category policies are built-in defaults (not deletable).
+ isDefault: true,
+ };
+}
+
+/**
+ * The backend id of the stored policy for a category, if one exists. Used to
+ * enforce one-policy-per-category: a save reuses this id (update) rather than
+ * creating a duplicate, even if the local cache lost the link.
+ */
+export async function findBackendId(
+ categoryId: string,
+): Promise {
+ const byCategory = await fetchPoliciesByCategory();
+ return byCategory.get(categoryId)?.id;
+}
+
+/** Persist a policy (create or update); returns the backend-assigned id. */
+export async function persistPolicy(store: PolicyToStore): Promise {
+ const saved = await policyApi.savePolicy(buildBackendPolicy(store));
+ return saved.id;
+}
+
+/**
+ * Flip a stored policy's `enabled` flag (pause/resume) — the backend gates
+ * automatic triggering on it. Reads the current policy so the rest of its config
+ * is preserved on the round-trip.
+ */
+export async function setPolicyEnabled(
+ backendId: string,
+ enabled: boolean,
+): Promise {
+ const current = await policyApi.getPolicy(backendId);
+ await policyApi.savePolicy({ ...current, enabled });
+}
+
+/** Delete a stored policy by its backend id. */
+export async function removePolicy(backendId: string): Promise {
+ await policyApi.deletePolicy(backendId);
+}
diff --git a/frontend/editor/src/proprietary/services/policyCatalog.ts b/frontend/editor/src/proprietary/services/policyCatalog.ts
new file mode 100644
index 000000000..cefe191a8
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyCatalog.ts
@@ -0,0 +1,43 @@
+/**
+ * The catalog of available policy *definitions* — the policy types, their
+ * editable fields, the sources a policy can run over, and the document types
+ * scope can be narrowed to. This is the single seam through which definitions
+ * reach the UI: components never import the static definitions directly, they
+ * read the catalog (via {@link usePolicyCatalog}).
+ *
+ * Backed by the static `policyDefinitions` today; swap {@link loadPolicyCatalog}
+ * for a fetch to move definitions server-side without touching any component.
+ */
+
+import {
+ POLICY_CATEGORIES,
+ POLICY_CONFIG,
+ POLICY_SOURCES,
+ POLICY_DOC_TYPES,
+} from "@app/data/policyDefinitions";
+import type {
+ PolicyCategory,
+ PolicyConfigDef,
+ PolicySource,
+} from "@app/types/policies";
+
+export interface PolicyCatalog {
+ /** Available policy types, in display order. */
+ categories: PolicyCategory[];
+ /** Per-category narrative + editable field definitions, keyed by category id. */
+ configs: Record;
+ /** Sources a policy can run over (wizard step 2). */
+ sources: PolicySource[];
+ /** Document types scope can be narrowed to (gated behind classification). */
+ docTypes: string[];
+}
+
+/** Load the policy catalog. Swap this for a backend fetch to go live. */
+export function loadPolicyCatalog(): PolicyCatalog {
+ return {
+ categories: POLICY_CATEGORIES,
+ configs: POLICY_CONFIG,
+ sources: POLICY_SOURCES,
+ docTypes: POLICY_DOC_TYPES,
+ };
+}
diff --git a/frontend/editor/src/proprietary/services/policyFolders.test.ts b/frontend/editor/src/proprietary/services/policyFolders.test.ts
new file mode 100644
index 000000000..1a34a9414
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyFolders.test.ts
@@ -0,0 +1,85 @@
+import "fake-indexeddb/auto";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+// jsdom's crypto has no randomUUID, which watchedFolderStorage uses for folder ids.
+if (typeof globalThis.crypto?.randomUUID !== "function") {
+ const orig = globalThis.crypto;
+ vi.stubGlobal("crypto", {
+ getRandomValues: orig?.getRandomValues?.bind(orig),
+ randomUUID: () =>
+ `p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
+ });
+}
+
+import {
+ createPolicyFolder,
+ getPolicyOperations,
+ updatePolicyOperations,
+ setPolicyFolderPaused,
+ deletePolicyFolder,
+} from "@app/services/policyFolders";
+import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
+import { automationStorage } from "@app/services/automationStorage";
+import type { PolicyCategory } from "@app/types/policies";
+
+const category: PolicyCategory = {
+ id: "security",
+ label: "Security",
+ icon: null,
+ desc: "Detect PII, encrypt, verify.",
+};
+
+const steps = [
+ { operation: "sanitize", parameters: {} },
+ { operation: "addPassword", parameters: {} },
+];
+
+describe("policyFolders backing-folder layer", () => {
+ beforeEach(async () => {
+ // Clean slate between tests (fake-indexeddb persists within a run).
+ for (const f of await watchedFolderStorage.getAllFolders()) {
+ await watchedFolderStorage.deleteFolder(f.id);
+ }
+ for (const a of await automationStorage.getAllAutomations()) {
+ await automationStorage.deleteAutomation(a.id);
+ }
+ });
+
+ it("creates a folder + automation tagged with the policy category", async () => {
+ const folder = await createPolicyFolder(category, steps);
+ expect(folder.policyCategoryId).toBe("security");
+ expect(folder.automationId).toBeTruthy();
+
+ const ops = await getPolicyOperations(folder.id);
+ expect(ops.map((o) => o.operation)).toEqual(["sanitize", "addPassword"]);
+ });
+
+ it("updates the steps through the backing automation", async () => {
+ const folder = await createPolicyFolder(category, steps);
+ await updatePolicyOperations(folder.id, [
+ { operation: "compress", parameters: {} },
+ ]);
+ const ops = await getPolicyOperations(folder.id);
+ expect(ops.map((o) => o.operation)).toEqual(["compress"]);
+ });
+
+ it("pauses/resumes via the backing folder flag", async () => {
+ const folder = await createPolicyFolder(category, steps);
+ await setPolicyFolderPaused(folder.id, true);
+ expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe(
+ true,
+ );
+ await setPolicyFolderPaused(folder.id, false);
+ expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe(
+ false,
+ );
+ });
+
+ it("deletes the folder and its automation", async () => {
+ const folder = await createPolicyFolder(category, steps);
+ const automationId = folder.automationId;
+ await deletePolicyFolder(folder.id);
+ expect(await watchedFolderStorage.getFolder(folder.id)).toBeNull();
+ expect(await automationStorage.getAutomation(automationId)).toBeNull();
+ });
+});
diff --git a/frontend/editor/src/proprietary/services/policyFolders.ts b/frontend/editor/src/proprietary/services/policyFolders.ts
new file mode 100644
index 000000000..7f034f904
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyFolders.ts
@@ -0,0 +1,137 @@
+/**
+ * Backing-folder layer for Policies. A configured policy's folder trigger,
+ * editable steps, output and run-state all live in a Watched Folders
+ * {@link WatchedFolder} (+ its {@link AutomationConfig}) — the policy reuses the
+ * Watched Folders engine rather than re-implementing execution. This module is
+ * the seam that creates and manages that backing record.
+ *
+ * The folder is tagged with `policyCategoryId` so the Watched Folders UI can
+ * filter it out (it's owned by Policies). The backing automation also rides
+ * along to the backend (in the saved policy's output.options) for round-trip;
+ * this folder remains the locally-editable copy.
+ */
+
+import { automationStorage } from "@app/services/automationStorage";
+import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
+import type {
+ AutomationConfig,
+ AutomationOperation,
+} from "@app/types/automation";
+import type { WatchedFolder } from "@app/types/watchedFolders";
+import type { PolicyCategory, PolicyFolderSettings } from "@app/types/policies";
+
+/** Folder icon (a name string) used for each policy category's backing folder. */
+const CATEGORY_FOLDER_ICON: Record = {
+ ingestion: "StorageIcon",
+ security: "SecurityIcon",
+ compliance: "CheckIcon",
+ routing: "SwapHorizIcon",
+ retention: "StorageIcon",
+};
+
+const POLICY_FOLDER_ACCENT = "#3b82f6";
+
+/**
+ * Create the backing folder for a policy: persist an automation from the given
+ * steps, then a WatchedFolder (the folder trigger) referencing it, tagged with
+ * the policy's category id. Returns the created folder.
+ */
+export async function createPolicyFolder(
+ category: PolicyCategory,
+ operations: AutomationOperation[],
+): Promise {
+ const automation = await automationStorage.saveAutomation({
+ name: `${category.label} Policy`,
+ description: `Pipeline for the ${category.label} policy`,
+ operations,
+ });
+ return watchedFolderStorage.createFolder({
+ name: `${category.label} Policy`,
+ description: category.desc,
+ automationId: automation.id,
+ icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon",
+ accentColor: POLICY_FOLDER_ACCENT,
+ policyCategoryId: category.id,
+ inputSource: "idb",
+ });
+}
+
+/**
+ * Create the backing folder for a policy from an *already-saved* automation
+ * (e.g. one the workflow builder just created). Pairs with the wizard, where
+ * AutomationCreation persists the automation and we link a folder to it.
+ */
+export async function createPolicyFolderForAutomation(
+ category: PolicyCategory,
+ automationId: string,
+): Promise {
+ return watchedFolderStorage.createFolder({
+ name: `${category.label} Policy`,
+ description: category.desc,
+ automationId,
+ icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon",
+ accentColor: POLICY_FOLDER_ACCENT,
+ policyCategoryId: category.id,
+ inputSource: "idb",
+ });
+}
+
+/** The policy's current steps, resolved through its backing folder's automation. */
+export async function getPolicyOperations(
+ folderId: string,
+): Promise {
+ const folder = await watchedFolderStorage.getFolder(folderId);
+ if (!folder) return [];
+ const automation = await automationStorage.getAutomation(folder.automationId);
+ return automation?.operations ?? [];
+}
+
+/** The policy's backing automation (its editable pipeline), via its folder. */
+export async function getPolicyAutomation(
+ folderId: string,
+): Promise {
+ const folder = await watchedFolderStorage.getFolder(folderId);
+ if (!folder) return null;
+ return automationStorage.getAutomation(folder.automationId);
+}
+
+/** Replace the policy's steps by updating its backing automation. */
+export async function updatePolicyOperations(
+ folderId: string,
+ operations: AutomationOperation[],
+): Promise {
+ const folder = await watchedFolderStorage.getFolder(folderId);
+ if (!folder) return;
+ const automation = await automationStorage.getAutomation(folder.automationId);
+ if (!automation) return;
+ await automationStorage.updateAutomation({ ...automation, operations });
+}
+
+/** Apply output + retry settings to the policy's backing folder. */
+export async function updatePolicyFolderSettings(
+ folderId: string,
+ settings: PolicyFolderSettings,
+): Promise {
+ const folder = await watchedFolderStorage.getFolder(folderId);
+ if (!folder) return;
+ await watchedFolderStorage.updateFolder({ ...folder, ...settings });
+}
+
+/** Pause/resume the policy by toggling its backing folder's paused flag. */
+export async function setPolicyFolderPaused(
+ folderId: string,
+ paused: boolean,
+): Promise {
+ const folder = await watchedFolderStorage.getFolder(folderId);
+ if (!folder) return;
+ await watchedFolderStorage.updateFolder({ ...folder, isPaused: paused });
+}
+
+/** Delete the policy's backing folder and its automation. */
+export async function deletePolicyFolder(folderId: string): Promise {
+ const folder = await watchedFolderStorage.getFolder(folderId);
+ if (folder) {
+ await automationStorage.deleteAutomation(folder.automationId);
+ }
+ await watchedFolderStorage.deleteFolder(folderId);
+}
diff --git a/frontend/editor/src/proprietary/services/policyLiveData.test.ts b/frontend/editor/src/proprietary/services/policyLiveData.test.ts
new file mode 100644
index 000000000..0a60feeae
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyLiveData.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ runsToActivity,
+ runsToStats,
+ policyActiveFor,
+} from "@app/services/policyLiveData";
+import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
+
+function run(over: Partial): PolicyRunRecord {
+ return {
+ runId: "r1",
+ categoryId: "security",
+ fileId: "f1",
+ fileName: "f.pdf",
+ fileSize: 0,
+ status: "COMPLETED",
+ outputs: [],
+ error: null,
+ startedAt: Date.now(),
+ ...over,
+ };
+}
+
+const HOUR = 3_600_000;
+
+describe("runsToActivity", () => {
+ it("maps completed/running/failed runs to activity rows", () => {
+ const activity = runsToActivity([
+ run({ runId: "a", fileName: "fresh.pdf", status: "RUNNING" }),
+ run({
+ runId: "b",
+ fileName: "contract.pdf",
+ status: "COMPLETED",
+ fileSize: 2_100_000,
+ startedAt: Date.now() - HOUR,
+ }),
+ run({
+ runId: "c",
+ fileName: "bad.pdf",
+ status: "FAILED",
+ error: "Step 2 failed",
+ }),
+ ]);
+
+ expect(activity).toHaveLength(3);
+ expect(activity[0]).toMatchObject({
+ doc: "fresh.pdf",
+ status: "processing",
+ action: "Enforcing…",
+ });
+ expect(activity[1]).toMatchObject({
+ doc: "contract.pdf",
+ status: "enforced",
+ });
+ expect(activity[1].action).toContain("2.0 MB");
+ expect(activity[2]).toMatchObject({
+ doc: "bad.pdf",
+ status: "flagged",
+ action: "Step 2 failed",
+ });
+ });
+});
+
+describe("runsToStats", () => {
+ it("counts + sizes only the completed runs", () => {
+ const stats = runsToStats(
+ [
+ run({ runId: "a", status: "COMPLETED", fileSize: 2_100_000 }),
+ run({ runId: "b", status: "COMPLETED", fileSize: 900_000 }),
+ run({ runId: "c", status: "RUNNING", fileSize: 5_000_000 }),
+ ],
+ undefined,
+ );
+ expect(stats.enforced).toBe(2);
+ expect(stats.dataProcessed).toBe("2.9 MB");
+ expect(stats.activeFor).toBe("Today");
+ });
+
+ it("derives activeFor from the backing folder's creation time", () => {
+ const fiveDaysAgo = new Date(Date.now() - 5 * 86400000).toISOString();
+ expect(runsToStats([], fiveDaysAgo).activeFor).toBe("5d");
+ });
+});
+
+describe("policyActiveFor", () => {
+ it("returns 'Today' for a just-activated policy", () => {
+ expect(policyActiveFor(new Date().toISOString())).toBe("Today");
+ });
+ it("returns 'Today' when there's no backing folder", () => {
+ expect(policyActiveFor(undefined)).toBe("Today");
+ });
+ it("reports whole-day duration since activation", () => {
+ const fiveDaysAgo = new Date(Date.now() - 5 * 86400000).toISOString();
+ expect(policyActiveFor(fiveDaysAgo)).toBe("5d");
+ });
+});
diff --git a/frontend/editor/src/proprietary/services/policyLiveData.ts b/frontend/editor/src/proprietary/services/policyLiveData.ts
new file mode 100644
index 000000000..48d2ba2b7
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyLiveData.ts
@@ -0,0 +1,100 @@
+/**
+ * Maps real backend policy runs (from policyRunStore) into the detail view's
+ * activity feed + summary stats. Runs are produced by the auto-run controller
+ * firing `/api/v1/policies/{id}/run` on every uploaded file, so the feed is the
+ * policy's actual enforcement history — not a cosmetic file listing.
+ */
+
+import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
+import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
+
+/** Relative "Nm/Nh ago" for an activity timestamp (epoch ms). */
+function relativeTime(ts: number): string {
+ if (!ts) return "—";
+ const mins = Math.floor((Date.now() - ts) / 60000);
+ if (mins < 1) return "Just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ const days = Math.floor(hrs / 24);
+ return days === 1 ? "Yesterday" : `${days}d ago`;
+}
+
+/** Duration since a timestamp, e.g. "18d" / "5h" (no "ago"). */
+function durationSince(ts: number): string {
+ const ms = Date.now() - ts;
+ const days = Math.floor(ms / 86400000);
+ if (days >= 1) return `${days}d`;
+ const hrs = Math.floor(ms / 3600000);
+ return hrs >= 1 ? `${hrs}h` : "Today";
+}
+
+/**
+ * Human byte size, e.g. "2.1 MB". Intentionally NOT core `formatFileSize`:
+ * that one renders 2 decimals (`2.13 MB`), whereas the policy summary wants
+ * the quieter whole-number / single-decimal form used across this surface.
+ */
+function formatBytes(bytes: number): string {
+ if (!bytes) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let v = bytes;
+ let i = 0;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i += 1;
+ }
+ return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
+}
+
+/** Map a run's lifecycle status to an activity row's display status. */
+function activityStatus(run: PolicyRunRecord): PolicyActivityItem["status"] {
+ if (run.status === "COMPLETED") return "enforced";
+ if (run.status === "FAILED" || run.status === "CANCELLED") return "flagged";
+ return "processing";
+}
+
+function activityAction(run: PolicyRunRecord): string {
+ switch (activityStatus(run)) {
+ case "enforced":
+ return `${formatBytes(run.fileSize)} • enforced`;
+ case "flagged":
+ return run.error ?? "Enforcement failed";
+ default:
+ return "Enforcing…";
+ }
+}
+
+/** Build the detail view's activity feed from a category's runs (newest first). */
+export function runsToActivity(runs: PolicyRunRecord[]): PolicyActivityItem[] {
+ return runs.map((run) => ({
+ doc: run.fileName,
+ action: activityAction(run),
+ time: relativeTime(run.startedAt),
+ status: activityStatus(run),
+ runId: run.runId,
+ fileId: run.fileId,
+ }));
+}
+
+/** Build the summary stats from a category's runs. */
+export function runsToStats(
+ runs: PolicyRunRecord[],
+ folderCreatedAt: string | undefined,
+): PolicyStats {
+ const enforced = runs.filter((r) => r.status === "COMPLETED");
+ const bytes = enforced.reduce((sum, r) => sum + (r.fileSize ?? 0), 0);
+ return {
+ enforced: enforced.length,
+ dataProcessed: formatBytes(bytes),
+ activeFor: policyActiveFor(folderCreatedAt),
+ };
+}
+
+/**
+ * How long a policy has been active — from its backing folder's creation time
+ * (when it was enabled), or "Today" when there's no folder yet.
+ */
+export function policyActiveFor(folderCreatedAt: string | undefined): string {
+ if (!folderCreatedAt) return "Today";
+ return durationSince(new Date(folderCreatedAt).getTime());
+}
diff --git a/frontend/editor/src/proprietary/services/policyPipeline.test.ts b/frontend/editor/src/proprietary/services/policyPipeline.test.ts
new file mode 100644
index 000000000..ed4d0a831
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyPipeline.test.ts
@@ -0,0 +1,157 @@
+import { describe, it, expect } from "vitest";
+import {
+ buildPipelineDefinition,
+ buildBackendPolicy,
+ fromBackendPolicy,
+} from "@app/services/policyPipeline";
+import type { ToolRegistry } from "@app/data/toolsTaxonomy";
+
+// Minimal registry: a static-endpoint tool and a function-endpoint tool.
+const registry = {
+ compress: { operationConfig: { endpoint: "/api/v1/misc/compress-pdf" } },
+ rotate: {
+ operationConfig: {
+ endpoint: (p: Record) =>
+ `/api/v1/general/rotate-pdf?angle=${p.angle}`,
+ },
+ },
+} as unknown as Partial;
+
+describe("buildPipelineDefinition", () => {
+ it("maps frontend operations to backend endpoint steps", () => {
+ const { definition, unresolved } = buildPipelineDefinition(
+ {
+ name: "Secure Ingestion",
+ operations: [
+ { operation: "compress", parameters: {} },
+ { operation: "rotate", parameters: { angle: 90 } },
+ ],
+ },
+ registry,
+ );
+
+ expect(unresolved).toEqual([]);
+ expect(definition.name).toBe("Secure Ingestion");
+ expect(definition.output).toEqual({ type: "inline", options: {} });
+ expect(definition.steps).toEqual([
+ { operation: "/api/v1/misc/compress-pdf", parameters: {} },
+ {
+ operation: "/api/v1/general/rotate-pdf?angle=90",
+ parameters: { angle: 90 },
+ },
+ ]);
+ });
+
+ it("drops + reports operations with no resolvable endpoint", () => {
+ const { definition, unresolved } = buildPipelineDefinition(
+ {
+ name: "X",
+ operations: [
+ { operation: "compress", parameters: {} },
+ { operation: "notARealTool", parameters: {} },
+ ],
+ },
+ registry,
+ );
+ expect(unresolved).toEqual(["notARealTool"]);
+ expect(definition.steps).toHaveLength(1);
+ });
+
+ it("runs each tool's buildFormData so stored params match its endpoint", () => {
+ // A redact-like tool whose UI param (wordsToRedact[]) is transformed into
+ // the endpoint's field (listOfText) by buildFormData — the "marry up".
+ const redactish = {
+ redact: {
+ operationConfig: {
+ endpoint: () => "/api/v1/security/auto-redact",
+ buildFormData: (
+ p: { wordsToRedact?: string[]; useRegex?: boolean },
+ file: File,
+ ) => {
+ const fd = new FormData();
+ fd.append("fileInput", file);
+ fd.append("listOfText", (p.wordsToRedact ?? []).join("\n"));
+ fd.append("useRegex", String(p.useRegex ?? false));
+ return fd;
+ },
+ },
+ },
+ } as unknown as Partial;
+
+ const { definition } = buildPipelineDefinition(
+ {
+ name: "Security",
+ operations: [
+ {
+ operation: "redact",
+ parameters: { wordsToRedact: ["SSN", "Account"], useRegex: true },
+ },
+ ],
+ },
+ redactish,
+ );
+
+ // The document field is dropped; the UI param became the endpoint field.
+ expect(definition.steps[0]).toEqual({
+ operation: "/api/v1/security/auto-redact",
+ parameters: { listOfText: "SSN\nAccount", useRegex: "true" },
+ });
+ });
+});
+
+const samplePolicy = {
+ categoryId: "security",
+ name: "Security",
+ enabled: true,
+ automation: {
+ id: "auto-1",
+ name: "Security",
+ operations: [{ operation: "compress", parameters: {} }],
+ createdAt: "",
+ updatedAt: "",
+ },
+ pipelineSteps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
+ sources: ["editor"],
+ scopeTypes: ["Contracts"],
+ reviewerEmail: "me@x.com",
+ fieldValues: { minConfidence: "80%" },
+ folder: {
+ outputMode: "new_version" as const,
+ outputName: "secured",
+ outputNamePosition: "suffix" as const,
+ maxRetries: 2,
+ retryDelayMinutes: 10,
+ },
+};
+
+describe("buildBackendPolicy", () => {
+ it("maps a frontend policy to the backend Policy shape", () => {
+ const policy = buildBackendPolicy(samplePolicy);
+ expect(policy.id).toBe(""); // blank → backend assigns
+ expect(policy.name).toBe("Security");
+ expect(policy.enabled).toBe(true);
+ expect(policy.steps).toEqual([
+ { operation: "/api/v1/misc/compress-pdf", parameters: {} },
+ ]);
+ // Extras ride in options.
+ expect(policy.trigger.options.categoryId).toBe("security");
+ expect(policy.trigger.options.reviewerEmail).toBe("me@x.com");
+ expect(policy.output.options.maxRetries).toBe(2);
+ });
+
+ it("round-trips losslessly through fromBackendPolicy", () => {
+ const policy = buildBackendPolicy(samplePolicy);
+ const decoded = fromBackendPolicy({ ...policy, id: "p1" });
+ expect(decoded.id).toBe("p1");
+ expect(decoded.categoryId).toBe("security");
+ expect(decoded.enabled).toBe(true);
+ expect(decoded.sources).toEqual(["editor"]);
+ expect(decoded.scopeTypes).toEqual(["Contracts"]);
+ expect(decoded.reviewerEmail).toBe("me@x.com");
+ expect(decoded.fieldValues).toEqual({ minConfidence: "80%" });
+ expect(decoded.folder).toEqual(samplePolicy.folder);
+ expect(decoded.automation?.operations).toEqual(
+ samplePolicy.automation.operations,
+ );
+ });
+});
diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts
new file mode 100644
index 000000000..4b691e988
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyPipeline.ts
@@ -0,0 +1,291 @@
+/**
+ * Bridge between the frontend automation model and the backend Policies engine.
+ * "Backend automation" = send the whole pipeline + files to
+ * `/api/v1/policies/run` and let the server orchestrate the steps, instead of
+ * the browser running them one-by-one via executeAutomationSequence.
+ *
+ * The backend `PipelineStep.operation` is a tool endpoint *path*
+ * (e.g. `/api/v1/misc/compress-pdf`) — exactly the `operationConfig.endpoint`
+ * the frontend tool registry already carries for client-side execution. This
+ * module maps a frontend AutomationConfig to the backend's PipelineDefinition
+ * using that registry.
+ */
+
+import type { AutomationConfig } from "@app/types/automation";
+import type { ToolRegistry } from "@app/data/toolsTaxonomy";
+import type { PolicyFolderSettings } from "@app/types/policies";
+
+/** A single backend pipeline step: a tool endpoint path + its scalar params. */
+export interface BackendPipelineStep {
+ operation: string;
+ parameters: Record;
+ fileParameters?: Record;
+}
+
+/** Where the run's outputs are delivered. "inline" = return for download. */
+export interface BackendOutputSpec {
+ type: string;
+ options: Record;
+}
+
+/** The engine-level pipeline the `/run` endpoint accepts (as JSON). */
+export interface BackendPipelineDefinition {
+ name: string;
+ steps: BackendPipelineStep[];
+ output: BackendOutputSpec;
+}
+
+/** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */
+export interface BackendTriggerConfig {
+ type: string;
+ options: Record;
+}
+
+/** A stored, owned policy on the backend (mirrors the Java `Policy` record). */
+export interface BackendPolicy {
+ /** Blank on create — the backend assigns an id and returns it. */
+ id: string;
+ name: string;
+ owner: string;
+ /** Gates automatic triggering; an explicit run ignores it. */
+ enabled: boolean;
+ trigger: BackendTriggerConfig;
+ steps: BackendPipelineStep[];
+ output: BackendOutputSpec;
+}
+
+/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
+export type PolicyRunStatus =
+ | "PENDING"
+ | "RUNNING"
+ | "WAITING_FOR_INPUT"
+ | "COMPLETED"
+ | "FAILED"
+ | "CANCELLED";
+
+export interface BackendResultFile {
+ fileId: string;
+ fileName: string;
+}
+
+/** Read-only view returned by the run status endpoint (mirrors PolicyRunView). */
+export interface PolicyRunView {
+ runId: string;
+ status: PolicyRunStatus;
+ currentStep: number;
+ stepCount: number;
+ error: string | null;
+ outputs: BackendResultFile[];
+}
+
+/** Resolve a frontend operation id to its backend tool endpoint path. */
+function resolveEndpoint(
+ operation: string,
+ parameters: Record,
+ toolRegistry: Partial,
+): string | null {
+ const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig;
+ const endpoint = config?.endpoint;
+ if (!endpoint) return null;
+ const resolved =
+ typeof endpoint === "function" ? endpoint(parameters) : endpoint;
+ return resolved ?? null;
+}
+
+/**
+ * Convert a tool's UI parameters into the exact scalar form-fields its backend
+ * endpoint expects, by running the same `buildFormData` the client-side runner
+ * uses (the one source of truth for the request shape) and keeping its non-file
+ * fields. This is what makes the stored steps "marry up" with the engine: e.g.
+ * redact's `wordsToRedact: string[]` becomes the `listOfText` string the
+ * /auto-redact endpoint reads. Falls back to the raw params if the tool has no
+ * transform (or it throws), so tools without one are unaffected.
+ */
+function toApiParameters(
+ config: ToolRegistry[keyof ToolRegistry]["operationConfig"] | undefined,
+ parameters: Record,
+): Record {
+ const build = config?.buildFormData;
+ if (typeof build !== "function") return parameters;
+ const dummy = new File([], "input.pdf", { type: "application/pdf" });
+ // buildFormData takes a File (single-file tools) or File[] (multi) — try both.
+ for (const fileArg of [dummy, [dummy]]) {
+ try {
+ const formData = build(parameters, fileArg as never);
+ const out: Record = {};
+ // Keep scalar fields; skip File entries (the document(s) the engine feeds
+ // separately, and any supporting-file blobs).
+ formData.forEach((value, key) => {
+ if (typeof value === "string") out[key] = value;
+ });
+ return out;
+ } catch {
+ // Wrong file-arg shape for this tool — try the other, then give up.
+ }
+ }
+ return parameters;
+}
+
+/**
+ * Map a frontend automation to the backend pipeline definition. Steps whose
+ * endpoint can't be resolved from the registry are dropped (and reported), so
+ * the backend never receives an unrunnable operation id.
+ */
+export function buildPipelineDefinition(
+ automation: Pick,
+ toolRegistry: Partial,
+): { definition: BackendPipelineDefinition; unresolved: string[] } {
+ const unresolved: string[] = [];
+ const steps: BackendPipelineStep[] = [];
+ for (const op of automation.operations) {
+ const parameters = (op.parameters ?? {}) as Record;
+ const endpoint = resolveEndpoint(op.operation, parameters, toolRegistry);
+ if (!endpoint) {
+ unresolved.push(op.operation);
+ continue;
+ }
+ const config =
+ toolRegistry[op.operation as keyof ToolRegistry]?.operationConfig;
+ steps.push({
+ operation: endpoint,
+ parameters: toApiParameters(config, parameters),
+ });
+ }
+ return {
+ definition: {
+ name: automation.name,
+ steps,
+ output: { type: "inline", options: {} },
+ },
+ unresolved,
+ };
+}
+
+/** A frontend policy ready to persist on the backend (the full settings set). */
+export interface PolicyToStore {
+ /** Existing backend id (blank/omitted → create). */
+ id?: string;
+ /** The frontend catalog category this policy belongs to (1 policy per category). */
+ categoryId: string;
+ name: string;
+ /** Active (enabled) vs paused/off. */
+ enabled: boolean;
+ /** Full frontend automation, stashed for a lossless UI round-trip. */
+ automation: AutomationConfig;
+ /**
+ * The engine-runnable steps (endpoint paths), pre-built from `automation` via
+ * the tool registry by the caller that has it (the wizard). The store layer
+ * has no registry, so it receives these ready-made.
+ */
+ pipelineSteps: BackendPipelineStep[];
+ sources: string[];
+ scopeTypes: string[];
+ reviewerEmail: string;
+ fieldValues: Record;
+ folder: PolicyFolderSettings;
+}
+
+/** The decoded policy read back from the backend. */
+export interface DecodedPolicy {
+ id: string;
+ /** The catalog category this policy maps to (from trigger.options.categoryId). */
+ categoryId: string;
+ name: string;
+ enabled: boolean;
+ /** Null if the stored policy carried no automation blob. */
+ automation: AutomationConfig | null;
+ sources: string[];
+ scopeTypes: string[];
+ reviewerEmail: string;
+ fieldValues: Record;
+ folder: PolicyFolderSettings;
+}
+
+const DEFAULT_FOLDER: PolicyFolderSettings = {
+ outputMode: "new_file",
+ outputName: "",
+ outputNamePosition: "prefix",
+ maxRetries: 3,
+ retryDelayMinutes: 5,
+};
+
+/**
+ * Map a frontend policy to the backend {@link BackendPolicy} for persistence.
+ * The backend models only name/enabled/trigger/steps/output, so the policy-level
+ * extras (categoryId, sources, scope, reviewer, fields) ride in `trigger.options`
+ * and the output + retry settings in `output.options`; the full frontend
+ * automation is stashed in `output.options.automation` for a lossless UI
+ * round-trip (while `steps` carries the endpoint-mapped pipeline the engine
+ * runs, pre-built by the caller).
+ */
+export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
+ return {
+ id: input.id ?? "",
+ name: input.name,
+ owner: "",
+ enabled: input.enabled,
+ trigger: {
+ type: "folder",
+ options: {
+ categoryId: input.categoryId,
+ sources: input.sources,
+ scopeTypes: input.scopeTypes,
+ reviewerEmail: input.reviewerEmail,
+ fieldValues: input.fieldValues,
+ },
+ },
+ steps: input.pipelineSteps,
+ output: {
+ type: "inline",
+ options: {
+ mode: input.folder.outputMode,
+ name: input.folder.outputName,
+ position: input.folder.outputNamePosition,
+ maxRetries: input.folder.maxRetries,
+ retryDelayMinutes: input.folder.retryDelayMinutes,
+ automation: input.automation,
+ },
+ },
+ };
+}
+
+/** Decode a stored backend policy back into the frontend settings. */
+export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
+ const trigger = policy.trigger.options;
+ const output = policy.output.options;
+ const str = (v: unknown, fallback = "") =>
+ typeof v === "string" ? v : fallback;
+ const num = (v: unknown, fallback: number) =>
+ typeof v === "number" ? v : fallback;
+ return {
+ id: policy.id,
+ categoryId: str(trigger.categoryId),
+ name: policy.name,
+ enabled: policy.enabled,
+ automation: (output.automation as AutomationConfig | undefined) ?? null,
+ sources: Array.isArray(trigger.sources)
+ ? (trigger.sources as string[])
+ : [],
+ scopeTypes: Array.isArray(trigger.scopeTypes)
+ ? (trigger.scopeTypes as string[])
+ : [],
+ reviewerEmail: str(trigger.reviewerEmail),
+ fieldValues:
+ (trigger.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {},
+ folder: {
+ outputMode: output.mode === "new_version" ? "new_version" : "new_file",
+ outputName: str(output.name),
+ outputNamePosition:
+ output.position === "suffix"
+ ? "suffix"
+ : output.position === "auto-number"
+ ? "auto-number"
+ : "prefix",
+ maxRetries: num(output.maxRetries, DEFAULT_FOLDER.maxRetries),
+ retryDelayMinutes: num(
+ output.retryDelayMinutes,
+ DEFAULT_FOLDER.retryDelayMinutes,
+ ),
+ },
+ };
+}
diff --git a/frontend/editor/src/proprietary/services/policyStorage.test.ts b/frontend/editor/src/proprietary/services/policyStorage.test.ts
new file mode 100644
index 000000000..dd0622a7c
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyStorage.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import {
+ loadPolicies,
+ updatePolicy,
+ resetPolicy,
+ onPoliciesChange,
+} from "@app/services/policyStorage";
+
+describe("policyStorage", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("defaults every category to unconfigured (backend is the source of truth)", () => {
+ const p = loadPolicies();
+ expect(p.ingestion.configured).toBe(false);
+ expect(p.ingestion.status).toBe("default");
+ expect(p.security.configured).toBe(false);
+ expect(p.security.status).toBe("default");
+ expect(p.retention.configured).toBe(false);
+ });
+
+ it("persists an update and reflects it on reload", () => {
+ updatePolicy("security", { configured: true, status: "active" });
+ const reloaded = loadPolicies();
+ expect(reloaded.security.configured).toBe(true);
+ expect(reloaded.security.status).toBe("active");
+ // Other categories untouched.
+ expect(reloaded.retention.configured).toBe(false);
+ });
+
+ it("merges partial field-value updates without clobbering siblings", () => {
+ updatePolicy("security", { fieldValues: { detectPII: false } });
+ updatePolicy("security", { reviewerEmail: "x@y.com" });
+ const p = loadPolicies();
+ expect(p.security.fieldValues).toEqual({ detectPII: false });
+ expect(p.security.reviewerEmail).toBe("x@y.com");
+ });
+
+ it("resetPolicy reverts a category to unconfigured default", () => {
+ updatePolicy("compliance", {
+ configured: true,
+ status: "active",
+ reviewerEmail: "a@b.com",
+ });
+ resetPolicy("compliance");
+ const p = loadPolicies();
+ expect(p.compliance.configured).toBe(false);
+ expect(p.compliance.status).toBe("default");
+ });
+
+ it("heals missing categories from corrupt/partial storage", () => {
+ localStorage.setItem(
+ "stirling-policies-state",
+ JSON.stringify({ ingestion: { configured: true, status: "active" } }),
+ );
+ const p = loadPolicies();
+ // Missing category gets a default rather than being undefined.
+ expect(p.routing).toBeDefined();
+ expect(p.routing.configured).toBe(false);
+ });
+
+ it("fires a change event on update", () => {
+ const cb = vi.fn();
+ const off = onPoliciesChange(cb);
+ updatePolicy("routing", { status: "paused" });
+ expect(cb).toHaveBeenCalledTimes(1);
+ off();
+ updatePolicy("routing", { status: "active" });
+ expect(cb).toHaveBeenCalledTimes(1); // not called after unsubscribe
+ });
+});
diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts
new file mode 100644
index 000000000..7aa25c23e
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyStorage.ts
@@ -0,0 +1,111 @@
+/**
+ * Local cache + offline fallback for Policies — the backend (/api/v1/policies)
+ * is the source of truth. Holds per-category state (configured/active/paused,
+ * sources, scope, reviewer, field overrides) and broadcasts changes so hooks
+ * re-render. Mirrors the change-event pattern of the automation/folder stores.
+ */
+
+import { loadPolicyCatalog } from "@app/services/policyCatalog";
+import type { PoliciesByCategory, PolicyState } from "@app/types/policies";
+
+const STORAGE_KEY = "stirling-policies-state";
+export const POLICIES_CHANGE_EVENT = "stirling:policies-changed";
+
+function defaultState(): PolicyState {
+ // Unconfigured by default. The backend is the source of truth for what's
+ // actually configured + active; this is just the empty local-cache shape.
+ return {
+ configured: false,
+ status: "default",
+ sources: ["editor"],
+ scopeTypes: [],
+ // Empty by default; the wizard defaults the reviewer to the signed-in user.
+ reviewerEmail: "",
+ fieldValues: {},
+ // Every catalog category is a shipped, built-in policy → default (not
+ // deletable). User-created policies (later) will set this false.
+ isDefault: true,
+ };
+}
+
+/** An obsolete reviewer email from earlier development, scrubbed from persisted
+ * state on read so it can re-default to the real signed-in user. */
+const STALE_REVIEWER_EMAIL = "matt@stirlingpdf.com";
+
+/** Read the full policy state, seeding + healing any missing categories. */
+export function loadPolicies(): PoliciesByCategory {
+ let parsed: Partial = {};
+ try {
+ const raw =
+ typeof localStorage !== "undefined"
+ ? localStorage.getItem(STORAGE_KEY)
+ : null;
+ if (raw) parsed = JSON.parse(raw) as Partial;
+ } catch {
+ // Corrupt/unavailable storage — fall back to seed.
+ }
+ // Always reconcile against the current category list so a newly-added
+ // category gets a default rather than being undefined.
+ const out: PoliciesByCategory = {};
+ for (const cat of loadPolicyCatalog().categories) {
+ const merged = { ...defaultState(), ...(parsed[cat.id] ?? {}) };
+ // Migration: clear the obsolete persisted reviewer email so it re-defaults
+ // to the real signed-in user.
+ if (merged.reviewerEmail === STALE_REVIEWER_EMAIL)
+ merged.reviewerEmail = "";
+ out[cat.id] = merged;
+ }
+ return out;
+}
+
+function persist(state: PoliciesByCategory): void {
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ }
+ } catch {
+ // Best-effort; ignore quota/availability failures.
+ }
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new CustomEvent(POLICIES_CHANGE_EVENT));
+ }
+}
+
+/** Merge a partial update into one category's state and persist. */
+export function updatePolicy(
+ categoryId: string,
+ patch: Partial,
+): PoliciesByCategory {
+ const current = loadPolicies();
+ const next: PoliciesByCategory = {
+ ...current,
+ // Fall back to defaults so a not-yet-seeded category id still yields a
+ // complete PolicyState rather than a partial.
+ [categoryId]: {
+ ...defaultState(),
+ ...current[categoryId],
+ ...patch,
+ },
+ };
+ persist(next);
+ return next;
+}
+
+/** Reset a category to its unconfigured default (the "Delete policy" action). */
+export function resetPolicy(categoryId: string): PoliciesByCategory {
+ return updatePolicy(categoryId, {
+ ...defaultState(),
+ configured: false,
+ status: "default",
+ // Drop the backing-folder + backend links (the caller deletes those).
+ folderId: undefined,
+ backendId: undefined,
+ });
+}
+
+/** Subscribe to policy-state changes (same-tab). Returns an unsubscribe fn. */
+export function onPoliciesChange(cb: () => void): () => void {
+ if (typeof window === "undefined") return () => {};
+ window.addEventListener(POLICIES_CHANGE_EVENT, cb);
+ return () => window.removeEventListener(POLICIES_CHANGE_EVENT, cb);
+}
diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts
new file mode 100644
index 000000000..979d69625
--- /dev/null
+++ b/frontend/editor/src/proprietary/types/policies.ts
@@ -0,0 +1,211 @@
+/**
+ * Types for Policies — a proprietary, automation-backed enforcement feature.
+ *
+ * A Policy is conceptually like a Watch Folder (a configured automation that
+ * runs over documents) but backend-driven and triggered by *sources/events*
+ * (editor save/export, device sweeps, cloud connectors) rather than just a
+ * folder. Per-policy state is persisted locally (localStorage) today; server
+ * persistence and real enforcement land in a follow-up. Activity + stats shown
+ * in the detail view are derived live from the user's real uploaded files.
+ */
+
+import type { ReactNode } from "react";
+import type {
+ AutomationConfig,
+ AutomationOperation,
+} from "@app/types/automation";
+
+/** Lifecycle status of a policy category for the current user/org. */
+export type PolicyStatus = "default" | "active" | "paused";
+
+/** Derived display status for a row/detail (treats a spend-limit hit as paused). */
+export type PolicyRowStatus = "active" | "paused" | "setup";
+
+/** A configurable field within a policy's settings. */
+export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
+
+export interface PolicyField {
+ label: string;
+ key: string;
+ type: PolicyFieldType;
+ /** Current value: boolean (toggle), string (select/text), string[] (chips). */
+ value: boolean | string | string[];
+ /** Options for select/chips. */
+ options?: string[];
+}
+
+/** Static definition of a policy category (the "what it does"). */
+export interface PolicyCategory {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ /** Long description shown in the setup wizard. */
+ desc: string;
+ /**
+ * This category provides document classification, so it's the target of the
+ * setup wizard's "Set up Classification" action. Data-driven replacement for
+ * the hardcoded `selectPolicy("ingestion")` call site.
+ */
+ providesClassification?: boolean;
+ /**
+ * Not yet available — shown as a locked "Coming soon" row that can't be opened
+ * or configured. Only Security is live today.
+ */
+ comingSoon?: boolean;
+}
+
+/**
+ * The three-up summary stats shown at the foot of a configured policy's detail,
+ * derived live from the user's uploaded files (see policyLiveData).
+ */
+export interface PolicyStats {
+ /** Documents enforced (rendered with toLocaleString). */
+ enforced: number;
+ /** Human-formatted data-processed figure, e.g. "2.3 GB". */
+ dataProcessed: string;
+ /** Human-formatted active-for figure, e.g. "12d", or "—" when idle. */
+ activeFor: string;
+}
+
+/** The narrative + field configuration backing a category. */
+export interface PolicyConfigDef {
+ /** One-line summary of what the policy enforces. */
+ summary: string;
+ /** Pipeline-like rule chips shown in the "Enforces" section. */
+ rules: string[];
+ /** Human label for the scope this policy applies to. */
+ scopeLabel: string;
+ /** Editable settings fields. */
+ fields: PolicyField[];
+ /**
+ * The preset pipeline this category seeds a new policy with — the real,
+ * editable tool steps (same shape as the backend's `PipelineStep` and the
+ * Watch Folders automation `operations`). Configuring a policy starts from
+ * these and the user can edit them.
+ */
+ defaultOperations: AutomationOperation[];
+}
+
+/** A document a source can ingest, used in the wizard's "Sources" step. */
+export interface PolicySource {
+ id: string;
+ label: string;
+ desc: string;
+ icon: ReactNode;
+}
+
+/** An entry in a policy's recent-activity feed (derived from real uploads). */
+export interface PolicyActivityItem {
+ /** Document the policy acted on. */
+ doc: string;
+ /** What the policy did, e.g. "Classified as Contract • 3 tables extracted". */
+ action: string;
+ /** Relative timestamp, e.g. "2h ago". */
+ time: string;
+ /**
+ * "enforced" (clean green), "flagged" (needs review, amber), or "processing"
+ * (in progress — enforcement currently running, blue).
+ */
+ status: "enforced" | "flagged" | "processing";
+ /** The backend run this row reflects — used to offer Retry on a failed run. */
+ runId?: string;
+ /** The file the run acted on — needed to re-run it on Retry. */
+ fileId?: string;
+}
+
+/** Per-category runtime state held in the local cache. */
+export interface PolicyState {
+ configured: boolean;
+ status: PolicyStatus;
+ /** Selected sources (ids from POLICY_SOURCES). */
+ sources: string[];
+ /** When non-empty, narrows the policy to these document types. */
+ scopeTypes: string[];
+ /** Email that low-confidence enforcements are routed to. */
+ reviewerEmail: string;
+ /** Saved field values, keyed by field key (overrides the definition default). */
+ fieldValues: Record;
+ /**
+ * The backing folder-trigger record (a Watched Folders `WatchedFolder`) that
+ * holds this policy's editable steps (its automation), output config and run
+ * state. Present once the policy is configured. The folder trigger reuses the
+ * Watched Folders engine; this is the link to it. Maps to the backend's
+ * `Policy.trigger` (folder) + `steps` + `output`.
+ */
+ folderId?: string;
+ /**
+ * Id of this policy's record on the backend (the source of truth). Present once
+ * it has been persisted server-side; used to update/delete/run it.
+ */
+ backendId?: string;
+ /**
+ * A built-in policy (one of the shipped catalog categories) rather than a
+ * user-created one. Default policies are configurable but NOT deletable — the
+ * Delete action is hidden for them (it returns for custom policies later).
+ */
+ isDefault?: boolean;
+}
+
+export type PoliciesByCategory = Record;
+
+/**
+ * Output + retry settings applied by the Watch Folders engine to a policy's
+ * backing folder (the real, working settings reused from the folder setup).
+ */
+export interface PolicyFolderSettings {
+ outputMode: "new_file" | "new_version";
+ outputName: string;
+ outputNamePosition: "prefix" | "suffix" | "auto-number";
+ maxRetries: number;
+ retryDelayMinutes: number;
+}
+
+/** Everything the shared policy wizard collects, handed back on submit. */
+export interface PolicyWizardResult {
+ /** The saved workflow automation (created on setup, updated in place on edit). */
+ automation: AutomationConfig;
+ fieldValues: Record;
+ sources: string[];
+ scopeTypes: string[];
+ reviewerEmail: string;
+ /** Output + retry settings for the backing folder. */
+ folder: PolicyFolderSettings;
+ /**
+ * Backend pipeline steps (each `operation` is a tool ENDPOINT path), built
+ * from the workflow via the tool registry in the wizard's Workflow step — the
+ * hook persists these to the backend without needing the registry itself.
+ * Structurally matches policyPipeline's `BackendPipelineStep` (inlined here to
+ * avoid a types↔services import cycle).
+ */
+ pipelineSteps: {
+ operation: string;
+ parameters: Record;
+ fileParameters?: Record;
+ }[];
+ /** Operation ids whose endpoint couldn't be resolved (dropped from steps). */
+ unresolvedOps: string[];
+}
+
+/**
+ * What the locked tool-config page hands back on save. Unlike the wizard's
+ * result it carries the tool `operations` directly (the page owns a fixed,
+ * configure-only chain — there's no separate saved automation), plus the
+ * endpoint-mapped pipeline steps. Used for both first-time configure and edits
+ * of a preset policy.
+ */
+export interface PolicyConfigResult {
+ /** The enabled tools (in order) as automation operations. */
+ operations: AutomationOperation[];
+ /** Endpoint-mapped backend steps built from `operations` via the registry. */
+ pipelineSteps: PolicyWizardResult["pipelineSteps"];
+ /** Operation ids whose endpoint couldn't be resolved (dropped from steps). */
+ unresolvedOps: string[];
+ fieldValues: Record;
+ sources: string[];
+ scopeTypes: string[];
+ reviewerEmail: string;
+ folder: PolicyFolderSettings;
+}
+
+/** Which sub-view of a configured policy's detail panel is showing. */
+export type PolicyDetailView = "detail" | "settings";
diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts
index 8caadae68..bb672250d 100644
--- a/frontend/editor/vite.config.ts
+++ b/frontend/editor/vite.config.ts
@@ -203,6 +203,15 @@ export default defineConfig(async ({ mode }) => {
}),
compressStaticCopyPlugin(),
],
+ resolve: {
+ // Global alias so @shared resolves for ALL importers — including the
+ // shared components' own `@shared/components/X.css` self-imports, which
+ // live outside the editor tsconfig scope and so aren't rewritten by
+ // vite-tsconfig-paths. Required for the editor to consume SUI components.
+ alias: {
+ "@shared": path.resolve(__dirname, "../shared"),
+ },
+ },
server: {
host: true,
// make sure this port matches the devUrl port in tauri.conf.json file
diff --git a/frontend/editor/vitest.config.ts b/frontend/editor/vitest.config.ts
index 3343f0242..806a4db30 100644
--- a/frontend/editor/vitest.config.ts
+++ b/frontend/editor/vitest.config.ts
@@ -1,6 +1,12 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import tsconfigPaths from "vite-tsconfig-paths";
+import path from "node:path";
+
+// Global @shared alias so SUI components (and their own `@shared/*` self-imports,
+// which live outside the editor tsconfig scope) resolve under test — mirrors the
+// resolve.alias in vite.config.ts.
+const sharedDir = path.resolve(__dirname, "../shared");
export default defineConfig({
test: {
@@ -42,6 +48,9 @@ export default defineConfig({
projects: ["./tsconfig.core.vite.json"],
}),
],
+ resolve: {
+ alias: { "@shared": sharedDir },
+ },
esbuild: {
target: "es2020",
},
@@ -60,6 +69,9 @@ export default defineConfig({
projects: ["./tsconfig.proprietary.vite.json"],
}),
],
+ resolve: {
+ alias: { "@shared": sharedDir },
+ },
esbuild: {
target: "es2020",
},
@@ -78,6 +90,9 @@ export default defineConfig({
projects: ["./tsconfig.desktop.vite.json"],
}),
],
+ resolve: {
+ alias: { "@shared": sharedDir },
+ },
esbuild: {
target: "es2020",
},
@@ -96,6 +111,9 @@ export default defineConfig({
projects: ["./tsconfig.saas.vite.json"],
}),
],
+ resolve: {
+ alias: { "@shared": sharedDir },
+ },
esbuild: {
target: "es2020",
},
@@ -114,6 +132,9 @@ export default defineConfig({
projects: ["./tsconfig.prototypes.vite.json"],
}),
],
+ resolve: {
+ alias: { "@shared": sharedDir },
+ },
esbuild: {
target: "es2020",
},
diff --git a/frontend/shared/components/Checkbox.css b/frontend/shared/components/Checkbox.css
index a9f8aeb4e..5281cdf5e 100644
--- a/frontend/shared/components/Checkbox.css
+++ b/frontend/shared/components/Checkbox.css
@@ -75,6 +75,12 @@
display: block;
}
+.sui-check__icon {
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-text-4);
+ flex-shrink: 0;
+}
.sui-check__text {
display: flex;
flex-direction: column;
diff --git a/frontend/shared/components/Checkbox.tsx b/frontend/shared/components/Checkbox.tsx
index 245242046..7d6be673d 100644
--- a/frontend/shared/components/Checkbox.tsx
+++ b/frontend/shared/components/Checkbox.tsx
@@ -7,13 +7,15 @@ export interface CheckboxProps extends Omit<
> {
label?: ReactNode;
description?: ReactNode;
+ /** Optional glyph shown between the box and the label text. */
+ leadingIcon?: ReactNode;
/** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */
indeterminate?: boolean;
}
export const Checkbox = forwardRef(
function Checkbox(
- { label, description, indeterminate, className, id, ...rest },
+ { label, description, leadingIcon, indeterminate, className, id, ...rest },
ref,
) {
return (
@@ -57,6 +59,11 @@ export const Checkbox = forwardRef(
+ {leadingIcon && (
+
+ {leadingIcon}
+
+ )}
{(label || description) && (
{label && {label} }
diff --git a/frontend/shared/components/ChipFlow.css b/frontend/shared/components/ChipFlow.css
new file mode 100644
index 000000000..13eba56df
--- /dev/null
+++ b/frontend/shared/components/ChipFlow.css
@@ -0,0 +1,12 @@
+.sui-chipflow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+}
+.sui-chipflow__sep {
+ color: var(--color-text-4);
+ font-size: 0.75rem;
+ line-height: 1;
+ flex-shrink: 0;
+}
diff --git a/frontend/shared/components/ChipFlow.stories.tsx b/frontend/shared/components/ChipFlow.stories.tsx
new file mode 100644
index 000000000..5be1c7238
--- /dev/null
+++ b/frontend/shared/components/ChipFlow.stories.tsx
@@ -0,0 +1,25 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ChipFlow } from "@shared/components/ChipFlow";
+
+const meta: Meta = {
+ title: "Primitives/ChipFlow",
+ component: ChipFlow,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: {
+ items: ["Classify", "Extract", "Name", "Normalize"],
+ separator: "arrow",
+ tone: "neutral",
+ size: "sm",
+ },
+ argTypes: {
+ separator: { control: "inline-radio", options: ["arrow", "none"] },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Pipeline: Story = { args: { separator: "arrow" } };
+export const Plain: Story = {
+ args: { separator: "none", items: ["HIPAA", "GDPR", "SOC 2"] },
+};
diff --git a/frontend/shared/components/ChipFlow.tsx b/frontend/shared/components/ChipFlow.tsx
new file mode 100644
index 000000000..7415387de
--- /dev/null
+++ b/frontend/shared/components/ChipFlow.tsx
@@ -0,0 +1,47 @@
+import { Fragment } from "react";
+import type { ReactNode } from "react";
+import { Chip } from "@shared/components/Chip";
+import type { ChipTone, ChipSize } from "@shared/components/Chip";
+import "@shared/components/ChipFlow.css";
+
+export interface ChipFlowProps {
+ /** Items rendered as chips, in order. */
+ items: ReactNode[];
+ /** `arrow` joins chips with a → connector (pipeline look); `none` just wraps. */
+ separator?: "arrow" | "none";
+ tone?: ChipTone;
+ size?: ChipSize;
+ className?: string;
+}
+
+/**
+ * A sequence of {@link Chip}s, optionally joined by arrows to read as a
+ * pipeline (A → B → C). Use `separator="arrow"` for flows, `none` for a plain
+ * wrapped chip list.
+ */
+export function ChipFlow({
+ items,
+ separator = "none",
+ tone = "neutral",
+ size = "sm",
+ className,
+}: ChipFlowProps) {
+ return (
+
+ {items.map((item, i) => (
+
+ {i > 0 && separator === "arrow" && (
+
+ →
+
+ )}
+
+ {item}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/shared/components/DataRow.css b/frontend/shared/components/DataRow.css
new file mode 100644
index 000000000..ab5fbf69a
--- /dev/null
+++ b/frontend/shared/components/DataRow.css
@@ -0,0 +1,23 @@
+.sui-datarow {
+ display: flex;
+ gap: 0.5rem;
+}
+.sui-datarow--center {
+ align-items: center;
+}
+.sui-datarow--top {
+ align-items: flex-start;
+}
+.sui-datarow__label {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--color-text-4);
+}
+.sui-datarow__value {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.75rem;
+ color: var(--color-text-1);
+ overflow-wrap: anywhere;
+}
diff --git a/frontend/shared/components/DataRow.stories.tsx b/frontend/shared/components/DataRow.stories.tsx
new file mode 100644
index 000000000..887d8541f
--- /dev/null
+++ b/frontend/shared/components/DataRow.stories.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { DataRow } from "@shared/components/DataRow";
+import { ChipFlow } from "@shared/components/ChipFlow";
+import { Card } from "@shared/components/Card";
+
+const meta: Meta = {
+ title: "Primitives/DataRow",
+ component: DataRow,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Single: Story = {
+ args: { label: "Reviewer", children: "matt@stirlingpdf.com" },
+};
+
+export const Summary: Story = {
+ render: () => (
+
+
+
+
+
+ 3 selected
+ matt@stirlingpdf.com
+
+
+ ),
+};
diff --git a/frontend/shared/components/DataRow.tsx b/frontend/shared/components/DataRow.tsx
new file mode 100644
index 000000000..93a52377e
--- /dev/null
+++ b/frontend/shared/components/DataRow.tsx
@@ -0,0 +1,41 @@
+import type { ReactNode } from "react";
+import "@shared/components/DataRow.css";
+
+export interface DataRowProps {
+ /** Left-aligned key. */
+ label: ReactNode;
+ /** Value — text, chips, or any node. */
+ children: ReactNode;
+ /** Fixed key-column width (CSS length). Defaults to 4.5rem. */
+ labelWidth?: string;
+ /** Vertical alignment of label vs value. `top` suits multi-line values. */
+ align?: "center" | "top";
+ className?: string;
+}
+
+/**
+ * A key/value row for read-only detail/summary read-outs (a single line of a
+ * description list). Compose several inside a Card. Use `align="top"` when the
+ * value wraps (e.g. a chip flow).
+ */
+export function DataRow({
+ label,
+ children,
+ labelWidth = "4.5rem",
+ align = "center",
+ className,
+}: DataRowProps) {
+ return (
+
+
+ {label}
+
+
{children}
+
+ );
+}
diff --git a/frontend/shared/components/IconBadge.css b/frontend/shared/components/IconBadge.css
new file mode 100644
index 000000000..2a510b282
--- /dev/null
+++ b/frontend/shared/components/IconBadge.css
@@ -0,0 +1,35 @@
+.sui-iconbadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-md);
+ flex-shrink: 0;
+}
+.sui-iconbadge--sm {
+ width: 1.75rem;
+ height: 1.75rem;
+}
+.sui-iconbadge--md {
+ width: 2rem;
+ height: 2rem;
+}
+.sui-iconbadge--blue {
+ color: var(--color-blue);
+ background: color-mix(in srgb, var(--color-blue) 14%, transparent);
+}
+.sui-iconbadge--purple {
+ color: var(--color-purple);
+ background: color-mix(in srgb, var(--color-purple) 14%, transparent);
+}
+.sui-iconbadge--green {
+ color: var(--color-green);
+ background: color-mix(in srgb, var(--color-green) 14%, transparent);
+}
+.sui-iconbadge--amber {
+ color: var(--color-amber);
+ background: color-mix(in srgb, var(--color-amber) 14%, transparent);
+}
+.sui-iconbadge--red {
+ color: var(--color-red);
+ background: color-mix(in srgb, var(--color-red) 14%, transparent);
+}
diff --git a/frontend/shared/components/IconBadge.stories.tsx b/frontend/shared/components/IconBadge.stories.tsx
new file mode 100644
index 000000000..8ac7f8d39
--- /dev/null
+++ b/frontend/shared/components/IconBadge.stories.tsx
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { IconBadge } from "@shared/components/IconBadge";
+
+function Glyph() {
+ return (
+
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Primitives/IconBadge",
+ component: IconBadge,
+ tags: ["autodocs"],
+ parameters: { layout: "centered" },
+ args: { accent: "blue", size: "md", children: },
+ argTypes: {
+ accent: {
+ control: "inline-radio",
+ options: ["blue", "purple", "green", "amber", "red"],
+ },
+ size: { control: "inline-radio", options: ["sm", "md"] },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Blue: Story = {};
+export const Accents: Story = {
+ render: () => (
+
+ {(["blue", "purple", "green", "amber", "red"] as const).map((a) => (
+
+
+
+ ))}
+
+ ),
+};
diff --git a/frontend/shared/components/IconBadge.tsx b/frontend/shared/components/IconBadge.tsx
new file mode 100644
index 000000000..0968b6459
--- /dev/null
+++ b/frontend/shared/components/IconBadge.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from "react";
+import "@shared/components/IconBadge.css";
+
+export type IconBadgeAccent = "blue" | "purple" | "green" | "amber" | "red";
+
+export interface IconBadgeProps {
+ children: ReactNode;
+ /** Tone tint. Defaults to blue. */
+ accent?: IconBadgeAccent;
+ size?: "sm" | "md";
+ className?: string;
+}
+
+/**
+ * A glyph in a rounded, tone-tinted square — the recurring "category icon box"
+ * motif used by panel headers, summaries and list leads. Centralises the tint
+ * so every consumer shares one treatment.
+ */
+export function IconBadge({
+ children,
+ accent = "blue",
+ size = "md",
+ className,
+}: IconBadgeProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/shared/components/ListRow.css b/frontend/shared/components/ListRow.css
new file mode 100644
index 000000000..8317b76fc
--- /dev/null
+++ b/frontend/shared/components/ListRow.css
@@ -0,0 +1,88 @@
+.sui-listrow {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.625rem;
+ width: 100%;
+ padding: 0.7rem 0.875rem;
+ text-align: left;
+ background: transparent;
+ color: var(--color-text-1);
+}
+.sui-listrow--divider {
+ border-top: 1px solid var(--color-border);
+}
+.sui-listrow--interactive {
+ cursor: pointer;
+ transition: background var(--motion-fast);
+}
+.sui-listrow--interactive:hover {
+ background: var(--color-bg-hover);
+}
+.sui-listrow--interactive:focus-visible {
+ outline: 0.125rem solid var(--color-blue);
+ outline-offset: -0.125rem;
+}
+
+.sui-listrow__leading {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.6rem;
+ height: 1.6rem;
+ border-radius: var(--radius-md);
+ flex-shrink: 0;
+ margin-top: 0.05rem;
+ color: var(--color-text-4);
+ background: var(--color-bg-muted);
+}
+.sui-listrow__leading[data-tone="success"] {
+ color: var(--color-green);
+ background: color-mix(in srgb, var(--color-green) 14%, transparent);
+}
+.sui-listrow__leading[data-tone="warning"] {
+ color: var(--color-amber);
+ background: color-mix(in srgb, var(--color-amber) 14%, transparent);
+}
+.sui-listrow__leading[data-tone="danger"] {
+ color: var(--color-red);
+ background: color-mix(in srgb, var(--color-red) 14%, transparent);
+}
+.sui-listrow__leading[data-tone="info"] {
+ color: var(--color-blue);
+ background: color-mix(in srgb, var(--color-blue) 14%, transparent);
+}
+.sui-listrow__leading[data-tone="purple"] {
+ color: var(--color-purple);
+ background: color-mix(in srgb, var(--color-purple) 14%, transparent);
+}
+
+.sui-listrow__text {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+}
+.sui-listrow__title {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-1);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.sui-listrow__desc {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ margin-top: 0.125rem;
+}
+.sui-listrow__meta {
+ font-size: 0.6875rem;
+ color: var(--color-text-4);
+ margin-top: 0.125rem;
+}
+.sui-listrow__trailing {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ margin-left: auto;
+}
diff --git a/frontend/shared/components/ListRow.stories.tsx b/frontend/shared/components/ListRow.stories.tsx
new file mode 100644
index 000000000..5aa7f7e97
--- /dev/null
+++ b/frontend/shared/components/ListRow.stories.tsx
@@ -0,0 +1,80 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ListRow } from "@shared/components/ListRow";
+import { Card } from "@shared/components/Card";
+import { StatusBadge } from "@shared/components/StatusBadge";
+
+const meta: Meta = {
+ title: "Primitives/ListRow",
+ component: ListRow,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+function Dot({ ch }: { ch: string }) {
+ return {ch} ;
+}
+
+export const Default: Story = {
+ args: {
+ leading: ,
+ leadingTone: "success",
+ title: "MSA_Acme_2026.pdf",
+ description: "Classified as Contract • 3 tables extracted",
+ meta: "2h ago",
+ },
+};
+
+/** A divided list inside a padding-none Card — the canonical usage. */
+export const InCard: Story = {
+ render: () => (
+
+ }
+ leadingTone="success"
+ title="MSA_Acme_2026.pdf"
+ description="Classified as Contract • 3 tables extracted"
+ meta="2h ago"
+ />
+ }
+ leadingTone="warning"
+ title="scan_002.pdf"
+ description="Low confidence (62%) • flagged for review"
+ meta="Yesterday"
+ trailing={
+
+ Flagged
+
+ }
+ />
+ }
+ leadingTone="success"
+ title="Invoice_4471.pdf"
+ description="Classified as Invoice • renamed to standard"
+ meta="5h ago"
+ />
+
+ ),
+};
+
+export const Interactive: Story = {
+ args: {
+ leading: ,
+ leadingTone: "info",
+ title: "Clickable row",
+ description: "The whole row is a button",
+ onClick: () => {},
+ },
+};
diff --git a/frontend/shared/components/ListRow.tsx b/frontend/shared/components/ListRow.tsx
new file mode 100644
index 000000000..098df7850
--- /dev/null
+++ b/frontend/shared/components/ListRow.tsx
@@ -0,0 +1,79 @@
+import type { ReactNode } from "react";
+import "@shared/components/ListRow.css";
+import type { StatusTone } from "@shared/components/StatusBadge";
+
+export interface ListRowProps {
+ /** Leading visual (icon/avatar), shown in a tone-tinted square. */
+ leading?: ReactNode;
+ /** Tint for the leading square. Defaults to neutral. */
+ leadingTone?: StatusTone;
+ /** Primary line. */
+ title: ReactNode;
+ /** Secondary line. */
+ description?: ReactNode;
+ /** Tertiary line (e.g. timestamp). */
+ meta?: ReactNode;
+ /** Right-aligned content (badge, chevron, action). */
+ trailing?: ReactNode;
+ /** Makes the whole row a button. */
+ onClick?: () => void;
+ /** Draw a top hairline — set on every row after the first inside a list. */
+ divider?: boolean;
+ className?: string;
+}
+
+/**
+ * A single list row: a tone-tinted leading glyph + a title/description/meta
+ * stack + an optional trailing slot. Compose a divided list by wrapping rows in
+ * a {@code Card padding="none"} and setting {@code divider} on all but the first.
+ */
+export function ListRow({
+ leading,
+ leadingTone = "neutral",
+ title,
+ description,
+ meta,
+ trailing,
+ onClick,
+ divider,
+ className,
+}: ListRowProps) {
+ const classes = [
+ "sui-listrow",
+ divider ? "sui-listrow--divider" : "",
+ onClick ? "sui-listrow--interactive" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ const content = (
+ <>
+ {leading && (
+
+ {leading}
+
+ )}
+
+ {title}
+ {description && (
+ {description}
+ )}
+ {meta && {meta} }
+
+ {trailing && {trailing} }
+ >
+ );
+
+ return onClick ? (
+
+ {content}
+
+ ) : (
+ {content}
+ );
+}
diff --git a/frontend/shared/components/MetricCard.css b/frontend/shared/components/MetricCard.css
index 03cc4c154..59c2b4500 100644
--- a/frontend/shared/components/MetricCard.css
+++ b/frontend/shared/components/MetricCard.css
@@ -17,6 +17,18 @@
color: inherit;
text-align: left;
}
+/* Compact density for narrow rails / metric strips. */
+.sui-metric--sm {
+ gap: var(--space-1);
+ padding: var(--space-2) var(--space-3);
+ min-width: 0;
+}
+.sui-metric--sm .sui-metric__value {
+ font-size: 1.125rem;
+}
+.sui-metric--sm .sui-metric__label {
+ white-space: nowrap;
+}
.sui-metric--primary {
background: var(--color-bg-subtle);
}
diff --git a/frontend/shared/components/MetricCard.tsx b/frontend/shared/components/MetricCard.tsx
index 9771a9821..1d357932f 100644
--- a/frontend/shared/components/MetricCard.tsx
+++ b/frontend/shared/components/MetricCard.tsx
@@ -14,6 +14,8 @@ export interface MetricCardProps {
deltaDirection?: DeltaDirection;
/** Visual emphasis. `primary` = darker surface, used for hero metrics. */
emphasis?: "default" | "primary";
+ /** Density. `sm` tightens padding + value size for narrow rails/strips. */
+ size?: "sm" | "md";
/** Optional icon shown in the top-right corner. */
icon?: ReactNode;
onClick?: () => void;
@@ -41,6 +43,7 @@ export function MetricCard({
delta,
deltaDirection,
emphasis = "default",
+ size = "md",
icon,
onClick,
className,
@@ -50,6 +53,7 @@ export function MetricCard({
const classes = [
"sui-metric",
+ `sui-metric--${size}`,
emphasis === "primary" ? "sui-metric--primary" : "",
interactive ? "sui-metric--interactive" : "",
className ?? "",
diff --git a/frontend/shared/components/NavItem.css b/frontend/shared/components/NavItem.css
index d70c11378..982abbc26 100644
--- a/frontend/shared/components/NavItem.css
+++ b/frontend/shared/components/NavItem.css
@@ -40,8 +40,53 @@
.sui-navitem__trailing {
display: inline-flex;
align-items: center;
+ margin-left: auto;
}
.sui-navitem:focus-visible {
outline: 0.125rem solid var(--color-blue);
outline-offset: 0.125rem;
}
+
+/* ---- Status accent (optional) ---- */
+.sui-navitem--accent {
+ position: relative;
+}
+.sui-navitem--accent::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0.3125rem;
+ bottom: 0.3125rem;
+ width: 3px;
+ border-radius: 3px;
+}
+.sui-navitem[data-accent="blue"]::before {
+ background: var(--color-blue);
+}
+.sui-navitem[data-accent="purple"]::before {
+ background: var(--color-purple);
+}
+.sui-navitem[data-accent="green"]::before {
+ background: var(--color-green);
+}
+.sui-navitem[data-accent="amber"]::before {
+ background: var(--color-amber);
+}
+.sui-navitem[data-accent="red"]::before {
+ background: var(--color-red);
+}
+.sui-navitem[data-accent="blue"] .sui-navitem__icon {
+ color: var(--color-blue);
+}
+.sui-navitem[data-accent="purple"] .sui-navitem__icon {
+ color: var(--color-purple);
+}
+.sui-navitem[data-accent="green"] .sui-navitem__icon {
+ color: var(--color-green);
+}
+.sui-navitem[data-accent="amber"] .sui-navitem__icon {
+ color: var(--color-amber);
+}
+.sui-navitem[data-accent="red"] .sui-navitem__icon {
+ color: var(--color-red);
+}
diff --git a/frontend/shared/components/NavItem.tsx b/frontend/shared/components/NavItem.tsx
index fd328bc0f..05826e4b6 100644
--- a/frontend/shared/components/NavItem.tsx
+++ b/frontend/shared/components/NavItem.tsx
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import "@shared/components/NavItem.css";
+export type NavItemAccent = "blue" | "purple" | "green" | "amber" | "red";
+
export interface NavItemProps {
/** Stable view id passed to the click handler. */
id: string;
@@ -8,6 +10,11 @@ export interface NavItemProps {
icon?: ReactNode;
/** Show the active highlight (navActive background, navActiveText colour). */
isActive?: boolean;
+ /**
+ * Optional status accent: draws a left edge bar in the tone colour and tints
+ * the leading icon to match. For listing live/paused/etc. entities.
+ */
+ accent?: NavItemAccent;
/** Optional trailing badge (e.g. unread count, "new"). */
trailing?: ReactNode;
onClick?: (id: string) => void;
@@ -19,13 +26,14 @@ export interface NavItemProps {
*
* Active styling: navActive background, navActiveText colour, weight 500.
* Hover styling: navHover background, navHoverText colour (only when not
- * already active).
+ * already active). An optional `accent` adds a status edge bar + icon tint.
*/
export function NavItem({
id,
label,
icon,
isActive,
+ accent,
trailing,
onClick,
className,
@@ -34,9 +42,15 @@ export function NavItem({
onClick?.(id)}
- className={["sui-navitem", isActive ? "is-active" : "", className ?? ""]
+ className={[
+ "sui-navitem",
+ isActive ? "is-active" : "",
+ accent ? "sui-navitem--accent" : "",
+ className ?? "",
+ ]
.filter(Boolean)
.join(" ")}
+ data-accent={accent}
aria-current={isActive ? "page" : undefined}
>
{icon && (
diff --git a/frontend/shared/components/PanelHeader.css b/frontend/shared/components/PanelHeader.css
index df537d9b8..dabffb27d 100644
--- a/frontend/shared/components/PanelHeader.css
+++ b/frontend/shared/components/PanelHeader.css
@@ -16,16 +16,22 @@
display: inline-flex;
align-items: center;
justify-content: center;
- width: 2rem;
- height: 2rem;
- border-radius: var(--radius-md);
- color: var(--color-text-3);
+ flex-shrink: 0;
+ width: 1.875rem;
+ height: 1.875rem;
+ border: 1px solid var(--color-border);
+ border-radius: 50%;
+ background: var(--color-surface);
+ color: var(--color-text-2);
+ cursor: pointer;
transition:
background var(--motion-fast),
- color var(--motion-fast);
+ color var(--motion-fast),
+ border-color var(--motion-fast);
}
.sui-panelhdr__back:hover {
background: var(--color-bg-hover);
+ border-color: var(--color-text-4);
color: var(--color-text-1);
}
.sui-panelhdr__text {
diff --git a/frontend/shared/components/PanelHeader.tsx b/frontend/shared/components/PanelHeader.tsx
index 031edbf1d..e65b21f37 100644
--- a/frontend/shared/components/PanelHeader.tsx
+++ b/frontend/shared/components/PanelHeader.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import "@shared/components/PanelHeader.css";
+import { IconBadge } from "@shared/components/IconBadge";
export interface PanelHeaderProps {
title: ReactNode;
@@ -7,6 +8,10 @@ export interface PanelHeaderProps {
subtitle?: ReactNode;
/** Show a back chevron and trigger this callback when clicked. */
onBack?: () => void;
+ /** Optional leading visual (e.g. a category glyph) shown in a tinted box. */
+ icon?: ReactNode;
+ /** Accent tint for the leading icon box. Defaults to blue. */
+ iconAccent?: "blue" | "purple" | "green" | "amber" | "red";
/** Right-aligned action buttons / chips. */
actions?: ReactNode;
className?: string;
@@ -14,12 +19,15 @@ export interface PanelHeaderProps {
/**
* Header strip used by drill-down panels (admin tabs, agent detail, settings
- * sub-pages). Back chevron renders only when `onBack` is supplied.
+ * sub-pages). Back chevron renders only when `onBack` is supplied; an optional
+ * leading `icon` renders in a tinted box before the title.
*/
export function PanelHeader({
title,
subtitle,
onBack,
+ icon,
+ iconAccent = "blue",
actions,
className,
}: PanelHeaderProps) {
@@ -49,6 +57,11 @@ export function PanelHeader({
)}
+ {icon && (
+
+ {icon}
+
+ )}
{title}
{subtitle &&
{subtitle}
}
diff --git a/frontend/shared/components/SectionHeader.css b/frontend/shared/components/SectionHeader.css
new file mode 100644
index 000000000..63a354cc2
--- /dev/null
+++ b/frontend/shared/components/SectionHeader.css
@@ -0,0 +1,33 @@
+.sui-sectionhdr {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ width: 100%;
+ padding: 0.4rem 0;
+ background: transparent;
+ border: none;
+ text-align: left;
+}
+button.sui-sectionhdr {
+ cursor: pointer;
+}
+.sui-sectionhdr__title {
+ flex: 1;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-4);
+}
+.sui-sectionhdr__count {
+ font-size: 0.6875rem;
+ color: var(--color-text-4);
+}
+.sui-sectionhdr__chevron {
+ color: var(--color-text-4);
+ flex-shrink: 0;
+ transition: transform var(--motion-fast);
+}
+.sui-sectionhdr__chevron[data-collapsed="true"] {
+ transform: rotate(-90deg);
+}
diff --git a/frontend/shared/components/SectionHeader.stories.tsx b/frontend/shared/components/SectionHeader.stories.tsx
new file mode 100644
index 000000000..d3adffd1c
--- /dev/null
+++ b/frontend/shared/components/SectionHeader.stories.tsx
@@ -0,0 +1,36 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { SectionHeader } from "@shared/components/SectionHeader";
+
+const meta: Meta
= {
+ title: "Primitives/SectionHeader",
+ component: SectionHeader,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Static: Story = { args: { title: "Policies", count: "3 active" } };
+
+export const Collapsible: Story = {
+ render: () => {
+ const [open, setOpen] = useState(true);
+ return (
+ setOpen((v) => !v)}
+ />
+ );
+ },
+};
diff --git a/frontend/shared/components/SectionHeader.tsx b/frontend/shared/components/SectionHeader.tsx
new file mode 100644
index 000000000..257873093
--- /dev/null
+++ b/frontend/shared/components/SectionHeader.tsx
@@ -0,0 +1,66 @@
+import type { ReactNode } from "react";
+import "@shared/components/SectionHeader.css";
+
+export interface SectionHeaderProps {
+ /** Uppercase eyebrow title. */
+ title: ReactNode;
+ /** Optional right-aligned tally/count. */
+ count?: ReactNode;
+ /** Render as a button with a disclosure chevron. */
+ collapsible?: boolean;
+ /** When collapsible, whether the section is expanded. */
+ expanded?: boolean;
+ onToggle?: () => void;
+ className?: string;
+}
+
+/**
+ * An uppercase section eyebrow with an optional trailing count and, when
+ * `collapsible`, a disclosure chevron + toggle. Use above a group of rows/cards.
+ */
+export function SectionHeader({
+ title,
+ count,
+ collapsible,
+ expanded = true,
+ onToggle,
+ className,
+}: SectionHeaderProps) {
+ const classes = ["sui-sectionhdr", className ?? ""].filter(Boolean).join(" ");
+ const inner = (
+ <>
+ {title}
+ {count != null && {count} }
+ {collapsible && (
+
+
+
+ )}
+ >
+ );
+
+ return collapsible ? (
+
+ {inner}
+
+ ) : (
+ {inner}
+ );
+}
diff --git a/frontend/shared/components/Select.css b/frontend/shared/components/Select.css
index f97bf0f58..b01cf2ecd 100644
--- a/frontend/shared/components/Select.css
+++ b/frontend/shared/components/Select.css
@@ -44,6 +44,25 @@
-webkit-appearance: none;
}
+/*
+ * The native option popup is OS-rendered: without explicit colours it falls
+ * back to the UA scheme (white) while the option text inherits the themed
+ * (light) colour — i.e. white-on-white in dark mode wherever the surrounding
+ * app keeps color-scheme: light (e.g. the editor, whose dark mode is driven by
+ * Mantine, not color-scheme). Theme the options explicitly and pin the
+ * select's color-scheme to the active SUI theme so the popup is always legible.
+ */
+.sui-select__el option {
+ background-color: var(--color-surface);
+ color: var(--color-text-1);
+}
+[data-theme="dark"] .sui-select__el {
+ color-scheme: dark;
+}
+[data-theme="light"] .sui-select__el {
+ color-scheme: light;
+}
+
.sui-select--sm .sui-select__el {
font-size: 0.8125rem;
padding-left: var(--space-2);
diff --git a/frontend/shared/components/SettingsRow.css b/frontend/shared/components/SettingsRow.css
new file mode 100644
index 000000000..f374a5fda
--- /dev/null
+++ b/frontend/shared/components/SettingsRow.css
@@ -0,0 +1,30 @@
+.sui-settingsrow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ width: 100%;
+}
+.sui-settingsrow__text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+.sui-settingsrow__label {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-1);
+}
+.sui-settingsrow__desc {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ margin-top: 0.125rem;
+}
+.sui-settingsrow__control {
+ flex: 0 0 auto;
+ min-width: 0;
+ max-width: 11rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+}
diff --git a/frontend/shared/components/SettingsRow.stories.tsx b/frontend/shared/components/SettingsRow.stories.tsx
new file mode 100644
index 000000000..964a92a1e
--- /dev/null
+++ b/frontend/shared/components/SettingsRow.stories.tsx
@@ -0,0 +1,82 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { SettingsRow } from "@shared/components/SettingsRow";
+import { ToggleSwitch } from "@shared/components/ToggleSwitch";
+import { Select } from "@shared/components/Select";
+import { Card } from "@shared/components/Card";
+
+const meta: Meta = {
+ title: "Primitives/SettingsRow",
+ component: SettingsRow,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Toggle: Story = {
+ args: {
+ label: "Auto-classify",
+ control: {}} size="sm" />,
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ label: "Detect PII",
+ description: "Scan documents for sensitive fields on save",
+ control: {}} size="sm" />,
+ },
+};
+
+/** A settings list inside a padding-none Card. */
+export const List: Story = {
+ render: () => (
+
+ {[
+ { label: "Auto-classify", on: true },
+ { label: "Extract tables", on: true },
+ { label: "Strip blank pages", on: false },
+ ].map((r, i) => (
+ 0 ? "1px solid var(--color-border)" : undefined,
+ }}
+ >
+ {}} size="sm" />
+ }
+ />
+
+ ))}
+
+ ),
+};
+
+export const SelectControl: Story = {
+ args: {
+ label: "OCR level",
+ control: (
+ {}}
+ options={[
+ { value: "standard", label: "Standard" },
+ { value: "high", label: "High" },
+ { value: "max", label: "Maximum" },
+ ]}
+ />
+ ),
+ },
+};
diff --git a/frontend/shared/components/SettingsRow.tsx b/frontend/shared/components/SettingsRow.tsx
new file mode 100644
index 000000000..00bfe8da9
--- /dev/null
+++ b/frontend/shared/components/SettingsRow.tsx
@@ -0,0 +1,39 @@
+import type { ReactNode } from "react";
+import "@shared/components/SettingsRow.css";
+
+export interface SettingsRowProps {
+ /** Left-aligned setting name. */
+ label: ReactNode;
+ /** Optional secondary line under the label. */
+ description?: ReactNode;
+ /** Right-aligned control (toggle / select / input / button). */
+ control: ReactNode;
+ className?: string;
+}
+
+/**
+ * Horizontal settings row: label (+ optional description) on the left, control
+ * right-aligned. The companion to vertical {@link FormField} — use this for the
+ * label-left/control-right settings pattern (the layout ToggleSwitch's docs
+ * point at). Stack rows inside a {@code Card padding="none"} for a settings list.
+ */
+export function SettingsRow({
+ label,
+ description,
+ control,
+ className,
+}: SettingsRowProps) {
+ return (
+
+
+ {label}
+ {description && (
+ {description}
+ )}
+
+
{control}
+
+ );
+}
diff --git a/frontend/shared/components/StepIndicator.css b/frontend/shared/components/StepIndicator.css
new file mode 100644
index 000000000..d5d46e7bd
--- /dev/null
+++ b/frontend/shared/components/StepIndicator.css
@@ -0,0 +1,27 @@
+.sui-steps {
+ display: flex;
+ gap: 0.3rem;
+ width: 100%;
+}
+.sui-steps__bar {
+ flex: 1;
+ border-radius: 999px;
+ background: var(--color-border);
+ transition: background var(--motion-fast);
+}
+.sui-steps--md .sui-steps__bar {
+ height: 0.3rem;
+}
+.sui-steps--sm .sui-steps__bar {
+ height: 0.2rem;
+}
+/* Completed steps: solid accent. */
+.sui-steps__bar[data-state="done"] {
+ background: var(--color-blue);
+}
+/* Current step: solid accent + a soft ring so it reads as "you are here". */
+.sui-steps__bar[data-state="current"] {
+ background: var(--color-blue);
+ box-shadow: 0 0 0 0.1875rem
+ color-mix(in srgb, var(--color-blue) 22%, transparent);
+}
diff --git a/frontend/shared/components/StepIndicator.stories.tsx b/frontend/shared/components/StepIndicator.stories.tsx
new file mode 100644
index 000000000..7d75798f8
--- /dev/null
+++ b/frontend/shared/components/StepIndicator.stories.tsx
@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { StepIndicator } from "@shared/components/StepIndicator";
+
+const meta: Meta = {
+ title: "Primitives/StepIndicator",
+ component: StepIndicator,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: { total: 3, current: 2, size: "md" },
+ argTypes: {
+ current: { control: { type: "number", min: 1, max: 5 } },
+ total: { control: { type: "number", min: 1, max: 5 } },
+ size: { control: "inline-radio", options: ["sm", "md"] },
+ },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Step1: Story = { args: { total: 3, current: 1 } };
+export const Step2: Story = { args: { total: 3, current: 2 } };
+export const Step3: Story = { args: { total: 3, current: 3 } };
+export const Small: Story = { args: { total: 4, current: 2, size: "sm" } };
diff --git a/frontend/shared/components/StepIndicator.tsx b/frontend/shared/components/StepIndicator.tsx
new file mode 100644
index 000000000..700c92367
--- /dev/null
+++ b/frontend/shared/components/StepIndicator.tsx
@@ -0,0 +1,43 @@
+import "@shared/components/StepIndicator.css";
+
+export interface StepIndicatorProps {
+ /** Total number of steps. */
+ total: number;
+ /** Current step, 1-based. */
+ current: number;
+ size?: "sm" | "md";
+ className?: string;
+}
+
+/**
+ * A segmented step/progress rail for multi-step flows (wizards, onboarding).
+ * Segments before `current` read as completed, the `current` segment is
+ * emphasised, and the rest are upcoming.
+ */
+export function StepIndicator({
+ total,
+ current,
+ size = "md",
+ className,
+}: StepIndicatorProps) {
+ return (
+
+ {Array.from({ length: total }, (_, i) => {
+ const step = i + 1;
+ const state =
+ step < current ? "done" : step === current ? "current" : "upcoming";
+ return (
+
+ );
+ })}
+
+ );
+}