AI

Guardrail Trace

The safety evaluation of one request, rule by rule — a verdict header derived from the rules rather than asserted beside them, matched-span excerpts with per-rule rationale, verdict filter chips, a passing-row budget that can never hide a block, and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Ban,
  Check,
  ChevronRight,
  Eye,
  RefreshCcw,
  ShieldAlert,
  ShieldCheck,
  ShieldOff,
  ShieldX,
  TriangleAlert,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/guardrail-trace.json

Prompt

Build a React + TypeScript + Tailwind "GuardrailTrace" component with zod and
lucide-react, reusing the shadcn Button and Badge primitives.

Contract
- A zod schema (`guardrailRuleSchema` in a sibling contract file) is the single
  source of truth for ONE evaluated rule: { id, name, category, verdict, stage?:
  "input"|"output", matchedSpan?: string, rationale?: string, score?: number,
  threshold?: number, durationMs?: number, policy?: { label, href? } }.
- The verdict union is THREE values — "pass" | "flag" | "block". "flag" is not a
  rounding error: a rule can fire, be recorded, colour the trace and still not
  stop anything, and collapsing it into pass/block produces both classic support
  tickets ("why was this blocked?" for a flag, "nothing fired" for a monitored
  rule).
- Everything except id / name / category / verdict is optional. A keyword
  denylist has no score, a routing check has no excerpt, a passing rule usually
  has no rationale — the row must degrade to one scannable line, never to a grid
  of em dashes.
- The envelope adds: status ("loading" | "empty" | "error" | "ready"), rules[],
  subject? { label?, requestId?, policyVersion? } and an optional engine-recorded
  `verdict`. The envelope status is the PANEL's state, not a rule's: "error"
  means the trace failed to load, "empty" means nothing was evaluated.
- Props: status; rules; subject?; verdict?; defaultOpenVerdicts? (["block"]);
  defaultFilters? ([]); onFiltersChange?(verdicts); order? ("trace" |
  "severity"); groupByStage? (true); excerptChars? (120); maxPassingRows? (6);
  maskExcerpts? (false); formatDuration?; emptyState?; errorMessage?; onRetry?;
  label? ("Guardrail trace"); className plus the rest of the element props, ref
  forwarded to the root <section>.
- Numeric props are clamped (excerptChars >= 24, maxPassingRows >= 1, Infinity
  allowed to disable the budget) so a 0 or a NaN can never blank the panel.

Behavior
- THE HEADER VERDICT IS DERIVED, never asserted. Fold the rules with
  block beats flag beats pass and render that. If the caller also supplies the
  engine's recorded `verdict`, show the recorded one AND print the derived one
  next to it whenever they disagree: a rule that says "block" inside a request
  that was allowed is a rule running in monitor mode, and hiding either number
  turns a five-second read into an afternoon.
- Headline copy follows the counts and never lies: "Blocked by 2 rules",
  "Allowed with 3 flags", "All 9 checks passed", and a plain "Allowed" for the
  monitor-mode case where the recorded decision is a pass but rules did fire.
- Filter chips are aria-pressed toggles inside a role="group", one per verdict,
  each carrying its count computed from ALL rules (never from the filtered set,
  or the counts move as you press them). An empty selection means "show
  everything"; chips with a count of zero stay pressable.
- FILTERING TO NOTHING IS NOT THE EMPTY STATE. Rules ran; this view is hiding
  them. Render a distinct "no check matches this filter" line with a Clear
  button — offering the reader a reload for a problem a button solves is how
  people conclude the trace is broken.
- Rows keep evaluation order by default (it is a trace); order="severity"
  re-sorts worst-first with a stable sort, so equal verdicts keep the order the
  engine ran them in.
- Group rows under "Input checks" / "Output checks" when any rule reports a
  stage; rules without a stage fall into a trailing "Other checks" group, and if
  NO rule has a stage the list renders flat with no headings at all. A block on
  the input side means the model never ran — do not fabricate output rows for a
  round trip that did not happen.
- The passing-row budget only ever eats PASSING rows. Draw at most
  maxPassingRows of them, then an explicit "Show 3 more passing checks" toggle
  that names how many and what kind. Blocked and flagged rows are never
  collapsed: a block hiding behind a "show more" is the one failure mode this
  panel cannot have.
- A row expands only if it has something to show (rationale, matched span or
  policy ref). Rows without detail render as a plain div with no chevron — a
  disclosure button whose panel is empty teaches the reader to stop clicking.
- Disclosure state is an OVERRIDE MAP, not a set of open ids: a row is open when
  `override[key] ?? defaultOpenVerdicts.includes(rule.verdict)`. That is what
  makes a rule which later flips to "block", or arrives late in a streaming
  trace, open itself — while a row the reader explicitly closed stays closed.
  "Expand all" writes the override for every visible expandable row.
- Rows are keyed by OCCURRENCE, not by id. The same detector legitimately
  reports twice (once on input, once on output) under one id; keying by id alone
  would make the two rows open and close together.
- Excerpts are clipped to excerptChars on the row with an ellipsis, and the
  panel prints the full text plus the exact character count. Nothing is ever cut
  silently.
- maskExcerpts withholds every excerpt BEFORE RENDER: the row says "Excerpt
  withheld · 62 characters" and the plaintext is built only after the reader
  presses Reveal inside the panel. A blur or a covering box would leave the card
  number in the DOM, in the copy buffer and in the accessibility tree. Reveal is
  per row and never survives into a different request.
- Trace identity is the REQUEST (`subject.requestId`), not the rules array. A
  streaming trace appends rules as the engine works; keying the reset on the
  array would slam every expanded row shut on each arrival. With no requestId,
  fall back to a FINGERPRINT of the rules (id + verdict + matched span), never to
  the first rule's id: every trace from one policy opens with the same rule, so
  that key would hand two different requests the same identity and carry a
  revealed excerpt from one into the next. The fingerprint costs a requestId-less
  streaming trace its expanded rows as rules land, which is the right way round.
  When identity does change, reset overrides, reveals, filters and the budget
  toggle by adjusting state during render — no effect, so there is never a frame
  where the previous request's revealed excerpt is on screen.
- The panel owns NO CLOCK and no timers: durations are data, the total is their
  sum, and a re-render, a replay or a server render all produce the same string.
- The error branch says out loud that a trace which failed to load is not
  evidence of a safe request, and the empty branch says that nothing being
  evaluated is not everything passing. Both mistakes are made constantly.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-muted for meter tracks and group headings, and one tone per verdict as a
  CSS variable — var(--muted-foreground) for pass, var(--chart-4) for flag,
  var(--destructive) for block. A passing check is context, not news, so it is
  deliberately the quietest thing on screen. Category dots pick a var(--chart-1
  ..5) from a hash of the category id, so an id the caller invented today still
  gets a stable tint.
- Three verdicts are three SHAPES — ban / triangle / check on rows, and
  shield-x / shield-alert / shield-check in the header — each also spelled out in
  a lowercase mono label, so the panel survives a monochrome theme and a
  colour-blind reader.
- The score meter draws the fill and a one-pixel tick at the threshold, because
  "0.94" is a number and "0.94 past a 0.60 gate" is an explanation. The bar is
  aria-hidden; the figures beside it carry sr-only words ("score", "against a
  threshold of") so they are not read as a fraction.
- The root is a <section aria-label> holding ONE persistent sr-only role=status
  line ("Blocked by 1 rule. 1 blocked, 1 flagged, 4 passed."), mounted for the
  whole ready branch so a verdict that changes is announced from an element the
  screen reader already watches. The count line under the chips is its own
  role=status, so filtering reports its effect.
- Disclosure is a real <button aria-expanded> whose panel is UNMOUNTED when
  closed (a zero-height collapse leaves every link inside reachable by Tab), and
  aria-controls is set only while open so it never points at a missing id.
- Long rule names, category ids and excerpts use `wrap-anywhere` plus `min-w-0`,
  not `break-words`: only the former lowers min-content width, which is what
  actually stops an 84-character rule name from widening the panel.
- The only animations are the chevron rotation and the loading pulse, both
  motion-reduce guarded; nothing about the trace depends on them.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Reading posture: `defaultOpenVerdicts` decides what opens itself (["block"] for
  a review queue, ["block","flag"] for a policy author, [] for a compact
  transcript sidebar); `order` switches between evaluation order and worst-first;
  `groupByStage` turns the input/output headings off for engines with one rail.
- Density: `maxPassingRows` is the noise budget (6 by default, 1 for a sidebar,
  Infinity for an export view), `excerptChars` decides how much of a matched
  span survives on the row before the panel takes over.
- Disclosure of content: `maskExcerpts` withholds every excerpt behind a Reveal —
  turn it on for surfaces where reviewers should not read cardholder data or a
  self-harm passage to do their job. It is a render-time decision, not CSS.
- Filters: `defaultFilters` pre-selects chips (["block","flag"] for a triage
  queue), `onFiltersChange` lets you persist the selection into the URL.
- Units and copy: `formatDuration` swaps the wording (a token count, a cost),
  `label` names the region, `subject` owns the identity line, `emptyState`
  replaces the empty body, `errorMessage` + `onRetry` own the envelope failure.
- Tones: swap var(--chart-4) for the flag tone if your palette already reserves
  a colour for warnings; keep pass on var(--muted-foreground) — the whole
  hierarchy depends on passing rules being quiet.

Concepts

  • Derived verdict, printed disagreement — the header is a fold over the rules (block beats flag beats pass), so the summary can never drift from the rows underneath it. When the engine also reports its own decision and the two differ, both are shown: that gap is a rule running in monitor mode, and it is the single most confusing thing a safety log can hide.
  • Filtered-empty is not the empty state — hiding rows with a chip and having no rules at all look identical if you reuse one branch. They get different copy and different exits: a Clear button for the filter, a plain statement of fact for a trace where nothing ran (which is also not "everything passed").
  • The budget only eats passing rows — noise control is worth having, but a cap that could push a block under a "show more" control would be worse than no cap. Passing checks collapse behind a control that names how many and what kind; anything that fired stays on screen unconditionally.
  • Open state is an override, not a set — a row is open when the reader's explicit toggle says so, otherwise when its verdict is in defaultOpenVerdicts. A rule that flips to block mid-stream, or arrives after the reader started reading, therefore opens itself, while a row that was deliberately closed stays closed.
  • Withheld before render, not blurred — with maskExcerpts the matched text is never built into the DOM until a reviewer asks for it, so it is absent from the markup, the copy buffer and the accessibility tree. A blur, a covering box or -webkit-text-security leaves the card number in all three, one "view source" away.
  • Identity is the request, not the array — traces stream, and resetting on the rules array would close every expanded row each time a rule lands. Keying on subject.requestId keeps the reading stable while the evaluation is still running, and guarantees that a new request never inherits the previous one's revealed excerpts. Give it one: without it the panel has to fingerprint the rules instead, which is safe — any difference in what is on screen counts as a new trace — but a streaming trace then closes its rows as rules arrive. Falling back to the first rule's id would be the worst of both, since every trace from one policy starts with the same rule.

On This Page