diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json
index 97b5e46b81..6fad765c1b 100644
--- a/packages/apollo-react/package.json
+++ b/packages/apollo-react/package.json
@@ -123,6 +123,11 @@
"import": "./dist/canvas/constants.js",
"require": "./dist/canvas/constants.cjs"
},
+ "./canvas/guardrails": {
+ "types": "./dist/canvas/components/Guardrails/index.d.ts",
+ "import": "./dist/canvas/components/Guardrails/index.js",
+ "require": "./dist/canvas/components/Guardrails/index.cjs"
+ },
"./canvas/xyflow/*.css": "./dist/canvas/xyflow/*.css",
"./canvas/styles/*.css": "./dist/canvas/styles/*.css"
},
@@ -211,6 +216,7 @@
"@uipath/apollo-wind": "workspace:*",
"@xyflow/react": "12.8.2",
"@xyflow/system": "0.0.66",
+ "class-variance-authority": "^0.7.1",
"d3-hierarchy": "^3.1.2",
"d3-sankey": "^0.12.3",
"d3-scale": "^4.0.2",
@@ -267,6 +273,7 @@
"@types/d3-scale-chromatic": "^3.0.3",
"@types/d3-selection": "^3.0.11",
"@types/d3-zoom": "^3.0.8",
+ "@types/jest-axe": "^3.5.9",
"@types/lodash": "^4.17.21",
"@types/luxon": "^3.7.1",
"@types/mdast": "^4.0.4",
@@ -284,6 +291,7 @@
"esbuild": "^0.28.1",
"glob": "^13.0.0",
"happy-dom": "^20.0.0",
+ "jest-axe": "^10.0.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"typescript": "^5.9.3",
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/README.md b/packages/apollo-react/src/canvas/components/Guardrails/README.md
new file mode 100644
index 0000000000..5548e02b47
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/README.md
@@ -0,0 +1,241 @@
+# Guardrails components
+
+Shared UI for the UiPath Guardrails experience, consumed by Flow (flow-workbench) and, in a
+later stage, Agents (`frontend-sw`). Lives in apollo-react next to canvas — MUI-free, built
+entirely on `@uipath/apollo-wind` primitives and its `forms/` engine, strings on lingui —
+and is exported through the narrow `@uipath/apollo-react/canvas/guardrails` subpath (also
+re-exported from `./canvas`). Members: `GuardrailBuilder` (the whole Add/Edit screen),
+`GuardrailFormLayout` (the screen shell), and `GuardrailValidatorForm` (the validator
+parameter section, also rendered inside the builder).
+
+## GuardrailBuilder
+
+The complete Add/Edit screen for an OOTB guardrail validator: status banners, usage note,
+type display (edit mode), name, description, validator parameters, scope selector, action
+(log / block / escalate; filter reserved for the custom-guardrail phase), evaluations
+toggle, mixed-scopes banner, and the Save / Cancel / Save-as-new footer.
+
+```tsx
+import { GuardrailBuilder } from '@uipath/apollo-react/canvas/guardrails';
+
+;
+```
+
+Contract highlights:
+
+- **Owns form state.** Initialized from `definition`/`guardrail`/`defaultName` at mount —
+ remount with a new `key` to reset (all known hosts already remount per session).
+- **Owns validation and gates its own Save.** Messages localize through lingui
+ (overridable per string via `labels`); a host that validates externally (e.g. zod) passes
+ `errors` — host messages display immediately, win per field, and gate Save. The pure
+ predicates (`getGuardrailActionErrorFields`, `getGuardrailSelectorErrorFields`,
+ `getRequiredEmptyParameterIds`) are exported.
+- **Escalation is slot-driven.** `renderRecipientSearch` (user/group directory autosuggest)
+ and `renderAppPicker` (escalation app) are host capabilities; `escalateHelp` renders under
+ the escalation grid (e.g. a marketplace link — product URLs never ship in this package).
+ Without slots the form falls back to a plain input / an "unavailable" note.
+- **Layout knobs for both hosts**: `inline`/`hideHeader`/`dialogMaxWidth`, `title` accepts a
+ ReactNode (chips, links), `evalsTogglePlacement: 'form' | 'footer'`.
+- Requires an ancestor `TooltipProvider`.
+
+`GuardrailFormLayout` is exported standalone for hosts composing their own screen: three
+modes (inline+hideHeader / inline with back-button header / modal Dialog), `secondaryAction`,
+`saveDisabled`, and a `footerStart` region.
+
+## GuardrailValidatorForm
+
+Renders one editor per `GuardrailParameterDefinition`, covering the seven wire parameter
+types:
+
+| type | editor |
+| --- | --- |
+| `number` | numeric input with `min`/`max`/`step` |
+| `text` | multiline textarea (`maxLength`) |
+| `boolean` | switch |
+| `enum` | single select (a stored value missing from `options` is kept as a synthetic option) |
+| `enum-list` | toggleable chips inline for ≤8 options, otherwise the wind `MultiSelect` |
+| `text-list` | repeated textarea rows with Add/Remove (`maxItems`, `maxLength`) |
+| `map-enum` | one numeric input per key selected in the sibling `keySource` enum-list |
+
+```tsx
+import {
+ GuardrailValidatorForm,
+ getRequiredEmptyParameterIds,
+ seedGuardrailParameters,
+} from '@uipath/apollo-react/canvas/guardrails';
+
+const [parameters, setParameters] = useState(() =>
+ seedGuardrailParameters(definition.parameters, existingGuardrail?.validatorParameters)
+);
+
+ — host-owned validation
+ onClearError={clearParamError}
+/>;
+```
+
+Requires an ancestor `TooltipProvider` (for the per-parameter info tooltips).
+
+The controlled contract above is this family's, not `MetadataForm`'s: the form owns its own
+state and exposes a plugin seam, so the translation lives in one named place,
+`useMetadataFormBridge`. Nothing else in the family reaches into `context.form`.
+
+### Contract
+
+- **Fully controlled values; validation is shared.** The host owns values (`parameters` +
+ `onChange`). Validation runs on both sides, and the split is deliberate:
+ - *The form* declares `required`/`min`/`max` from the parameter definitions and its resolver
+ evaluates them, with messages from the label catalog so they translate. A field can
+ therefore show an error with no `errors` entry at all. **When** it reports is `validateLive`:
+ on by default, since a host mounting `GuardrailValidatorForm` standalone has no save of its
+ own and nothing else would validate. `GuardrailBuilder` passes its own post-save-attempt
+ flag instead, so inside the dialog parameters stay quiet until the first failed Save and
+ then go live — the same gate the name, scope and action fields have always used.
+ - *The host* owns anything the form cannot know — domain rules, and the save-time gate.
+ Compute required-field errors with `getRequiredEmptyParameterIds(definitions, parameters)`
+ and out-of-range values with `getOutOfRangeParameterIds(definitions, parameters)`, gate
+ Save on both, and map the returned ids to your own localized messages. The component
+ renders `errors[id]` under the matching editor and calls `onClearError(id)` before
+ `onChange` when that parameter is edited.
+
+ **The host's verdict wins where they disagree** — a `text-list` of whitespace-only rows
+ passes the array's `.min(1)` but counts as empty for `getRequiredEmptyParameterIds`. That
+ precedence is pinned by a test rather than left to whichever ran last. Gate Save on the host
+ predicates: they are authoritative, and the resolver is there for live feedback while typing.
+ They are also what fills the dialog on a failed Save, since the resolver is still held back at
+ that instant — so keep computing them even though the resolver covers `required`/`min`/`max`.
+- **Definitions arrive pre-resolved.** `label`, `tooltip` and `optionLabels` are display
+ strings the host already localized; domain copy (PII entity names, validator descriptions)
+ never ships in this package.
+- **No product types cross the boundary.** `GuardrailValidatorParameter` structurally mirrors
+ the wire shape both products persist, so host unions assign cleanly in both directions.
+- **Per-parameter override.** `renderParameter(ctx)` replaces the editor for any parameter
+ (return `undefined` to fall through). `ctx.onValueChange` upserts the parameter;
+ `ctx.onParametersChange` replaces the whole array for overrides that persist sidecar
+ parameters (e.g. a model picker storing connection metadata).
+
+### Save-time companions
+
+The editors never prune or drop values while typing; reconcile at save time:
+
+```ts
+import { dropEmptyOptionalParameters, syncMapEnumParameters } from '@uipath/apollo-react/canvas/guardrails';
+
+const cleaned = dropEmptyOptionalParameters(
+ syncMapEnumParameters(parameters, definition.parameters),
+ definition.parameters
+);
+```
+
+- `syncMapEnumParameters` rebuilds every `map-enum` value so its keys exactly match the
+ current `keySource` selection (preserving user edits, then per-key defaults, then `min`).
+ It mirrors the map-enum editor's key resolution — keeping the two in one package is the
+ point: they must never drift.
+- `dropEmptyOptionalParameters` removes optional parameters left `''`/`[]`, which runtimes
+ reject at publish time.
+- `seedGuardrailParameters` builds the initial value array from definitions (editing passes
+ the stored values through verbatim), coercing `null` defaults to the union's value types.
+
+### Localization
+
+The component's own chrome strings (placeholders, Add, aria labels) localize through the
+package's standard lingui setup: `useSafeLingui` with explicit `guardrails.*` ids and English
+defaults, translations in the shared canvas catalog (`src/canvas/locales/*.json`, 13 locales
+translated; `ru` falls back to English per key). Without a lingui provider the components
+render the English defaults — mount `ApI18nProvider component="canvas"` (from
+`@uipath/apollo-react/i18n`) for translations. `labels` overrides individual strings and wins
+over the catalog. The resolver's own messages (`requiredError`, `minError`, `maxError`) are
+part of that catalog: the schema declares those constraints, so the messages ship with the
+component rather than arriving through `errors`. Domain messages still belong to hosts and
+come in via `errors`.
+
+Localized template strings that cross into plain-string APIs (dialog titles, the text-list
+remove label consumed by wind's `formatTemplate`) are ICU messages formatted with sentinel
+values, so they come back carrying the `{{token}}` convention — see `TEMPLATE_TOKENS` in
+`i18n.ts`.
+
+### Consuming from a shadow-DOM host (Agents stage 2)
+
+Radix overlays (the enum select, the enum-list popover, tooltips) portal to `document.body`
+by default and escape shadow roots; wrap the form's subtree with `PortalContainerProvider`
+and inject the compiled canvas stylesheet
+(`@uipath/apollo-react/canvas/styles/tailwind.canvas.css?inline` — its Tailwind build scans
+this directory) into the shadow root (see `AgentCanvasEditor` in `frontend-sw` for the
+`?inline` injection precedent). `@uipath/apollo-wind` must resolve to a single copy alongside
+apollo-react's own pin, or Radix contexts and CSS duplicate.
+
+## Built on the forms/ MetadataForm stack
+
+`GuardrailValidatorForm` is not a form renderer of its own: internally it is
+`buildGuardrailFormSchema(definitions, labels)` + the package's `MetadataForm`
+(`components/forms/`: `FormSchema` → `MetadataForm` → `field-renderer`), mounted with
+`container="div"`. The public contract above is the adapter boundary — hosts never see the
+schema.
+
+`MetadataForm` owns its own state; it has no controlled-host props. An earlier revision of
+#1107 added some (`values`, `onValuesChange`, `errors`, `disableValidation`) and they were
+removed in review, because they existed to route around features the schema contract already
+declared. The translation from this family's controlled contract onto the primitive therefore
+lives in one named place, `useMetadataFormBridge`, which is a `FormPlugin` that:
+
+- registers the guardrail-owned custom components from the first paint (`FormPlugin.components`);
+- pushes host `parameters` in with `context.form.setValue`, structurally compared so an echo of
+ the form's own emission performs no write and focus/cursor survive;
+- pushes host `errors` in as `type: 'external'`, cleared only when the prop drops them;
+- reports user edits out through `onValueChange`, suppressed while the hook is itself writing.
+
+Validation is live rather than disabled: `buildGuardrailFormSchema` declares `required`/`min`/
+`max` with messages from the label catalog (so they translate), and the host's own predicates
+(`getRequiredEmptyParameterIds`, `getOutOfRangeParameterIds`) run alongside, reaching the form
+as external errors. Where the two disagree — a `text-list` of whitespace-only rows passes the
+array's `.min(1)` but counts as empty for the host — the host verdict is what the user sees;
+`guardrail-validator-form.test.tsx` pins that.
+
+How each parameter type maps:
+
+| parameter type | rendering |
+| --- | --- |
+| `number` | field type `number` |
+| `text` | field type `textarea` (`minRows`, `maxLength`) |
+| `boolean` | field type `switch` |
+| `enum` | field type `select` (synthetic option appended for a stale stored value) |
+| `enum-list` > 8 options | field type `multiselect` |
+| `enum-list` ≤ 8 options | custom component `guardrail-enum-list-chips` (`GuardrailChip` toggles in a `FieldShell`) |
+| `text-list` | field type `string-list` (added to forms/ for this convergence — generic) |
+| `map-enum` | custom component `guardrail-map-enum` (reads the `keySource` sibling via the form context) |
+| any id claimed by `renderParameter` | custom component `guardrail-render-parameter` (the bridge that mounts the host's node and exposes `onValueChange`/`onParametersChange`) |
+
+Why the three custom components stay guardrail-owned: the chip-toggle UX is a product
+decision (small option sets read better as chips than a dropdown), `map-enum` derives its
+rows from a sibling field's live selection, and `renderParameter` is a host seam — all three
+are exactly what `type: 'custom'` + component registration exists for.
+
+Adapter invariants (guarded by the `controlled contract` tests in
+`guardrail-validator-form.test.tsx`):
+
+- Emissions upsert only the edited parameter into the host's current array — untouched
+ defaults never leak in, and parameters without a matching definition (sidecars written via
+ `onParametersChange`, e.g. `byomConnectionId`) never enter the form and round-trip
+ untouched.
+- A synchronous host echo of the emitted array is a no-op (per-field deep-equal guard): no
+ re-emission, focus and cursor survive. Hosts must echo synchronously from `onChange`.
+- Values are coerced to the wire shape on emit (`coerceGuardrailParameterValue`): a cleared
+ number input persists `0`, never `NaN`; text/enum never persist `null`.
+
+**Rule for new work**: a new parameter editor extends `field-renderer` with a first-class
+field type (when it's generic) or registers a custom component here (when it's
+guardrail-shaped) — never a parallel renderer next to `MetadataForm`.
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts
new file mode 100644
index 0000000000..c233c08547
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts
@@ -0,0 +1,183 @@
+import type * as React from 'react';
+import type { GuardrailParameterDefinition, GuardrailValidatorParameter } from './types';
+
+/**
+ * Structural mirrors of the guardrail wire shapes both consuming products persist. Hosts
+ * pass their own equivalent types without mapping; mutual assignability is asserted on the
+ * host side (see the Flow adapter's type-assertion file).
+ */
+
+export type GuardrailScope = 'Agent' | 'Llm' | 'Tool';
+
+export interface GuardrailSelector {
+ scopes: GuardrailScope[];
+ matchNames?: string[];
+}
+
+export type GuardrailSeverityLevel = 'Info' | 'Warning' | 'Error';
+
+/** Recipient type discriminators (numeric on the wire). */
+export const GuardrailRecipientType = {
+ User: 1,
+ Group: 2,
+ StaticEmail: 3,
+ AssetEmail: 4,
+ StaticGroupName: 5,
+ AssetGroupName: 6,
+} as const;
+export type GuardrailRecipientTypeValue =
+ (typeof GuardrailRecipientType)[keyof typeof GuardrailRecipientType];
+
+/**
+ * Escalation recipient union. The editor offers User/Group/StaticEmail/StaticGroupName;
+ * the asset variants (4/6) exist so values using them round-trip through the form unedited.
+ */
+export type GuardrailEscalateRecipient =
+ | { type: 1; value: string; displayName: string }
+ | { type: 2; value: string; displayName: string }
+ | { type: 3; value: string }
+ | { type: 4; folderPath?: string; assetName: string }
+ | { type: 5; value: string }
+ | { type: 6; folderPath?: string; assetName: string };
+
+export interface GuardrailEscalateApp {
+ id: string;
+ version: string;
+ name: string;
+ folderId?: string;
+ folderName?: string;
+ appProcessKey?: string;
+ runtime?: string;
+}
+
+export type GuardrailAction =
+ | { $actionType: 'log'; severityLevel: GuardrailSeverityLevel }
+ | { $actionType: 'block'; reason: string }
+ // Field references are product-shaped; the OOTB builder never edits them (pass-through
+ // only, the filter option is custom-guardrail territory), so they stay opaque here.
+ | { $actionType: 'filter'; fields: unknown[] }
+ | { $actionType: 'escalate'; app: GuardrailEscalateApp; recipient: GuardrailEscalateRecipient };
+
+export type GuardrailDefinitionStatus =
+ | 'Available'
+ | 'FeatureDisabled'
+ | 'Unauthorised'
+ | 'Disabled';
+
+/**
+ * The display-ready definition of an OOTB guardrail validator. `displayName` and `usageNote`
+ * arrive pre-resolved (host-localized); `parameters` reuses the validator-form definition
+ * type.
+ */
+export interface GuardrailDefinition {
+ validator: string;
+ displayName: string;
+ allowedScopes: GuardrailScope[];
+ parameters: GuardrailParameterDefinition[];
+ status: GuardrailDefinitionStatus;
+ /** Pre-localized informational note rendered above the form. */
+ usageNote?: React.ReactNode;
+ /** Present for bring-your-own guardrail definitions; stamped onto saved values. */
+ byoValidatorName?: string;
+}
+
+/** `validatorType` persisted for bring-your-own guardrail definitions. */
+export const GUARDRAIL_BYO_VALIDATOR_TYPE = 'byo';
+
+/** The builder's in/out value — mirrors the persisted built-in-validator guardrail shape. */
+export interface GuardrailBuilderValue {
+ id: string;
+ $guardrailType: 'builtInValidator';
+ name: string;
+ description?: string;
+ selector: GuardrailSelector;
+ action: GuardrailAction;
+ enabledForEvals: boolean;
+ validatorType: string;
+ validatorParameters: GuardrailValidatorParameter[];
+ byoValidatorName?: string;
+}
+
+/**
+ * Host-supplied validation errors, merged over the builder's internal validation (the host
+ * message wins per field). Any present error gates Save.
+ */
+export interface GuardrailBuilderErrors {
+ name?: string;
+ blockReason?: string;
+ filterFields?: string;
+ recipient?: string;
+ actionApp?: string;
+ scopes?: string;
+ toolNames?: string;
+ /** Per-parameter messages keyed by parameter id. */
+ parameters?: Record;
+}
+
+/** Context handed to the `renderRecipientSearch` slot (user/group directory autosuggest). */
+export interface GuardrailRecipientSearchContext {
+ kind: 'user' | 'group';
+ /** Current display value (displayName, falling back to the raw value). */
+ displayValue: string;
+ /** Localized placeholder for the current kind. */
+ placeholder: string;
+ /** Whether the recipient currently fails validation (style the input accordingly). */
+ invalid: boolean;
+ /**
+ * The validation message, when there is one. A slot that renders it **owns** it — the form
+ * renders no message of its own for a claimed field, matching `renderAppPicker`. Ignore it and
+ * the user sees only the invalid styling.
+ */
+ error?: string;
+ onSelect: (selection: { value: string; displayName: string }) => void;
+ onClear: () => void;
+}
+
+/** Context handed to the `renderAppPicker` slot (escalation action app). */
+export interface GuardrailAppPickerContext {
+ /** The selected app, or null when unset. */
+ app: GuardrailEscalateApp | null;
+ onChange: (app: GuardrailEscalateApp | null) => void;
+ /** Localized field label. */
+ label: string;
+ /** Validation message to surface, if any. */
+ error?: string;
+}
+
+/**
+ * Context handed to the `renderStaticRecipient` slot (StaticEmail/AssetEmail/StaticGroupName/
+ * AssetGroupName recipients).
+ */
+export interface GuardrailStaticRecipientContext {
+ /** Which recipient family the type select currently shows. */
+ kind: 'email' | 'groupName';
+ /** The current recipient — the static or asset variant of the kind. */
+ recipient: GuardrailEscalateRecipient;
+ /** Localized field label for the kind. */
+ label: string;
+ /** Whether the recipient currently fails validation (style the control accordingly). */
+ invalid: boolean;
+ /** Validation message to surface, if any. */
+ error?: string;
+ /**
+ * Replace the recipient wholesale — lets hosts toggle between the static and asset
+ * variants of the same kind (StaticEmail 3 ↔ AssetEmail 4, StaticGroupName 5 ↔
+ * AssetGroupName 6).
+ */
+ onChange: (recipient: GuardrailEscalateRecipient) => void;
+}
+
+export interface GuardrailBuilderSlots {
+ /** Replace the recipient autosuggest for User/Group recipients. Fallback: a plain input. */
+ renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => React.ReactNode;
+ /**
+ * Replace the editor for static/asset recipients (types 3/4/5/6). Return `undefined` to
+ * fall through to the built-in plain input (which edits `value` for static recipients and
+ * `assetName` for asset ones).
+ */
+ renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => React.ReactNode | undefined;
+ /** Render the escalation app picker. Fallback: a localized "picker unavailable" note. */
+ renderAppPicker?: (ctx: GuardrailAppPickerContext) => React.ReactNode;
+ /** Rendered under the escalation grid (e.g. a marketplace help line). */
+ escalateHelp?: React.ReactNode;
+}
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.test.ts
new file mode 100644
index 0000000000..d6bf971a08
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.test.ts
@@ -0,0 +1,226 @@
+import { describe, expect, it } from 'vitest';
+import {
+ type GuardrailAction,
+ type GuardrailDefinition,
+ GuardrailRecipientType,
+} from './builder-types';
+import {
+ createDefaultGuardrailAction,
+ generateGuardrailId,
+ getGuardrailActionErrorFields,
+ getGuardrailSelectorErrorFields,
+ initGuardrailBuilderFormData,
+} from './builder-utils';
+
+function makeDef(overrides?: Partial): GuardrailDefinition {
+ return {
+ validator: 'pii_detection',
+ displayName: 'PII Detection',
+ allowedScopes: ['Agent', 'Llm', 'Tool'],
+ parameters: [],
+ status: 'Available',
+ ...overrides,
+ };
+}
+
+describe('generateGuardrailId', () => {
+ it('produces the guardrail-- format', () => {
+ expect(generateGuardrailId()).toMatch(/^guardrail-[a-z0-9]+-[a-z0-9]{5}$/);
+ });
+});
+
+describe('createDefaultGuardrailAction', () => {
+ it('creates the per-type default payloads', () => {
+ expect(createDefaultGuardrailAction('log')).toEqual({
+ $actionType: 'log',
+ severityLevel: 'Info',
+ });
+ expect(createDefaultGuardrailAction('block')).toEqual({ $actionType: 'block', reason: '' });
+ expect(createDefaultGuardrailAction('filter')).toEqual({ $actionType: 'filter', fields: [] });
+ expect(createDefaultGuardrailAction('escalate')).toEqual({
+ $actionType: 'escalate',
+ app: { id: '', version: '', name: '' },
+ recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' },
+ });
+ });
+});
+
+describe('getGuardrailActionErrorFields', () => {
+ it('flags an empty block reason', () => {
+ expect(getGuardrailActionErrorFields({ $actionType: 'block', reason: ' ' })).toEqual([
+ 'blockReason',
+ ]);
+ expect(getGuardrailActionErrorFields({ $actionType: 'block', reason: 'why' })).toEqual([]);
+ });
+
+ it('flags an empty filter field selection', () => {
+ expect(getGuardrailActionErrorFields({ $actionType: 'filter', fields: [] })).toEqual([
+ 'filterFields',
+ ]);
+ expect(getGuardrailActionErrorFields({ $actionType: 'filter', fields: [{}] })).toEqual([]);
+ });
+
+ it('flags missing escalate recipient and app', () => {
+ const empty: GuardrailAction = {
+ $actionType: 'escalate',
+ app: { id: '', version: '', name: '' },
+ recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' },
+ };
+ expect(getGuardrailActionErrorFields(empty)).toEqual(['recipient', 'actionApp']);
+
+ const filled: GuardrailAction = {
+ $actionType: 'escalate',
+ app: { id: 'app-1', version: '1', name: 'Escalation app' },
+ recipient: { type: GuardrailRecipientType.StaticEmail, value: 'a@b.c' },
+ };
+ expect(getGuardrailActionErrorFields(filled)).toEqual([]);
+ });
+
+ it('treats a filled asset recipient as valid (validated on assetName)', () => {
+ const assetRecipient: GuardrailAction = {
+ $actionType: 'escalate',
+ app: { id: 'app-1', version: '1', name: 'App' },
+ recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'asset' },
+ };
+ expect(getGuardrailActionErrorFields(assetRecipient)).toEqual([]);
+ });
+
+ it('never flags a log action', () => {
+ expect(getGuardrailActionErrorFields({ $actionType: 'log', severityLevel: 'Info' })).toEqual(
+ []
+ );
+ });
+});
+
+describe('getGuardrailSelectorErrorFields', () => {
+ it('requires at least one scope', () => {
+ expect(getGuardrailSelectorErrorFields({ scopes: [] })).toEqual(['scopes']);
+ });
+
+ it('requires tools when the Tool scope is selected', () => {
+ expect(getGuardrailSelectorErrorFields({ scopes: ['Tool'] })).toEqual(['toolNames']);
+ expect(getGuardrailSelectorErrorFields({ scopes: ['Tool'], matchNames: [] })).toEqual([
+ 'toolNames',
+ ]);
+ expect(getGuardrailSelectorErrorFields({ scopes: ['Tool'], matchNames: ['ToolA'] })).toEqual(
+ []
+ );
+ });
+
+ it('accepts non-Tool scopes without matchNames', () => {
+ expect(getGuardrailSelectorErrorFields({ scopes: ['Agent', 'Llm'] })).toEqual([]);
+ });
+});
+
+describe('getGuardrailActionErrorFields — asset recipients', () => {
+ it('treats a filled assetName as a valid recipient and an empty one as invalid', () => {
+ const base = {
+ $actionType: 'escalate' as const,
+ app: { id: 'app1', version: '1', name: 'App' },
+ };
+ expect(
+ getGuardrailActionErrorFields({
+ ...base,
+ recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'EmailAsset' },
+ })
+ ).toEqual([]);
+ expect(
+ getGuardrailActionErrorFields({
+ ...base,
+ recipient: { type: GuardrailRecipientType.AssetGroupName, assetName: ' ' },
+ })
+ ).toEqual(['recipient']);
+ });
+});
+
+describe('initGuardrailBuilderFormData', () => {
+ it('copies an existing guardrail verbatim', () => {
+ const existing = {
+ id: 'g1',
+ $guardrailType: 'builtInValidator' as const,
+ name: 'Mine',
+ description: 'desc',
+ selector: { scopes: ['Tool' as const], matchNames: ['OtherTool'] },
+ action: { $actionType: 'log' as const, severityLevel: 'Info' as const },
+ enabledForEvals: false,
+ validatorType: 'pii_detection',
+ validatorParameters: [],
+ };
+
+ const form = initGuardrailBuilderFormData(makeDef(), 'Tool', existing, 'MyTool');
+ expect(form.id).toBe('g1');
+ expect(form.selector.matchNames).toEqual(['OtherTool']);
+ expect(form.enabledForEvals).toBe(false);
+ });
+
+ it('sets matchNames when scope is Tool and toolName is provided', () => {
+ const form = initGuardrailBuilderFormData(makeDef(), 'Tool', undefined, 'MyTool');
+ expect(form.selector.scopes).toEqual(['Tool']);
+ expect(form.selector.matchNames).toEqual(['MyTool']);
+ });
+
+ it('does not set matchNames when scope is Tool but no toolName', () => {
+ const form = initGuardrailBuilderFormData(makeDef(), 'Tool');
+ expect(form.selector.scopes).toEqual(['Tool']);
+ expect(form.selector.matchNames).toBeUndefined();
+ });
+
+ it('falls back to the first allowedScope when the opening scope is not allowed (non-Agent too)', () => {
+ // The palette filters by allowedScopes before opening, but a disallowed opening scope
+ // must never seed a selector the user can't fix (the scope selector renders only for Agent).
+ const form = initGuardrailBuilderFormData(
+ makeDef({ allowedScopes: ['Agent', 'Llm'] }),
+ 'Tool',
+ undefined,
+ 'MyTool'
+ );
+ expect(form.selector.scopes).toEqual(['Agent']);
+ expect(form.selector.matchNames).toBeUndefined();
+ });
+
+ it('falls back to first allowedScope when Agent is not allowed', () => {
+ const form = initGuardrailBuilderFormData(
+ makeDef({ allowedScopes: ['Llm', 'Tool'] }),
+ 'Agent',
+ undefined,
+ 'MyTool'
+ );
+ expect(form.selector.scopes).toEqual(['Llm']);
+ expect(form.selector.matchNames).toBeUndefined();
+ });
+
+ it('sets matchNames when Agent scope falls back to Tool', () => {
+ const form = initGuardrailBuilderFormData(
+ makeDef({ allowedScopes: ['Tool'] }),
+ 'Agent',
+ undefined,
+ 'MyTool'
+ );
+ expect(form.selector.scopes).toEqual(['Tool']);
+ expect(form.selector.matchNames).toEqual(['MyTool']);
+ });
+
+ it('seeds defaults with null coercion for create mode', () => {
+ const definition = makeDef({
+ parameters: [
+ { id: 'prompt', type: 'text', label: 'Prompt', required: true, defaultValue: null },
+ {
+ id: 'examples',
+ type: 'text-list',
+ label: 'Examples',
+ required: false,
+ defaultValue: null,
+ },
+ ],
+ });
+ const form = initGuardrailBuilderFormData(definition, 'Agent');
+
+ expect(form.validatorParameters).toEqual([
+ { $parameterType: 'text', id: 'prompt', value: '' },
+ { $parameterType: 'text-list', id: 'examples', value: [] },
+ ]);
+ expect(form.action).toEqual({ $actionType: 'log', severityLevel: 'Info' });
+ expect(form.enabledForEvals).toBe(true);
+ expect(form.name).toBe('PII Detection');
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.ts
new file mode 100644
index 0000000000..8cc1547a61
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.ts
@@ -0,0 +1,146 @@
+import {
+ type GuardrailAction,
+ type GuardrailBuilderValue,
+ type GuardrailDefinition,
+ GuardrailRecipientType,
+ type GuardrailScope,
+ type GuardrailSelector,
+} from './builder-types';
+import { normalizeGuardrailParameters, seedGuardrailParameters } from './utils';
+
+export function generateGuardrailId(): string {
+ return `guardrail-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
+}
+
+/** Default action payload for a freshly selected action type. */
+export function createDefaultGuardrailAction(
+ type: GuardrailAction['$actionType']
+): GuardrailAction {
+ switch (type) {
+ case 'block':
+ return { $actionType: 'block', reason: '' };
+ case 'filter':
+ return { $actionType: 'filter', fields: [] };
+ case 'escalate':
+ return {
+ $actionType: 'escalate',
+ app: { id: '', version: '', name: '' },
+ recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' },
+ };
+ default:
+ return { $actionType: 'log', severityLevel: 'Info' };
+ }
+}
+
+export type GuardrailActionErrorField = 'blockReason' | 'filterFields' | 'recipient' | 'actionApp';
+
+/** Action fields whose current value fails required validation. Message text is caller-owned. */
+export function getGuardrailActionErrorFields(
+ action: GuardrailAction
+): GuardrailActionErrorField[] {
+ const fields: GuardrailActionErrorField[] = [];
+ switch (action.$actionType) {
+ case 'block':
+ if (!action.reason.trim()) fields.push('blockReason');
+ break;
+ case 'filter':
+ if (action.fields.length === 0) fields.push('filterFields');
+ break;
+ case 'escalate': {
+ const recipient = action.recipient;
+ const filled =
+ 'value' in recipient
+ ? recipient.value.trim().length > 0
+ : recipient.assetName.trim().length > 0;
+ if (!filled) fields.push('recipient');
+ if (!action.app.id) fields.push('actionApp');
+ break;
+ }
+ }
+ return fields;
+}
+
+export type GuardrailSelectorErrorField = 'scopes' | 'toolNames';
+
+/**
+ * Selector fields whose current value fails required validation. Unconditional — callers
+ * gate on whether the scope selector is shown at all.
+ */
+export function getGuardrailSelectorErrorFields(
+ selector: GuardrailSelector
+): GuardrailSelectorErrorField[] {
+ const fields: GuardrailSelectorErrorField[] = [];
+ if (selector.scopes.length === 0) fields.push('scopes');
+ if (
+ selector.scopes.includes('Tool') &&
+ (!selector.matchNames || selector.matchNames.length === 0)
+ ) {
+ fields.push('toolNames');
+ }
+ return fields;
+}
+
+export interface GuardrailBuilderFormData {
+ id: string;
+ name: string;
+ description: string;
+ selector: GuardrailSelector;
+ action: GuardrailAction;
+ enabledForEvals: boolean;
+ validatorParameters: GuardrailBuilderValue['validatorParameters'];
+}
+
+/**
+ * Initial form state: an existing guardrail is copied verbatim (edit); otherwise values are
+ * seeded from the definition — parameters via `seedGuardrailParameters`, the scope coerced to
+ * the definition's first allowed scope when `Agent` is not allowed, a log/Info default
+ * action, and evaluations enabled.
+ *
+ * @internal Exported for testing only
+ */
+export function initGuardrailBuilderFormData(
+ definition: GuardrailDefinition,
+ scope: GuardrailScope,
+ existing?: GuardrailBuilderValue,
+ toolName?: string
+): GuardrailBuilderFormData {
+ if (existing) {
+ return {
+ id: existing.id,
+ name: existing.name,
+ description: existing.description ?? '',
+ selector: existing.selector,
+ action: existing.action,
+ enabledForEvals: existing.enabledForEvals,
+ // Normalised, not copied verbatim: this is the only path that receives persisted wire
+ // data, and a malformed value (a `text-list` of `null`, say) otherwise reached the
+ // builder's `guardrailResult` memo and threw during render, so the builder never mounted.
+ // Doing it here rather than at the crash site also fixes `getRequiredEmptyParameterIds`,
+ // which counted a null list as filled and let it past the Save gate.
+ validatorParameters: normalizeGuardrailParameters(
+ existing.validatorParameters,
+ definition.parameters
+ ),
+ };
+ }
+
+ // Coerce for every opening scope, not just Agent: hosts filter definitions by allowedScopes
+ // before opening the builder, but a definition that doesn't allow the opening scope must not
+ // seed an invalid selector the user can't fix (the scope selector only renders for Agent).
+ const initialScope = definition.allowedScopes.includes(scope)
+ ? scope
+ : (definition.allowedScopes[0] ?? scope);
+
+ return {
+ id: generateGuardrailId(),
+ name: definition.displayName,
+ description: '',
+ selector: {
+ scopes: [initialScope],
+ ...(initialScope === 'Tool' && toolName ? { matchNames: [toolName] } : {}),
+ },
+ action: { $actionType: 'log', severityLevel: 'Info' },
+ enabledForEvals: true,
+ validatorParameters: seedGuardrailParameters(definition.parameters),
+ };
+}
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/enum-list-chips-field.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/enum-list-chips-field.tsx
new file mode 100644
index 0000000000..718339f629
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/components/enum-list-chips-field.tsx
@@ -0,0 +1,67 @@
+import type { CustomFieldComponentProps } from '@uipath/apollo-wind';
+import { FormField, FormFieldError } from '@uipath/apollo-wind';
+import { useId } from 'react';
+import type { GuardrailValidatorFormLabels } from '../i18n';
+import type { GuardrailParameterDefinition } from '../types';
+import { FieldShell } from './field-shell';
+import { GuardrailChip } from './guardrail-chip';
+import { ParameterLabel } from './parameter-label';
+
+/**
+ * Inline chip-toggle editor for enum-list parameters with small option sets (the ≤8 case),
+ * registered as the `guardrail-enum-list-chips` custom component; larger sets map to the
+ * first-class `multiselect` field type instead. `paramDef` and `labels` arrive via
+ * `componentProps` from `buildGuardrailFormSchema`.
+ */
+export function EnumListChipsField(props: CustomFieldComponentProps) {
+ const { value, onChange, error, disabled } = props;
+ const paramDef = props.paramDef as GuardrailParameterDefinition;
+ const labels = props.labels as GuardrailValidatorFormLabels;
+
+ const uid = useId();
+ const selected = Array.isArray(value) ? (value as string[]) : [];
+
+ /** Friendly label for an option value, falling back to the raw value when unmapped. */
+ const labelFor = (option: string) => paramDef.optionLabels?.[option] ?? option;
+
+ const handleToggle = (option: string, pressed: boolean) => {
+ onChange(pressed ? [...selected, option] : selected.filter((s) => s !== option));
+ };
+
+ return (
+
+ {/* The chips are Toggle buttons, not labelable controls, so the label names nothing on its
+ own and needs an id for the group to point at. Same treatment as the scope selector:
+ without it a screen reader reads individually named buttons with no idea which
+ parameter they belong to, or that it is invalid. */}
+
+
+ {/* biome-ignore lint/a11y/useSemanticElements:
+ {error}
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.test.tsx
new file mode 100644
index 0000000000..2fd2ffa316
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.test.tsx
@@ -0,0 +1,368 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { axe } from 'jest-axe';
+import { describe, expect, it, vi } from 'vitest';
+import { type GuardrailAction, GuardrailRecipientType } from '../builder-types';
+import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n';
+import { EscalateActionFields } from './escalate-action-fields';
+
+const labels = GUARDRAIL_BUILDER_EN_LABELS;
+type EscalateAction = Extract;
+
+function makeAction(overrides?: Partial): EscalateAction {
+ return {
+ $actionType: 'escalate',
+ app: { id: '', version: '', name: '' },
+ recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' },
+ ...overrides,
+ };
+}
+
+const baseProps = {
+ actionTypeSelect: ,
+ labels,
+};
+
+describe('EscalateActionFields', () => {
+ it('renders the injected action type cell inside its grid', () => {
+ render();
+ expect(screen.getByTestId('action-type-cell')).toBeInTheDocument();
+ });
+
+ describe('recipient type switching', () => {
+ it.each([
+ ['Group', { type: GuardrailRecipientType.Group, value: '', displayName: '' }],
+ ['Email address', { type: GuardrailRecipientType.StaticEmail, value: '' }],
+ ['Group name', { type: GuardrailRecipientType.StaticGroupName, value: '' }],
+ ] as const)('switching to %s resets the recipient payload', async (optionLabel, expectedRecipient) => {
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('combobox'));
+ fireEvent.click(await screen.findByRole('option', { name: optionLabel }));
+
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ recipient: expectedRecipient })
+ );
+ });
+ });
+
+ it('uses the renderRecipientSearch slot for user recipients', () => {
+ const onChange = vi.fn();
+ render(
+ (
+
+ )}
+ />
+ );
+
+ const slot = screen.getByTestId('directory-search');
+ expect(slot).toHaveAttribute('data-kind', 'user');
+ expect(slot).toHaveAttribute('data-display', 'User One');
+ expect(slot).toHaveAttribute('data-invalid', 'true');
+
+ fireEvent.click(slot);
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ recipient: { type: GuardrailRecipientType.User, value: 'u2', displayName: 'User Two' },
+ })
+ );
+ });
+
+ it('falls back to a plain input for user recipients without the slot', () => {
+ const onChange = vi.fn();
+ render();
+
+ const input = screen.getByPlaceholderText('Search for a user...');
+ fireEvent.change(input, { target: { value: 'jane@acme.com' } });
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ recipient: {
+ type: GuardrailRecipientType.User,
+ value: 'jane@acme.com',
+ displayName: 'jane@acme.com',
+ },
+ })
+ );
+ });
+
+ it('renders a plain input for static email recipients', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.change(screen.getByPlaceholderText('Enter email address'), {
+ target: { value: 'a@b.c' },
+ });
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ recipient: { type: GuardrailRecipientType.StaticEmail, value: 'a@b.c' },
+ })
+ );
+ });
+
+ it('renders the recipient error once, inside the recipient field', () => {
+ render(
+
+ );
+
+ expect(screen.getAllByText('Recipient is required')).toHaveLength(1);
+ });
+
+ describe('recipient error ownership', () => {
+ // The sibling FormFieldError used to render outside the slot/fallback ternary, so a host
+ // rendering ctx.error itself showed the message twice.
+ it('renders the message once when a search slot renders it itself', () => {
+ render(
+
{ctx.error}
}
+ />
+ );
+
+ expect(screen.getAllByText('Recipient is required')).toHaveLength(1);
+ expect(screen.getByTestId('search-slot')).toHaveTextContent('Recipient is required');
+ });
+
+ it('renders the message once when a static slot renders it itself', () => {
+ render(
+
{ctx.error}
}
+ />
+ );
+
+ expect(screen.getAllByText('Recipient is required')).toHaveLength(1);
+ expect(screen.getByTestId('static-slot')).toHaveTextContent('Recipient is required');
+ });
+
+ // The searchable fallback set aria-invalid by hand next to a bare message, so nothing tied
+ // the two together. Input's `error` prop wires the association.
+ it('associates the message with the input in the searchable fallback', () => {
+ render(
+
+ );
+
+ const input = screen.getByRole('textbox');
+ expect(input).toHaveAttribute('aria-invalid', 'true');
+
+ const describedBy = input.getAttribute('aria-describedby');
+ expect(describedBy).toBeTruthy();
+ expect(document.getElementById(describedBy as string)).toHaveTextContent(
+ 'Recipient is required'
+ );
+ expect(input).toHaveAttribute('aria-errormessage', describedBy as string);
+ });
+ });
+
+ it('uses the renderAppPicker slot with app context', () => {
+ const onChange = vi.fn();
+ render(
+ (
+
+ )}
+ />
+ );
+
+ const slot = screen.getByTestId('app-picker');
+ expect(slot).toHaveAttribute('data-label', 'Action App');
+ expect(slot).toHaveAttribute('data-error', 'Action app is required');
+ expect(slot).toHaveAttribute('data-app', 'My app');
+
+ fireEvent.click(slot);
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ app: { id: '', version: '', name: '' } })
+ );
+ });
+
+ it('falls back to the unavailable note without an app picker slot', () => {
+ render();
+ expect(screen.getByText(/app picker unavailable/i)).toBeInTheDocument();
+ });
+
+ it('renders escalateHelp below the grid', () => {
+ render(
+ See the marketplace.
}
+ />
+ );
+
+ expect(screen.getByTestId('help-line')).toBeInTheDocument();
+ });
+
+ it('has no accessibility violations', async () => {
+ const { container } = render(
+
+ );
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
+
+describe('renderStaticRecipient slot', () => {
+ it('mounts the slot for static recipients and lets it replace the recipient wholesale', () => {
+ const onChange = vi.fn();
+ const action = makeAction({
+ recipient: { type: GuardrailRecipientType.StaticEmail, value: 'a@b.c' },
+ });
+
+ render(
+ (
+
+ )}
+ />
+ );
+
+ const slot = screen.getByTestId('static-slot');
+ expect(slot).toHaveAttribute('data-kind', 'email');
+ fireEvent.click(slot);
+ expect(onChange).toHaveBeenCalledWith({
+ ...action,
+ recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'EmailAsset' },
+ });
+ });
+
+ it('falls through to the built-in input when the slot returns undefined', () => {
+ const onChange = vi.fn();
+ const action = makeAction({
+ recipient: { type: GuardrailRecipientType.StaticGroupName, value: '' },
+ });
+
+ render(
+ undefined}
+ />
+ );
+
+ const input = screen.getByPlaceholderText(labels.groupNamePlaceholder);
+ fireEvent.change(input, { target: { value: 'Ops' } });
+ expect(onChange).toHaveBeenCalledWith({
+ ...action,
+ recipient: { type: GuardrailRecipientType.StaticGroupName, value: 'Ops' },
+ });
+ });
+
+ it('is not invoked for searchable (user/group) recipients', () => {
+ const renderStaticRecipient = vi.fn();
+ render(
+ {}}
+ renderStaticRecipient={renderStaticRecipient}
+ />
+ );
+ expect(renderStaticRecipient).not.toHaveBeenCalled();
+ });
+});
+
+describe('asset recipient variants', () => {
+ it('displays an asset recipient as its static sibling in the type select', () => {
+ render(
+ {}}
+ />
+ );
+
+ expect(
+ screen.getByRole('combobox', {
+ name: `${labels.assignToLabel}: ${labels.recipientGroupNameLabel}`,
+ })
+ ).toBeInTheDocument();
+ });
+
+ it('edits assetName through the built-in fallback input', () => {
+ const onChange = vi.fn();
+ const action = makeAction({
+ recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'Old' },
+ });
+
+ render();
+
+ const input = screen.getByPlaceholderText(labels.emailPlaceholder);
+ expect(input).toHaveValue('Old');
+ fireEvent.change(input, { target: { value: 'New' } });
+ expect(onChange).toHaveBeenCalledWith({
+ ...action,
+ recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'New' },
+ });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.tsx
new file mode 100644
index 0000000000..c9b11f58e8
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.tsx
@@ -0,0 +1,278 @@
+import {
+ Alert,
+ AlertDescription,
+ FormField,
+ FormFieldError,
+ Input,
+ Label,
+ RequiredIndicator,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@uipath/apollo-wind';
+import { Info } from 'lucide-react';
+import { type ReactNode, useCallback, useId } from 'react';
+import {
+ type GuardrailAction,
+ type GuardrailAppPickerContext,
+ type GuardrailEscalateRecipient,
+ type GuardrailRecipientSearchContext,
+ GuardrailRecipientType,
+ type GuardrailStaticRecipientContext,
+} from '../builder-types';
+import type { GuardrailBuilderLabels } from '../i18n';
+
+type EscalateAction = Extract;
+
+export interface EscalateActionFieldsProps {
+ action: EscalateAction;
+ onChange: (action: EscalateAction) => void;
+ /** Leading grid cell (the Action type select); this component owns the full escalate layout. */
+ actionTypeSelect: ReactNode;
+ errors?: { recipient?: string; actionApp?: string };
+ labels: GuardrailBuilderLabels;
+ renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => ReactNode;
+ renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => ReactNode | undefined;
+ renderAppPicker?: (ctx: GuardrailAppPickerContext) => ReactNode;
+ /** Rendered under the escalation grid (e.g. a marketplace help line). */
+ escalateHelp?: ReactNode;
+}
+
+/**
+ * Escalation action fields: recipient type + recipient value + action-app picker. The
+ * recipient autosuggest (User/Group) and the app picker are host capabilities injected via
+ * render props; plain-input / unavailable-note fallbacks keep the form usable without them.
+ */
+export function EscalateActionFields({
+ action,
+ onChange,
+ actionTypeSelect,
+ errors,
+ labels,
+ renderRecipientSearch,
+ renderStaticRecipient,
+ renderAppPicker,
+ escalateHelp,
+}: EscalateActionFieldsProps) {
+ // Namespaced per instance — two builders can share a document (inline panels).
+ const uid = useId();
+
+ const recipientTypeLabels: Record = {
+ [GuardrailRecipientType.User]: labels.recipientUserLabel,
+ [GuardrailRecipientType.Group]: labels.recipientGroupLabel,
+ [GuardrailRecipientType.StaticEmail]: labels.recipientEmailLabel,
+ [GuardrailRecipientType.StaticGroupName]: labels.recipientGroupNameLabel,
+ };
+
+ const recipientType = action.recipient.type;
+ // The type select offers the four base entries; asset variants (a host-slot concern)
+ // display as their static siblings so the selection never blanks.
+ const displayedRecipientType =
+ recipientType === GuardrailRecipientType.AssetEmail
+ ? GuardrailRecipientType.StaticEmail
+ : recipientType === GuardrailRecipientType.AssetGroupName
+ ? GuardrailRecipientType.StaticGroupName
+ : recipientType;
+
+ const handleRecipientTypeChange = useCallback(
+ (value: string) => {
+ const newType = Number(value) as GuardrailEscalateRecipient['type'];
+ let recipient: GuardrailEscalateRecipient;
+
+ switch (newType) {
+ case GuardrailRecipientType.Group:
+ recipient = { type: GuardrailRecipientType.Group, value: '', displayName: '' };
+ break;
+ case GuardrailRecipientType.StaticEmail:
+ recipient = { type: GuardrailRecipientType.StaticEmail, value: '' };
+ break;
+ case GuardrailRecipientType.StaticGroupName:
+ recipient = { type: GuardrailRecipientType.StaticGroupName, value: '' };
+ break;
+ default:
+ recipient = { type: GuardrailRecipientType.User, value: '', displayName: '' };
+ }
+
+ onChange({ ...action, recipient });
+ },
+ [action, onChange]
+ );
+
+ const handleRecipientSelect = useCallback(
+ (selection: { value: string; displayName: string }) => {
+ const r = action.recipient;
+ if (r.type === GuardrailRecipientType.User || r.type === GuardrailRecipientType.Group) {
+ onChange({
+ ...action,
+ recipient: { ...r, value: selection.value, displayName: selection.displayName },
+ });
+ }
+ },
+ [action, onChange]
+ );
+
+ const handleRecipientClear = useCallback(() => {
+ const r = action.recipient;
+ if (r.type === GuardrailRecipientType.User || r.type === GuardrailRecipientType.Group) {
+ onChange({ ...action, recipient: { ...r, value: '', displayName: '' } });
+ }
+ }, [action, onChange]);
+
+ const handleTextValueChange = useCallback(
+ (value: string) => {
+ const r = action.recipient;
+ if (
+ r.type === GuardrailRecipientType.StaticEmail ||
+ r.type === GuardrailRecipientType.StaticGroupName
+ ) {
+ onChange({ ...action, recipient: { ...r, value } });
+ } else if (
+ r.type === GuardrailRecipientType.AssetEmail ||
+ r.type === GuardrailRecipientType.AssetGroupName
+ ) {
+ onChange({ ...action, recipient: { ...r, assetName: value } });
+ }
+ },
+ [action, onChange]
+ );
+
+ const recipientValue =
+ 'value' in action.recipient ? action.recipient.value : action.recipient.assetName;
+ const recipientDisplayValue =
+ ('displayName' in action.recipient ? action.recipient.displayName : '') || recipientValue;
+ const isSearchable =
+ recipientType === GuardrailRecipientType.User || recipientType === GuardrailRecipientType.Group;
+ const searchKind = recipientType === GuardrailRecipientType.User ? 'user' : 'group';
+ const searchPlaceholder =
+ searchKind === 'user' ? labels.userSearchPlaceholder : labels.groupSearchPlaceholder;
+
+ const appPickerCtx: GuardrailAppPickerContext = {
+ app: action.app.name ? action.app : null,
+ onChange: (app) => onChange({ ...action, app: app ?? { id: '', version: '', name: '' } }),
+ label: labels.actionAppLabel,
+ error: errors?.actionApp,
+ };
+
+ const fields = (
+ <>
+ {/* Recipient type */}
+
+
+
+
+
+ {/* Recipient value */}
+
+
+ {/* One rule across all three slots, matching `renderAppPicker`: a slot receives `error`
+ and owns rendering it, so the form renders no message of its own for a claimed field.
+ The sibling `FormFieldError` used to sit outside this ternary, so a host doing the
+ obvious `` got the message twice. Every fallback goes
+ through `Input`'s `error` prop, which renders the message *and* wires
+ aria-describedby / aria-errormessage / aria-invalid — the searchable fallback set
+ aria-invalid by hand and left the message associated with nothing. */}
+ {isSearchable ? (
+ renderRecipientSearch ? (
+ renderRecipientSearch({
+ kind: searchKind,
+ displayValue: recipientDisplayValue,
+ placeholder: searchPlaceholder,
+ invalid: Boolean(errors?.recipient),
+ error: errors?.recipient,
+ onSelect: handleRecipientSelect,
+ onClear: handleRecipientClear,
+ })
+ ) : (
+ // Fallback without a host directory search: a plain input writing the value directly.
+
+ handleRecipientSelect({ value: e.target.value, displayName: e.target.value })
+ }
+ placeholder={searchPlaceholder}
+ error={errors?.recipient}
+ />
+ )
+ ) : (
+ (() => {
+ const staticNode = renderStaticRecipient?.({
+ kind:
+ displayedRecipientType === GuardrailRecipientType.StaticEmail
+ ? 'email'
+ : 'groupName',
+ recipient: action.recipient,
+ label: recipientTypeLabels[displayedRecipientType] ?? labels.recipientFallbackLabel,
+ invalid: Boolean(errors?.recipient),
+ error: errors?.recipient,
+ onChange: (recipient) => onChange({ ...action, recipient }),
+ });
+ // The slot owns the message; see the note above.
+ if (staticNode !== undefined) return staticNode;
+ return (
+ handleTextValueChange(e.target.value)}
+ placeholder={
+ displayedRecipientType === GuardrailRecipientType.StaticEmail
+ ? labels.emailPlaceholder
+ : labels.groupNamePlaceholder
+ }
+ error={errors?.recipient}
+ />
+ );
+ })()
+ )}
+
+
+ {/* Action app picker (host capability) */}
+
+ {renderAppPicker ? (
+ renderAppPicker(appPickerCtx)
+ ) : (
+ <>
+
+
+
+ {labels.appPickerUnavailable}
+
+ {/* The builder still gates Save on `actionApp` when no picker slot is supplied,
+ so without this the user is blocked with the reason rendered nowhere. */}
+ {errors?.actionApp}
+ >
+ )}
+
+ >
+ );
+
+ return (
+
+
+ {actionTypeSelect}
+ {fields}
+
+ {escalateHelp}
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.test.tsx
new file mode 100644
index 0000000000..eb57307445
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { FieldShell } from './field-shell';
+
+describe('FieldShell', () => {
+ it('renders the error border only when invalid', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('shell')).not.toHaveClass('border-error');
+
+ rerender();
+ expect(screen.getByTestId('shell')).toHaveClass('border-error');
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.tsx
new file mode 100644
index 0000000000..69cc580316
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.tsx
@@ -0,0 +1,37 @@
+import { cn } from '@uipath/apollo-wind';
+import * as React from 'react';
+
+export interface FieldShellProps extends React.ComponentPropsWithoutRef<'div'> {
+ /** Renders the error border (uses the `error` token, matching aria-invalid controls). */
+ invalid?: boolean;
+}
+
+/**
+ * Input-look container for non-input controls (chip groups) in the guardrail forms.
+ *
+ * Tracks apollo-wind's field chrome deliberately, including the `future:` layer: fields there go
+ * borderless on a raised surface with a larger radius, so a shell that kept a visible outline and
+ * a `background` fill read as flat and out-of-place next to its own neighbours in the future and
+ * dark themes. `InputGroup` is the same idea in apollo-wind — a container that looks like an input
+ * but wraps non-input children — and this mirrors its treatment, error state included.
+ */
+const FieldShell = React.forwardRef(
+ ({ invalid = false, className, children, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+FieldShell.displayName = 'FieldShell';
+
+export { FieldShell };
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.test.tsx
new file mode 100644
index 0000000000..b2a8a31283
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.test.tsx
@@ -0,0 +1,121 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { axe } from 'jest-axe';
+import { describe, expect, it, vi } from 'vitest';
+import { type GuardrailAction, GuardrailRecipientType } from '../builder-types';
+import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n';
+import { GuardrailActionSection } from './guardrail-action-section';
+
+const labels = GUARDRAIL_BUILDER_EN_LABELS;
+const logAction: GuardrailAction = { $actionType: 'log', severityLevel: 'Info' };
+
+describe('GuardrailActionSection', () => {
+ it('renders the action type select with the current value', () => {
+ render();
+
+ expect(screen.getByText('Action type')).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: /action type/i })).toHaveTextContent('Log');
+ });
+
+ it('resets the payload when the action type changes', async () => {
+ const onActionChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('combobox', { name: /action type/i }));
+ fireEvent.click(await screen.findByRole('option', { name: 'Block' }));
+
+ expect(onActionChange).toHaveBeenCalledWith({ $actionType: 'block', reason: '' });
+ });
+
+ it('offers Filter only when showFilter is set', async () => {
+ const { unmount } = render(
+
+ );
+ fireEvent.click(screen.getByRole('combobox', { name: /action type/i }));
+ expect(await screen.findByRole('option', { name: 'Log' })).toBeInTheDocument();
+ expect(screen.queryByRole('option', { name: 'Filter' })).not.toBeInTheDocument();
+ unmount();
+
+ render(
+
+ );
+ fireEvent.click(screen.getByRole('combobox', { name: /action type/i }));
+ expect(await screen.findByRole('option', { name: 'Filter' })).toBeInTheDocument();
+ });
+
+ it('renders the severity select for log actions and reports changes', async () => {
+ const onActionChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('combobox', { name: /severity level/i }));
+ fireEvent.click(await screen.findByRole('option', { name: 'Warning' }));
+
+ expect(onActionChange).toHaveBeenCalledWith({ $actionType: 'log', severityLevel: 'Warning' });
+ });
+
+ it('renders the blocking reason input with its error for block actions', () => {
+ const onActionChange = vi.fn();
+ render(
+
+ );
+
+ const input = screen.getByLabelText(/blocking reason/i);
+ fireEvent.change(input, { target: { value: 'no PII' } });
+ expect(onActionChange).toHaveBeenCalledWith({ $actionType: 'block', reason: 'no PII' });
+ expect(screen.getByText('Block reason is required')).toBeInTheDocument();
+ });
+
+ it('renders filterContent and its error for filter actions', () => {
+ render(
+ }
+ errors={{ filterFields: 'Fields selection is required' }}
+ labels={labels}
+ />
+ );
+
+ expect(screen.getByTestId('filter-slot')).toBeInTheDocument();
+ expect(screen.getByText('Fields selection is required')).toBeInTheDocument();
+ });
+
+ it('delegates escalate actions to the escalation fields', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Assign to')).toBeInTheDocument();
+ expect(screen.getByText('Action App')).toBeInTheDocument();
+ });
+
+ it('has no accessibility violations', async () => {
+ const { container } = render(
+
+ );
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.tsx
new file mode 100644
index 0000000000..63677e5acd
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.tsx
@@ -0,0 +1,156 @@
+import {
+ FormField,
+ FormFieldError,
+ Input,
+ Label,
+ RequiredIndicator,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@uipath/apollo-wind';
+import { type ReactNode, useId } from 'react';
+import type {
+ GuardrailAction,
+ GuardrailAppPickerContext,
+ GuardrailRecipientSearchContext,
+ GuardrailStaticRecipientContext,
+} from '../builder-types';
+import { createDefaultGuardrailAction } from '../builder-utils';
+import type { GuardrailBuilderLabels } from '../i18n';
+import { EscalateActionFields } from './escalate-action-fields';
+
+export interface GuardrailActionSectionProps {
+ action: GuardrailAction;
+ onActionChange: (action: GuardrailAction) => void;
+ /** Whether to include 'filter' as an option (custom guardrails only) */
+ showFilter?: boolean;
+ /** Content rendered as the second grid column when $actionType === 'filter' */
+ filterContent?: ReactNode;
+ errors?: { blockReason?: string; filterFields?: string; recipient?: string; actionApp?: string };
+ labels: GuardrailBuilderLabels;
+ renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => ReactNode;
+ renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => ReactNode | undefined;
+ renderAppPicker?: (ctx: GuardrailAppPickerContext) => ReactNode;
+ escalateHelp?: ReactNode;
+}
+
+/**
+ * Action section of the guardrail builder: a 2-column grid of action-type select + the
+ * type-dependent secondary field. Switching the type resets the action payload. Escalate
+ * expands into the full escalation layout.
+ */
+export function GuardrailActionSection({
+ action,
+ onActionChange,
+ showFilter = false,
+ filterContent,
+ errors,
+ labels,
+ renderRecipientSearch,
+ renderStaticRecipient,
+ renderAppPicker,
+ escalateHelp,
+}: GuardrailActionSectionProps) {
+ // Namespaced per instance — two builders can share a document (inline panels).
+ const uid = useId();
+
+ const actionTypeSelect = (
+
+
+
+
+ );
+
+ if (action.$actionType === 'escalate') {
+ return (
+
+ );
+ }
+
+ return (
+