Guardrail Panel
ProSafety policy as an operating surface: the rules, what they caught, and the answer the policy actually produced. 3 variants: rules, feed and redaction preview.
import { SvGuardrailPanelComponent } from '@styloviz-pro/guardrail-panel';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Feed & redaction preview
ProA policy tuned to your own traffic
ProWhen to use
- Tuning a safety policy, where the question is not how many things it caught but what it did to the output.
- Reviewing PII redaction before it ships, so an over-redacted answer is caught by a human rather than by a customer.
- Auditing which rules earn their latency — a policy grows rules and never loses them.
Async states
- loading
- error
- retry
Item identity & owned state
- Restorable state
- pendingRuleIds
- revealedEventIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| loading | boolean | false | True while the policy is being fetched; shows a skeleton in place of the panel. |
| error | SvDataError | string | null | null | Load failure, shown as an error panel in place of the content. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. |
| variant | GuardrailVariant | 'rules' | Presentation: the policy, the feed, or one event's text. |
| rules | readonly PolicyRule[] | [] | The policy. |
| events | readonly GuardrailEvent[] | [] | What the policy has seen. |
| selectedEventId | string | null (two-way) | null | The event whose text the `redaction` variant shows. Two-way, because selecting a row in the feed should move it. Falls back to the first event that actually had something masked — a redaction preview opening on an untouched answer teaches nothing. |
| mask | string | '[REDACTED]' | What a masked run is replaced with. |
| revealScope | SvRevealScope | 'on-request' | Who may see the original text behind a redaction. THE REDACTION REVIEWER SEES EXACTLY WHAT THE REDACTION EXISTS TO HIDE. Unavoidable for the person judging whether a rule fired correctly — and not a reason to render every event's raw text to everyone who opens the panel, on a surface that gets screenshotted into tickets and shared over calls. `'on-request'` needs the role *and* a click on that event; `'all'` is for a role whose job is reading raw matches all day, because a reviewer who clicks reflexively two hundred times is not consenting to anything. |
| editable | boolean | true | Whether the reader may change the policy. A GUARDRAIL TOGGLE IS AN AUTHORIZATION DECISION, AND THE COMPONENT DOES NOT KNOW THE ROLE. Set it false and the rules stay listed and readable with no controls — a policy a reader cannot see is a policy nobody revisits, so read-only is the right shape rather than an empty panel. |
| pendingRuleIds | readonly string[] | ReadonlySet<string> | [] | Ids of rules with a toggle in flight. Their controls disable, so a second click cannot fire the same privileged change twice. |
| policyVersion | string | null | null | The policy's version, as the host's system of record names it. |
| policyHistory | readonly SvPolicyChange[] | [] | Recorded changes, newest first. ORDERED BY THE HOST, NOT SORTED HERE. These are display strings from whatever keeps the audit log, and parsing them to sort would invent an ordering out of "2 Sept" and "yesterday". |
| now | number | null | null | Clock for the toggle payload, injectable so a test is not at the mercy of the wall clock. |
| lowConfidenceMatch | number | SV_LOW_CONFIDENCE_MATCH | Confidence below which a match counts as a guess, `0`–`1`. EVERY THRESHOLD ON THIS COMPONENT IS A POLICY QUESTION WEARING A NUMBER, and every default is about somebody else's traffic. 0.7 is where a classifier's guess stops being trusted — a team matching medical identifiers wants it higher than a team filtering profanity. |
| overRedactionPct | number | SV_OVER_REDACTION_PCT | Share of an answer past which the policy has probably destroyed it, `0`–`100`. 30 assumes an answer of a few hundred characters. On an SMS reply it fires constantly; on a ten-page report it never does. |
| destructiveRunChars | number | SV_DESTRUCTIVE_RUN_CHARS | A single masked run this long means a claim went, not a phone number. 120 characters is a paragraph in prose and three lines of a table. |
| dormantMinEvents | number | SV_DORMANT_MIN_EVENTS | Events needed before "this rule never fired" means anything. On forty requests a zero-hit rule has not had the chance. Calling it dormant there teaches the reader to ignore the label, and then the genuinely dead rule goes unread with the rest — which is the failure the dormant badge exists to prevent. |
| revealedEventIds | ReadonlySet<string> (two-way) | new Set() | Event ids the reader has explicitly asked to see in the clear. A `model()`, NOT A PRIVATE SIGNAL — and `check:collection-identity` caught it as one. Keyed state a host cannot reach is state that cannot be restored after a route change, and on *this* surface it is worse than inconvenient: a host logging reveals through `(revealRequested)` needs to be able to put the same set back, or its own audit trail and what the panel shows disagree after every navigation. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| retry | void | Emitted when the user presses the error panel's "Try again" button (retryable errors only); the host should re-run the failed load. |
| ruleToggle | SvRuleToggle | The reader asked to enable or disable a rule. CARRIES THE STATE THE READER SAW. Disabling a guardrail is a privileged, logged act, and a payload of just the rule says which rule was clicked without saying what the reader believed they were changing. Two operators disabling the same rule during an incident is the ordinary case: the second toggle is a no-op, not a second change, and an audit log that records it as one describes an event that never happened. `svRuleToggleIsStale()` is the comparison. **Breaking:** the payload was `PolicyRule`. Migration is `$event` → `$event.rule`. |
| revealRequested | GuardrailEvent | The reader asked to see one event's original text. Log it. |
| eventSelect | GuardrailEvent | The reader activated an event. |
| selectedEventIdChange | string | null | Emits the new `selectedEventId` whenever it changes — the output half of `[(selectedEventId)]`. |
| revealedEventIdsChange | ReadonlySet<string> | Emits the new `revealedEventIds` whenever it changes — the output half of `[(revealedEventIds)]`. |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.
Template
ProBound to the names this component actually exposes.
Host class
ProDrive the three async inputs from your own fetch.
- This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `events`, `loading` and `error`.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- `[revealScope]` decides who sees the original text behind a redaction: `on-request` (the default — the role allows it and the reader asks for that one event), `all` (a role whose job is reading raw matches all day, because a reviewer told to check two hundred matches cannot click two hundred times) or `none`. The feed masks **by span**, using the ranges the policy itself found, so it is offset-exact — which is why it does not hit the objection that keeps the pack-wide pattern policy (`X-2`) out of this component. Every reveal fires `(revealRequested)` so the host can log who looked at what; hiding it again does not, because nobody needs an audit trail of somebody closing a panel. The redaction preview's **As generated** view is the same reveal under the same scope: under `none` the control is absent and the preview stays on the delivered text; under `on-request` showing the original adds the event to the shared `[(revealedEventIds)]` and fires the same `(revealRequested)`, so one audit log covers both surfaces; under `all` it is a plain view switch. The preview opens masked in every scope, including for an event already revealed in the feed.
- `[editable]` false shows every rule and its state with no controls — a guardrail toggle is an authorization decision and the component does not know the role, and a policy nobody can see is a policy nobody revisits. The handler refuses as well as the control disappearing, so a host calling it directly cannot change a policy either.
- `(ruleToggle)` carries `{ rule, enabled, observedEnabled, at }`. Two operators disabling the same rule during an incident is the ordinary case: the second toggle is a **no-op, not a second change**, and an audit log that records it as one describes an event that never happened. `svRuleToggleIsStale(toggle, current)` is the comparison. `[pendingRuleIds]` disables a rule while its change is in flight, so one click cannot fire the same privileged change twice. **Breaking:** the payload used to be the bare `PolicyRule` — migrate `$event` to `$event.rule`.
- `[policyVersion]` and `[policyHistory]` (newest first, in the host’s own order — parsing '2 Sept' and 'yesterday' to sort would invent an ordering) render the version and the last change with its author, because 'who turned this off, and when' is the first question asked after an incident.
- The pack-wide privacy policy (`X-2`) deliberately does NOT apply to this panel. Every span here is a pair of offsets into the event text, so masking that text first would shift every offset after it and the highlighting would colour the wrong characters; and this is the surface where an operator reads what their own redaction policy did, so a second invisible layer would hide the over-redaction evidence the panel exists to show.
- `rules` and `events` are inputs and both default to empty; import `DEMO_RULES` / `DEMO_EVENTS` for a demo.
- Three layouts via `variant`: `rules` (the policy in review order), `feed` (what it saw, newest first) and `redaction` (one event before and after).
- Overlapping spans are merged before anything is masked. Two rules matching the same address produce overlapping ranges, and splicing them one at a time shifts every offset after the first replacement.
- A merged run keeps the LOWEST confidence and the STRONGEST action of its contributors: a run containing a guess is a guess, and a `block` overlapping a `redact` is a block.
- `[overRedactionPct]` (30) and `[destructiveRunChars]` (120) each independently flag a redaction that probably destroyed the answer — they catch different failures, one scattered and one contiguous.
- `[dormantMinEvents]` (200) is the traffic needed before "this rule never fired" means anything. Disabled rules are never called dormant: they cost nothing.
- All four thresholds are inputs defaulting to the exported `SV_LOW_CONFIDENCE_MATCH`, `SV_OVER_REDACTION_PCT`, `SV_DESTRUCTIVE_RUN_CHARS` and `SV_DORMANT_MIN_EVENTS`, which are still exported for direct callers of `svRuleStats` / `svRedactionImpact` / `svIsUncertainMatch` — each of those now takes the thresholds as an optional last argument. `thresholds()` reports what the component settled on, and `svResolveGuardrailThresholds` is exported so a host can compute the same answer.
- Emits `ruleToggle` and `eventSelect`; `selectedEventId` is two-way, so a feed row moves the preview.
Best practices
- Do: Read the redaction preview. "Redaction applied" reads as a success on every dashboard, and it is also what a policy that ate the whole answer reports.
- Do: Give classifier matches a `confidence`. A regex either matched or it did not; a model guessed, and the two must not be silently redacted alike.
- Don't: Do not sum blocks and redactions into one "violations" number. Three refusals and a hundred masked phone numbers are not a hundred and three of anything.
- Do: Set the four thresholds to what they mean in your domain. 0.7 confidence suits a mixed matcher, 30% assumes an answer a few hundred characters long, 120 characters is a paragraph in prose and three lines of a table, and 200 events is an afternoon for one product and a quarter for another.
- Don't: Don't pass `[lowConfidenceMatch]` as a percentage. 70 is not 0.7: it would make every match a guess, every answer suspect, and a reviewer who stops reading the flag by the end of the day. Out-of-range figures fall back to the documented default rather than being clamped into range, because a 70 clamped to 1 behaves just as wrongly with nothing to show why.
- Tip: Rules that never fire are listed first, not last. Each one costs latency on every request, and volume ordering puts them below the fold forever.
Accessibility
- Each rule row states its action, hits and the dormancy caveat in its accessible name, not only in a badge.
- Blocked and redacted are given visibly different colours as well as different words, because one of them is a user who got nothing.
- The redaction toggle reports `aria-pressed`; masked runs carry a title naming the rule and its confidence.
Feedback on Guardrail Panel
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.