From 5f841ac9b6b974ae1efb0136dc263dac1672594e Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Fri, 23 Oct 2026 13:01:21 +0300 Subject: [PATCH 1/2] feat(apollo-react): show what a centralized guardrail reads on its row A centralized guardrail row now reads "Scope: LLM calls | Detection: Files | Execution stage: Pre-execution", worded as both products' own guardrail rows. Detection is the policy's detectionScope, or Text when it stored none, as the gateway reads it; Both reads "Text, Files". It shows only on a built-in whose definition still offers the parameter: hosts strip it where file support is off, and then no file is read. BYO rows show none. getCentralizedGuardrailDetection is exported so the hosts' legacy rows read it the same way. The new labels are optional, like the detection scope fallback. Co-Authored-By: Claude Opus 5.5 --- .../canvas/components/Guardrails/README.md | 9 ++ .../centralized-guardrail-utils.test.ts | 55 +++++++++++ .../Guardrails/centralized-guardrail-utils.ts | 43 ++++++++- .../centralized-guardrails-section.test.tsx | 96 ++++++++++++++++--- .../centralized-guardrails-section.tsx | 7 +- .../centralized-guardrails.stories.tsx | 45 +++++++++ .../Guardrails/centralized-parity.test.ts | 5 + .../components/centralized-guardrail-row.tsx | 46 ++++++++- .../canvas/components/Guardrails/i18n.test.ts | 11 ++- .../src/canvas/components/Guardrails/i18n.ts | 9 ++ .../src/canvas/components/Guardrails/index.ts | 2 + .../apollo-react/src/canvas/locales/en.json | 4 + 12 files changed, 309 insertions(+), 23 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/Guardrails/README.md b/packages/apollo-react/src/canvas/components/Guardrails/README.md index 6b7a687175..aa424bf52a 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/README.md +++ b/packages/apollo-react/src/canvas/components/Guardrails/README.md @@ -581,6 +581,15 @@ Contract highlights: `formatScope` / `formatAction` to override. Every one of those strings already existed in the canvas catalog, so defaulting removes a prop an adapter can forget for a visible regression (`Llm` instead of "LLM calls"). +- **The row's summary line reads as the hosts' own guardrail rows do**: + `Scope: LLM calls | Detection: Files | Execution stage: Pre-execution`, with the bars + `aria-hidden`. Detection is what the guardrail reads (`getCentralizedGuardrailDetection`): + the policy's `detectionScope`, or Text when it stored none, as the gateway reads it; `Both` + reads "Text, Files". It shows only on a built-in whose definition still offers the + parameter. Hosts strip it from their definitions where file support is off, and Agents then + reads no file for any guardrail, so the row leaves Detection out rather than claim it. A BYO + row, a validator without the parameter, and a row whose definitions are still loading show + none. - **A broken BYO configuration gets a chip and a sentence.** The chip is `GuardrailList`'s own (`Unavailable` / `Disabled`, same ids) and makes the row findable in a long policy; the sentence under it, which both products already show, says what to do about it. diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.test.ts index 2063f6aadc..44ca417831 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.test.ts +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.test.ts @@ -6,6 +6,7 @@ import { formatCentralizedExecutionStage, formatCentralizedScope, getApplicableCentralizedGuardrails, + getCentralizedGuardrailDetection, getCentralizedGuardrailDisplay, getCentralizedGuardrailItemId, isCentralizedGuardrailConfigMissing, @@ -205,6 +206,60 @@ describe('findCentralizedBuiltInDefinition', () => { }); }); +describe('getCentralizedGuardrailDetection', () => { + const offered = [{ validator: 'pii_detection', parameters: [{ id: 'detectionScope' }] }]; + const scoped = (value: unknown) => + guardrail({ parameters: [{ id: 'detectionScope', parameterType: 'enum', value }] }); + + it.each([ + ['Files', 'Files'], + ['Both', 'Both'], + ['Text', 'Text'], + [' files ', 'Files'], + ['BOTH', 'Both'], + ['Prompts', 'Text'], + ['', 'Text'], + [42, 'Text'], + ])('reads a stored %j as %s, the way the gateway reads the policy', (value, expected) => { + expect(getCentralizedGuardrailDetection(scoped(value), offered)).toBe(expected); + }); + + it('reads a built-in saved without a detection scope as Text', () => { + expect(getCentralizedGuardrailDetection(guardrail(), offered)).toBe('Text'); + expect(getCentralizedGuardrailDetection(guardrail({ parameters: null }), offered)).toBe('Text'); + }); + + it('has nothing to say while the definitions load or once file support stripped the parameter', () => { + expect(getCentralizedGuardrailDetection(scoped('Files'), undefined)).toBeUndefined(); + expect( + getCentralizedGuardrailDetection(scoped('Files'), [{ validator: 'pii_detection' }]) + ).toBeUndefined(); + expect( + getCentralizedGuardrailDetection(scoped('Files'), [ + { validator: 'pii_detection', parameters: [{ id: 'entities' }] }, + ]) + ).toBeUndefined(); + }); + + it('matches only the built-in definition of the same validator', () => { + expect( + getCentralizedGuardrailDetection(scoped('Files'), [ + { validator: 'harmful_content', parameters: [{ id: 'detectionScope' }] }, + { + validator: 'pii_detection', + byoValidatorName: 'Acme PII', + parameters: [{ id: 'detectionScope' }], + }, + ]) + ).toBeUndefined(); + }); + + it('never reports one for a BYO guardrail', () => { + const byo = { ...scoped('Files'), isByo: true, name: 'Acme PII' }; + expect(getCentralizedGuardrailDetection(byo, offered)).toBeUndefined(); + }); +}); + describe('isCentralizedGuardrailConfigMissing', () => { const identity = { validator: 'pii_detection', name: 'Acme PII', isByo: true }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.ts b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.ts index e765be4d70..26a39a745a 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.ts +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.ts @@ -59,11 +59,10 @@ export function findCentralizedByoDefinition( - guardrail: CentralizedGuardrailIdentity, - definitions: readonly T[] | undefined -): T | undefined { +/** A built-in's definition, to label its configuration rows and gate its detection scope. */ +export function findCentralizedBuiltInDefinition< + T extends Pick, +>(guardrail: CentralizedGuardrailIdentity, definitions: readonly T[] | undefined): T | undefined { if (guardrail.isByo) return undefined; return definitions?.find( (definition) => @@ -71,6 +70,40 @@ export function findCentralizedBuiltInDefinition & { parameters?: readonly { id: string }[] }; + +/** + * What a built-in reads, for its row. Agents stamps the policy's detection scope on every + * centralized built-in, so it shows only while the built-in's definition still offers the + * parameter: hosts strip it where file support is off, and then no file is read. Absent, blank + * or unknown is Text, as the gateway reads the policy. + */ +export function getCentralizedGuardrailDetection( + guardrail: CentralizedGuardrailIdentity & Pick, + definitions: readonly CentralizedDetectionDefinition[] | undefined +): GuardrailDetectionScope | undefined { + const definition = findCentralizedBuiltInDefinition(guardrail, definitions); + const offered = definition?.parameters?.some( + (parameter) => parameter.id === GUARDRAIL_DETECTION_SCOPE_PARAMETER_ID + ); + if (!offered) return undefined; + const value = guardrail.parameters?.find( + (parameter) => parameter.id === GUARDRAIL_DETECTION_SCOPE_PARAMETER_ID + )?.value; + switch (typeof value === 'string' ? value.trim().toLowerCase() : '') { + case 'files': + return 'Files'; + case 'both': + return 'Both'; + default: + return 'Text'; + } +} + /** Whether a BYO configuration is gone. `undefined` definitions (still loading) report `false`. */ export function isCentralizedGuardrailConfigMissing( guardrail: CentralizedGuardrailIdentity, diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.test.tsx index c26fc8b582..2eaa65b741 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.test.tsx +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.test.tsx @@ -26,6 +26,18 @@ const BYO_DEFINITION: CentralizedGuardrailDefinition = { description: 'Acme runs its own detector.', }; +const withDetectionScope = (validator: string): CentralizedGuardrailDefinition => ({ + validator, + parameters: [{ id: 'detectionScope', type: 'enum' }], +}); + +const detectionScope = (value: string): Partial => ({ + parameters: [{ id: 'detectionScope', parameterType: 'enum', value }], +}); + +const summaries = (container: HTMLElement) => + Array.from(container.querySelectorAll('[data-slot="centralized-guardrail-summary"]')); + describe('CentralizedGuardrailsSection', () => { it('renders nothing at all when the policy enforces none, as both products do', () => { const { container } = render( @@ -89,16 +101,19 @@ describe('CentralizedGuardrailsSection', () => { ); }); - it('localizes the scopes and the execution stage without a formatScope prop', () => { - render(); + it('localizes the scope and the execution stage without a formatScope prop', () => { + const { container } = render( + + ); - expect( - screen.getByText('Scopes: Agent, LLM calls · Execution stage: Pre-execution') - ).toBeInTheDocument(); + const [summary] = summaries(container); + expect(summary).toHaveTextContent('Scope: Agent, LLM calls | Execution stage: Pre-execution'); + // The separator is decoration; the parts are named by their own labels. + expect(summary?.querySelector('[aria-hidden="true"]')).toHaveTextContent('|'); }); it('lets a host replace the scope names', () => { - render( + const { container } = render( { /> ); - expect( - screen.getByText('Scopes: AGENT, LLM · Execution stage: Pre-execution') - ).toBeInTheDocument(); + expect(summaries(container)[0]).toHaveTextContent( + 'Scope: AGENT, LLM | Execution stage: Pre-execution' + ); + }); + + it('says what a built-in reads while its definition offers the detection scope', () => { + const { container } = render( + + ); + + expect(summaries(container).map((summary) => summary.textContent)).toEqual([ + 'Scope: LLM calls | Detection: Files | Execution stage: Pre-execution', + 'Scope: LLM calls | Detection: Text, Files | Execution stage: Pre-execution', + // Saved without a scope: the gateway reads it as text. + 'Scope: LLM calls | Detection: Text | Execution stage: Pre-execution', + ]); + }); + + it('leaves the detection scope out where the definitions do not offer it', () => { + // Stripped by the host where file support is off, still loading, a BYO row, and a + // validator with no such parameter. + const files = detectionScope('Files'); + const { container, rerender } = render( + + ); + expect(summaries(container)[0]).not.toHaveTextContent('Detection'); + + rerender( + + ); + expect(summaries(container)[0]).not.toHaveTextContent('Detection'); + + rerender( + + ); + for (const summary of summaries(container)) expect(summary).not.toHaveTextContent('Detection'); }); it('says nothing about a missing configuration while the catalog is still loading', () => { @@ -245,9 +316,10 @@ describe('CentralizedGuardrailsSection', () => { ); - // The row's metadata line, through the builder ids this block reuses. The section's own - // ids render English until `chore(l10n): sync from Localization` reaches them. - expect(screen.getByText(/スコープ/)).toBeInTheDocument(); + // The row's summary line: the reused builder scope names and the section's translated stage. + // Newer ids render English until `chore(l10n): sync from Localization` reaches them. + expect(screen.getByText(/エージェント, LLM の呼び出し/)).toBeInTheDocument(); + expect(screen.getByText(/実行ステージ: 実行前/)).toBeInTheDocument(); }); it('has no axe violations', async () => { diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.tsx b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.tsx index a592629745..512cfe546a 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.tsx +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.tsx @@ -5,6 +5,7 @@ import type { GuardrailScope } from './builder-types'; import { findCentralizedByoDefinition, formatCentralizedScope, + getCentralizedGuardrailDetection, getCentralizedGuardrailDisplay, getCentralizedGuardrailItemId, isCentralizedGuardrailConfigMissing, @@ -23,7 +24,10 @@ export interface CentralizedGuardrailsSectionProps< guardrails: CentralizedGuardrail[]; /** Name of the AI Trust Layer policy enforcing them; shown in the caption. */ policyName: string; - /** For provider names and configuration status. `undefined` while the catalog loads. */ + /** + * For provider names, configuration status and whether a row shows its detection scope: pass + * them after file-support stripping. `undefined` while the catalog loads. + */ definitions?: TDefinition[]; /** The host opens the details (a dialog, a panel overlay). */ onSelect?: (guardrail: CentralizedGuardrail) => void; @@ -131,6 +135,7 @@ export function CentralizedGuardrailsSection< scopeNames={guardrail.scopes.map( (scope) => formatScope?.(scope) ?? formatCentralizedScope(scope, labels) )} + detection={getCentralizedGuardrailDetection(guardrail, definitions)} labels={labels} onSelect={onSelect === undefined ? undefined : () => onSelect(guardrail)} /> diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails.stories.tsx b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails.stories.tsx index f60c72011e..2e4830bf78 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails.stories.tsx +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails.stories.tsx @@ -281,6 +281,51 @@ export const StillLoading: Story = { }, }; +const detectionScopeParameter = { id: 'detectionScope', type: 'enum', label: 'Detection scope' }; + +/** The catalog as a host passes it where file support is on. */ +const definitionsWithFileSupport: CentralizedGuardrailDefinition[] = [ + ...definitions.map((definition) => + definition.byoValidatorName === undefined && + (definition.validator === 'pii_detection' || definition.validator === 'harmful_content') + ? { ...definition, parameters: [...(definition.parameters ?? []), detectionScopeParameter] } + : definition + ), + { validator: 'llm_as_judge', parameters: [detectionScopeParameter] }, +]; + +export const DetectionScope: Story = { + name: 'What each guardrail reads', + args: { + guardrails: [piiFilesGuardrail, judgeGuardrail, harmfulContentGuardrail, byoGuardrail], + definitions: definitionsWithFileSupport, + policyName: 'Acme production policy', + docsHref: DOCS_HREF, + onSelect: noop, + }, + parameters: { + docs: { + description: { + story: + 'Where file support is on, the definitions a host passes still offer the detection scope, so each built-in row says what it reads: the policy value, or Text when the policy stored none. A BYO row never shows one.', + }, + }, + }, +}; + +export const DetectionScopeWithoutFileSupport: Story = { + name: 'What each guardrail reads, file support off', + args: { ...DetectionScope.args, definitions }, + parameters: { + docs: { + description: { + story: + 'Where file support is off, the host strips the detection scope from its definitions and no guardrail reads files, so the rows leave it out even though the policy still carries a value.', + }, + }, + }, +}; + export const InsideHostChrome: Story = { name: 'Inside a host section', args: { diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-parity.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/centralized-parity.test.ts index b169a2ec39..60a1c32533 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/centralized-parity.test.ts +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-parity.test.ts @@ -78,6 +78,11 @@ const HOST_COPY: Partial> = detectionScopeText: { agents: 'Text only' }, detectionScopeFiles: { agents: 'Files only' }, detectionScopeBoth: { agents: 'Text and files' }, + // Both products' own guardrail rows (Agents#6782, Flow#5421), whose wording the row adopts. + scope: { agents: 'Scope', flow: 'Scope' }, + detection: { agents: 'Detection', flow: 'Detection' }, + detectionText: { agents: 'Text', flow: 'Text' }, + detectionFiles: { agents: 'Files', flow: 'Files' }, }; interface CopyDivergence { diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-row.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-row.tsx index f4aa3ec925..f91bfa3a15 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-row.tsx +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-row.tsx @@ -4,6 +4,7 @@ import { formatCentralizedExecutionStage } from '../centralized-guardrail-utils' import type { CentralizedGuardrail, CentralizedGuardrailDefinition } from '../centralized-types'; import type { CentralizedGuardrailsLabels } from '../i18n'; import { formatGuardrailFormMessage } from '../i18n'; +import type { GuardrailDetectionScope } from '../types'; import { CentralizedGuardrailOriginChip } from './centralized-guardrail-origin-chip'; import { GuardrailStatusChip } from './guardrail-status-chip'; @@ -15,10 +16,37 @@ export interface CentralizedGuardrailRowProps { isConfigMissing: boolean; /** Localized scope names, already resolved and ordered. */ scopeNames: string[]; - labels: CentralizedGuardrailsLabels; + /** What the guardrail reads; `undefined` hides that part of the summary line. */ + detection?: GuardrailDetectionScope; + labels: Required; onSelect?: () => void; } +function SummarySeparator() { + return ( + <> + {' '} + {' '} + + ); +} + +function formatDetection( + detection: GuardrailDetectionScope, + labels: Required +): string { + switch (detection) { + case 'Files': + return labels.detectionFiles; + case 'Both': + return `${labels.detectionText}, ${labels.detectionFiles}`; + default: + return labels.detectionText; + } +} + /** * One centralized guardrail, styled like `GuardrailListRow`. With `onSelect` the whole row is * a `