Skip to content

AI Warden

AI Warden is an AI API relay/proxy. Customers register their provider keys, get a Logrite relay endpoint in their provider’s native format (a drop-in base-URL swap), and all AI traffic flows through Logrite. Each request is ALLOWed or BLOCKed by deterministic business rules plus an LLM judge — the payload is never modified — and every request/response is captured as a redacted, per-tenant audit record.

Shipped together with Compliance-in-Relay, which scans the same relay traffic in-line for PHI/PII/PCI/credentials with three outcomes: ALLOW / ALLOW+NOTIFY / BLOCK.

Two planes:

  • Control plane — Spring Boot backend (logwrite-api, port 8080). Owns JWT auth, JPA/Postgres (keys, rules, bindings), and per-workspace OpenSearch queries. JWT tokens carry a workspaceId claim (set at login) so every request is scoped to the caller’s tenant without an extra lookup.
  • Data plane — a new Express entrypoint co-located in logrite-ai-service on port 9600, its own container and compose service behind warden.logrite.ai. Scales and fails independently of the chat agent.
Customer SDK ──(base-URL swap, Authorization: lgw_live_…)──▶ Relay :9600 /v1/:provider/*
extractRelayKey → resolveBinding (LRU + internal resolve, returns bound rules + workspaceId + relayKeyId + projectId)
→ preEvaluate(business rules) → evaluateCompliance(request)
→ judge (inline, forced emit_verdict) → compose(most-restrictive)
→ BLOCK (provider-shaped error) OR forward (inject real key, stream raw bytes back)
→ async capture (warden-requests-{workspaceId}) + compliance events (relay-compliance-events-{workspaceId})

Blocking is pre-flight only — once provider bytes stream, the relay is pure transport (a mid-stream compliance hit becomes a post-hoc NOTIFY, never a block). WARDEN_FAIL_OPEN guarantees a Logrite outage never breaks customer prod. It is three-valued since 2026-09-29 — open (default), closed, per_binding — because a boolean could not express “this one binding is the one that must fail closed”; the legacy true/false spellings still resolve.

Rule engine + modes. The internal resolve returns the bound WardenRule rows, so preEvaluate runs the customer’s business rules (content-regex / model + endpoint allowlist / rate-limit / token-budget / PII-secret presence / prompt-injection heuristic) as an active gate alongside the compliance scan. The three judge modes are wired: inline_blocking (awaits the judge up to WARDEN_JUDGE_TIMEOUT_MS, honouring fail-mode), sampled (sampleRate slider in UI, 1–100%; only that fraction of requests are judged), and async_observe — true fire-and-forget via a bounded in-memory queue (WARDEN_ASYNC_QUEUE_SIZE=500; evicts oldest on full). The judge retries on 429 with exponential backoff [1s, 2s, 4s] and skips cleanly if disabled (WARDEN_JUDGE_ENABLED=false) or no Groq key is present.

Async BLOCK verdict patching. In async_observe mode, if the out-of-band judge returns BLOCK after the request has already been forwarded, the relay calls updateCaptureVerdict() to patch the existing capture doc in warden-requests-{workspaceId} with verdict=BLOCK, judgeAsyncVerdict=BLOCK, judgeAsyncAt timestamp — so the Overview tab always reflects the final determined verdict, not just the forwarded verdict.

Response compliance scan. After the provider response comes back, the assistant completion text is scanned against the bound compliance frameworks. response.violationCount is recorded in the capture doc alongside the request-side scan results.

⚠️ “Never blocks a streamed response” was printed on responses that never streamed. forward() requests responseType: 'stream' and pipes upstream→client for both modes, so a stream: false call — one JSON object the caller waits for in full — also lost its bytes before the scan ran. Not because they could not be held, but because of the order the relay did things in; the body was already being buffered, to be scanned, and then released anyway. The recorded reason told the operator it was physics. A stream genuinely cannot be recalled. A buffered answer was simply not withheld, and those are different facts about their protection.

WARDEN_BLOCK_BUFFERED_RESPONSE makes it real: a non-streamed response is held, scanned, and refused with 403 warden_response_blocked if the scan finds strong evidence — none of the flagged bytes reach the caller. A stream is excluded absolutely (an SSE response withheld until complete is not a slow stream, it is a broken one), and WARDEN_WITHHOLD_MAX_KB (1024) bounds the hold: past it the body is released as it arrives and that is recorded, never reported as a decision to allow.

It is OFF by default, deliberately. Turning a provider 200 into a 403 changes what a customer’s application sees; that is an operator’s decision, not something a relay upgrade does to live traffic overnight. What ships unconditionally is the honest reason, and there are four: streamed (cannot be recalled), released (could have been withheld — names the switch), refused (it was withheld), and truncated (outgrew the cap mid-flight).

Safe defaults when no policy applies. The binding resolve always emits mode=async_observe, failMode=fail_open, rules=[] when no ai_traffic business-rules policy reaches the key (before 2026-10-15 this was the ruleSetId=null case of WardenService.resolveBinding). The relay bindingResolver.ts normalizes these fields client-side as a second defense layer. The relay ships with a 20-test jest suite (leak-pattern guards, evaluateCompliance, snippet redaction, the 9-cell business×compliance compose matrix, and the rule engine).

ServicePortNotes
AI Warden relay9600Container built from logrite-ai-service (Dockerfile.warden), warden.logrite.ai / dev-warden.logrite.ai
Backend control plane8080/api/v1/warden/*, /internal/warden/*

Since 2026-10-14 every route here also accepts a Logrite API key (lgp_live_) except relay-key /reveal; writes evaluate the key minter’s role floor. See API keys, MCP and the 13 October test report.

MethodPathPurpose
GET/providersSupported provider profiles (openai, azure_openai, groq, anthropic, mistral)
POST/GET/DELETE/credentials (+/{id}/test)Provider credentials (AES-256-GCM envelope-encrypted; last4 only at rest)
POST/GET/relay-keys (+/{id}/rotate, /{id}/reveal, /{id} PATCH/DELETE)Mint a relay key — plaintext returned once (re-fetchable via /{id}/reveal); SHA-256 hash + last4 stored. Mint accepts an optional { name } (human-readable label; the UI pre-fills a friendly auto-name). PATCH accepts { name } and { status }. Since 2026-10-15 keys carry no ruleSetId/ruleSetIds/complianceProfileId (an unknown field on mint is ignored); the key view lists read-only policyIds (the business-rules policies on AI traffic that reach this key), and the mint and rotate answers carry the same policyIds (backend b8c5b1e6; they were null before).
—/rulesets[/{id}]Removed 2026-10-15. Business rules are policies of kind business_rules under /api/v1/policies — see Rules & Policies.
GET/requests, /requests/{id}, /statsTenant-scoped OpenSearch capture + aggregations
GET/compliance-events, /compliance-events/statsCompliance events from relay-compliance-events-<ws> (2026-10-14) — see below
DELETE/violations/suppressions/{id}Revoke a false-positive suppression (2026-10-14); listed and revoked from Rules & Policies → Exceptions
POST/relay-keys/{id}/testSend one test message through the relay with that key (2026-10-14)
MethodPathAuth
GET/internal/warden/bindings/resolve?relayKeyHash=…X-Warden-Internal-Token — returns the decrypted provider key + the merged bundle of the effective ai_traffic policies (rule ids p<policyId>.<ruleKey>, evaluateFrameworks)
MethodPathPurpose
ANY/v1/:provider/*Hot-path proxy; unknown :provider → 400

Compliance-in-Relay (/api/v1/relay/compliance) — removed

Section titled “Compliance-in-Relay (/api/v1/relay/compliance) — removed”

Removed 2026-10-15, together with warden_compliance_profile and the resolver’s legacy compliance fallback. Frameworks are framework items on a business-rules policy (or turned on from Rules & Policies → Frameworks). relay_compliance_binding is still read, only to resolve a request’s workspace and project. Violations (the relay-compliance-events-* index) are read through GET /api/v1/warden/compliance-events (which MCP’s warden_relay list_events and compliance_logs read).

Every control-plane API call extracts the workspaceId from the JWT (extractWorkspaceId(token) → claims.workspaceId), or, for an API key, from the verified key (ApiKeyWorkspace.of, 2026-10-14). The claim is written at login time from the user’s primary workspace. Pre-2026-06-09 tokens without the claim used to fall back to the configured client.clientId (default logrite); since the July 2026 audit requireJwtWorkspace rejects them instead, and the logrite tenant itself is retired (below).

  • relay_compliance_binding has a tenant_id column (migration 2026-warden-tenancy.sql). All read/write queries now require tenantId — cross-tenant enumeration of bindings via guessable projectId (bigserial) is no longer possible.
  • OpenSearch indexes are already per-tenant (warden-requests-{workspaceId}, relay-compliance-events-{workspaceId}). The workspace extracted from the JWT is the index discriminator — not a static config value.

The legacy logrite tenant is retired (2026-10-16)

Section titled “The legacy logrite tenant is retired (2026-10-16)”

logrite was never a workspace — it was the fallback tenant for claim-less tokens — but rows created under it outlived the fallback. Dev held 47 active relay keys with a non-numeric tenant (prod: 0). None could forward anything: 39 failed to resolve (their credentials were sealed under a master key dev no longer runs, so the relay answered 503 warden_unavailable) and 8 had no credential at all; 120 days of warden-requests-* showed no traffic from any of them.

  • Relay keys revoked. The run-once migrator WardenLegacyTenantRetireMigrator (marker warden-legacy-tenant-retire-v1) revokes every active relay key whose tenant_id is not numeric and writes a governance audit warden_relay_key_revoked with reason legacy_tenant_retired for each. It deletes nothing. Boot log: [legacy-tenant-retire] revoked=N (dev 47, prod 0). Within about 60 s (the relay’s binding cache) such a key gets 401 warden_unauthorized.
  • Rule sets archived, then dropped. The 11 logrite rule sets (47 relay-key links) were skipped by the policy migration by design. They, and the full legacy tables of both estates, were exported (CSV + pg_dump) to a mode-700 archive on the dev VM, after which logrite.policy.drop-legacy-tables=true was set on dev and prod — see Rules & Policies.

Postgres (ddl-auto + migrations/2026-warden.sql + migrations/2026-warden-tenancy.sql + migrations/2026-warden-relay-key-name.sql): warden_provider_credential, warden_relay_key, relay_compliance_binding. Business rules live in the shared policy tables (policy, policy_rule, policy_assignment, policy_version) — see Rules & Policies.

  • warden_relay_key.name (2026-warden-relay-key-name.sql) — nullable, free-form (non-unique) human-readable label for a relay key. The UI pre-fills a friendly auto-generated name at mint and supports inline rename via PATCH /relay-keys/{id} { name }. Legacy keys keep name = NULL and display ••••last4.

Legacy tables. warden_rule_set, warden_rule, warden_nl_judge_policy, warden_relay_key_ruleset, agent_rule_set and warden_compliance_profile were migrated into policies by PolicyUnifyMigrator (rule-set ids kept as policy ids) and are dropped when logrite.policy.drop-legacy-tables=true (env LOGRITE_POLICY_DROP_LEGACY_TABLES) — set on both estates on 2026-10-16. The earlier WardenRuleSetUnifyMigrator and WardenRuleSetAppliesToMigrator are deleted.

OpenSearch: warden-requests-{workspaceId} (per-tenant capture, _id=requestId), relay-compliance-events-{workspaceId} (redactedSnippet is text with index:false), and agent-flags-{workspaceId} (per-agent policy flags, 90d retention). All three get ISM retention policies. Create them with opensearch/scripts/create-warden-indexes.js before the first capture. ⚠ relay-compliance-events-* had no in-repo index template until 2026-10-14 — dev’s follow a hand-installed one, prod’s are dynamically mapped; see the compliance-events mapping.

The relay’s hot path is strictly sequential — resolve the binding → deterministic rules → request compliance scan → judge → forward upstream → response scan → capture — and every one of those boundaries already existed as a local in handleRelay. All of them were discarded: the capture kept totalMs and judgeMs, so an operator looking at a slow request could see that it was slow and nothing about where. forward() has always returned upstreamMs and nothing ever read it.

latency.stages is a flat map of integer durations — preflightMs, rulesMs, requestScanMs, judgeMs, upstreamMs, responseScanMs, plus capturedAtMs as a closing offset. Flat because every value stays aggregatable (avg(latency.stages.upstreamMs) across a workspace is the obvious next question, and getStats already aggregates latency.totalMs); an array of objects is not.

A stage that did not run is ABSENT, never zero. A blocked request never forwards, so it has no upstreamMs; a key with rulesEnabled: false never evaluates rules. A 0 would say the stage ran and took no time. It also makes exists: latency.stages.upstreamMs a real question: did this request reach a provider at all? A stage that threw is still timed, because it still spent the time.

capturedAtMs closes the sum, and the difference between it and the stages is relay time nothing measured — the UI draws that as its own “Relay (unattributed)” segment rather than letting a stage absorb it.

No judge, no judge stage (2026-10-16). The relay used to start the inline judge for every inline binding and record judge.outcome: "decided" plus a ~0 ms judgeMs even when the binding had nothing to judge — no plain-English policy, no framework, no legacy policyText — in which case the judge returns without calling a model. It now asks the judge only when buildJudgePolicies(...) is non-empty, so a deterministic-only binding is captured with judge.outcome: "not_requested", judge.ran: false and no latency.stages.judgeMs. No LLM cost was involved either way; the label was wrong. decided now always means a judge ran.

⚠ ~14,000 already-stored records carry no stages, so degrading is the common path, not the edge. The detail modal falls back to two bars — AI judge and Relay + upstream (not itemised) — with a dated note saying why. It deliberately does not treat latency.upstreamMs as evidence of staging: that field is mapped and written only by the demo seeder.

Every captured request now includes: workspaceId, relayKeyId (the WardenRelayKey.id), projectId (from the bound relay_compliance_binding, if any), client.ip, client.userAgent, stream (boolean), request.messageCount, and full response.usage (prompt/completion/total tokens from the upstream response). It also stores the decision review (review.business / review.compliance / review.final — the per-gate decisions that composed the verdict) and the AI judgement reasoning (judge.reasoning, the LLM’s plain-English explanation) so the Overview detail modal can show the full “why” without a second lookup. verdict is ALLOW / BLOCK / NOTIFY (a forwarded-but-flagged request is captured as NOTIFY).

Verdict filter (Overview). WardenQueryService.getRequests filters on the effective verdict, not just the raw verdict term, so the Overview pills match exactly what the table renders: NOTIFY → verdict==NOTIFY OR compliance.notify==true OR response.violationCount>0; ALLOW → verdict==ALLOW AND neither of those; BLOCK → verdict==BLOCK. This keeps legacy rows that were stored as ALLOW before the relay tagged NOTIFY directly (and any async-flagged rows) from leaking into the wrong filter.

Raw payload capture (WARDEN_CAPTURE_RAW, default on_flagged since 2026-09-29). ⚠️ The default changed. It was true — every clean ALLOW retained the full prompt and completion, which is a data-protection posture nobody chose and which stores the very PII the warden exists to flag. It is now three-valued: off (redacted previews only), on_flagged (default — retain raw only when the verdict is not ALLOW, i.e. exactly the requests someone will open), always (the old behaviour; WARDEN_CAPTURE_RAW=true still selects it). When raw capture applies, each capture doc also stores the full raw request and response — request.rawBody (the upstream JSON body, or the prompt text if it isn’t valid JSON) and response.rawBody (the upstream completion) on warden-requests-*, plus flat rawRequest / rawResponse on the matching relay-compliance-events-* events (request-side at the pre-flight scan, request and response at the post-response scan). Both are size-capped to 25 KB (…[truncated] suffix beyond that). This is what the UI detail modals surface. ⚠️ With raw capture on, payloads — including the PII/secrets the warden itself flags — are written to OpenSearch; set WARDEN_CAPTURE_RAW=off to fall back to the ≤200-char redacted previews only (false is still honoured).

Tool-call capture (always on). A request whose conversation involves tools also carries tools — tools.declared, tools.names, tools.callCount, tools.resultCount, tools.responseScope and tools.records. Unlike the raw bodies above these are taken from the parsed body, so they do not depend on WARDEN_CAPTURE_RAW and are not affected by the 25 KB cap; the arguments, results and conversation turns they carry are redacted and clipped to WARDEN_TOOL_ARG_CHARS / WARDEN_TOOL_RESULT_CHARS / WARDEN_TOOL_MESSAGE_CHARS (default 2000 each), at most WARDEN_TOOL_MAX_RECORDS (default 100) per request, each record keeping the true pre-clip bytes. records holds the whole conversation — message turns interleaved with call and result records, in order — and both the OpenAI and Anthropic wires are read, including streamed responses. The key is absent on a request with no tool activity. Full shape and the responseScope values: The revamped Request detail.

Tool-name filtering (?tool=) and the by_tool facet. tools.names is mapped keyword specifically so it can be faceted, and for a while no hop supported it: the field was missing from WardenRequestsQuery.FIELDS, from WardenQueryClauses, from the UI filter state, and from getStats. GET /warden/requests?tool=<name> now filters on it and /warden/stats returns byTool beside byProvider.

Per-key filtering (?relayKeyId=), since backend 24959075. Every capture doc carries relayKeyId, but nothing filtered on it: the field was not in WardenRequestsQuery.FIELDS, and that allow-list drops unknown parameters silently, so ?relayKeyId=215 returned every key’s traffic with a 200. It is now a term filter, and anything other than a positive id is a 400 naming relayKeyId. MCP warden_relay list_requests forwards relayKeyId (or keyId) to it.

Two traps are worth knowing, because both fail as something other than “the filter doesn’t work”:

  • The binder allowlist is silent. A parameter absent from WardenRequestsQuery.FIELDS is dropped with no error, so a filter wired through the service but not named there looks implemented and does nothing.
  • A nested field cannot be resolved by its dotted name. WardenFieldResolver.parse read only top-level mapping properties, so props.path("tools.names") was a missing node and the field resolved to its bare name unconditionally. On a dynamically-mapped index — which every pre-template index is — a bare terms agg on the text variant fails the whole search with “Fielddata is disabled”, which surfaces as the Overview page dying rather than as one broken facet. It now walks the mapping path (properties.tools.properties.names) and both live shapes are pinned by WardenFieldResolverTest.

The relay scans against the analyzer’s shared leakPatterns module (vendored byte-identical). Frameworks map to relay canonicalNames:

FrameworkRelay rules
HIPAAhipaa_log_leak_phi (critical → block)
SOC2soc2_log_leak_credentials (critical → block)
GDPRgen_log_leak_pii (high → notify) — was previously gdpr_log_leak_* which had no matching relay pattern, so GDPR bindings silently scanned nothing; now fixed to the canonical name the relay actually ships
GENgen_log_leak_pii (high → notify), gen_log_leak_payment (critical → block)
M-26-14m2614_log_management (high → notify) — the OMB logging-posture mandate. ⚠️ This row used to read m2614_pii_in_ai_prompt (critical → block); both that rule and the block are gone. M-26-14 was re-scoped and its own policy text now ends “never blocks a request”. The DLP behaviour moved to AI-Data-Gov below. A judge eval case expecting a block from M-26-14 was testing drift that had already been corrected.
AI-Data-Govaidatagov_prompt_leak (critical → block, request), aidatagov_value_leak (critical → block), aidatagov_response_leak (high → notify, response), aidatagov_model_artifact (high → notify). ⚠️ The backend’s ALLOWED_FRAMEWORKS did not list it until 2026-09-29, so a rule set naming it was refused 400 unknown framework — the PII-in-prompt block had no home a customer could select. Aliases ai-data-governance / ai-dlp are accepted.
OPS~55 operational signature rules (ops_*, all NOTIFY) — app crashes, OOM, auth/brute-force, service & container failures, disk, network/TLS, HTTP, permissions, kernel/hardware, resource exhaustion, database, config — each with a category + severity + remediation. Aimed at collector logs (not privacy). See Collector Agents → OPS ruleset.

⚠️ The table above is the privacy core, not the shipped set. FRAMEWORK_TO_RULES maps 30 frameworks (OWASP-LLM, GLBA, CCPA, CMMC, CJIS, FERPA, COPPA, BIPA, FedRAMP, SOX, EU-AI-Act, NERC-CIP, ISO 27001/27701/42001, NIST AI RMF, DORA, NIS2, Colorado AI, TRAIGA, NYC LL144, US state privacy …). Seven of them currently resolve to no relay-applicable rule — they are selectable and seed the judge, but contribute nothing to the deterministic scan. Read the mapping from the code, not from this table: RELAY_LEAK_PATTERNS + FRAMEWORK_TO_RULES in src/warden/leakPatterns.ts.

Only the frameworks the user selected scan — there is no always-on GEN floor, so an unselected framework never gates. This deterministic scan now runs as a fast pre-filter / fallback. Primary compliance decisioning is the LLM judge seeded by the same frameworks (below). Findings are redacted before any sink: leakedFields are names only; redactedSnippet ≤ 200 chars with field names AND raw value patterns (SSN, cards, emails, secret keys, long digit runs) masked. No raw value leaves the relay.

Regex dialect: a rule that cannot compile is not a rule that does not match

Section titled “Regex dialect: a rule that cannot compile is not a rule that does not match”

⚠️ (?i) is legal in Java and PCRE and a hard Invalid group throw in V8. The rule engine built every content_regex with new RegExp(...) inside a try/catch that returned {hit: false} — so a customer rule written in that dialect did not fail, it silently never matched, for as long as it stayed enabled. Measured 2026-09-29: 30 of the 34 patterns shipped in the starter rule sets and the backend seeder were in that state, and the rule editor’s own placeholder was (?i)password\s*[:=], which is how the syntax spread — it was the worked example every hand-written rule was copied from.

Three things changed, and they are belt, brace and backstop:

LayerWhat it does now
src/warden/regexDialect.tsTranslates the leading inline flags V8 can express (i, m, s) onto the RegExp flags argument. What it cannot translate it reports rather than dropping. An uncompilable pattern is counted and logged by rule id — before this, “cannot compile” and “legitimately did not match” were the same observable state, which is why it survived.
Shipped contentThe 10 starter patterns, the 20 seeder patterns and the editor placeholder are corrected, so nothing depends on a customer’s rule passing through a normaliser. Matching was always case-insensitive; the prefix never bought what it appeared to.
WardenRuleVocabulary.validateRulePattern + JsRegexDialect.javaThe backend validates the rule payload, not just its type, at write time — in the dialect the engine speaks. Java is exactly the wrong language to guess this from: (?i) is valid here, so a validator written in the host language would have passed every pattern the engine chokes on. It sits beside validateFrameworks and is called from WardenRuleSetOps.replaceRules, one line below the KNOWN_RULE_TYPES check whose comment already named the failure mode (“a dead rule”) — the insight had simply never been applied one level down, to the pattern itself.

⚠️ This row described a backend that did not exist until 2026-09-29. The change was written against WardenService, which has since been split into 45 warden classes, and the commit carrying it was never pushed — so for as long as this table has claimed a payload check, the backend shipped none, and scripts/warden-setup.cjs was still seeding 20 patterns with the (?i) prefix. The docs are the single source of truth and were ahead of the code; that is the failure mode worth naming, because a documented guard is one nobody re-checks.

🔴 A rule can be dead BEFORE this fix is reached, and the counters above will not see it. Measured on dev’s Postgres 2026-09-29: of 71 regex rules, 56 store the pattern under config.pattern and 15 under a legacy config.value. ruleEngine.ts reads String(rule.config?.pattern ?? '') and then if (!raw) return { hit: false } — so those 15 exit on the first line, before normalizePattern, and without incrementing the invalid or untranslatable counters this section introduced. They are enabled = true and they list in the UI. That is the precise failure this page exists to end, arriving through the one door the fix did not cover, and it is why the headline number needs restating: “49 of 71” was really “49 of the 56 we were counting”.

Bounded, and the bound is measured. All 15 are ids 1–19 in the internal tenant_id = 'logrite', across five rule sets whose names duplicate live ones (HIPAA Guard, SOC 2 / PCI, AI Safety, Enterprise Observe, M-26-14 AI Gov). All five have zero warden_relay_key_ruleset bindings, so no traffic reaches them, and customer tenants 2903/2905 hold only pattern-keyed sets. Nothing writes the legacy shape today: the UI’s toPayloadRule maps value → config.pattern on save, so these rows predate that translation. Open, not fixed — counting the drop is a small ai-service change, and the rows are data that should be migrated with the care of any rule edit. ⚠ Note the name collision when binding a key by rule-set name: pick the id, not the label.

WARDEN_RULES_DIALECT stages the rollout, because turning this on arms every previously-dormant rule at once: on (default) normalises, shadow reports what a normalised pattern would have matched without acting on it — no 403 reaches anyone — and off is byte-for-byte the old behaviour.

regexDialect.ts is duplicated byte-identically into the analyzer, which runs the same customer rules over log lines and backs the rule dry-run console. That second use is the sharp one: before the port, the tester a customer would use to diagnose why a rule never fired was itself reporting the bug as its answer. npm run check:regexdialect sha256-compares the two copies — a shared normaliser held together by a comment drifts; one held together by a gate does not. npm run check:patterns compiles every pattern the real template and seeder files ship.

Pattern rules at run time: a pattern cannot stall the service (2026-10-23)

Section titled “Pattern rules at run time: a pattern cannot stall the service (2026-10-23)”

JavaScript runs a regular expression on the event loop and cannot interrupt it. Before this change a content_regex rule was compiled again for every request and every log line, with no length limit and no shape check, and tested against the whole text (a relay body may be 25 MB; a collector line had no limit). One pattern such as (a+)+$ would have frozen the relay or the analyzer for every caller.

src/warden/regexGuard.ts now runs every pattern rule. It is byte-identical in ai-service and the analyzer, and npm run check:regexdialect fails when the two copies differ.

GuardRule
ShapeRefused before it is compiled: longer than 1,000 characters, a backreference, a repeated group whose body repeats or alternates (such as (a+)+), or more than 3 open-ended repeats. Same rule the backend applies at save. A refused pattern matches nothing and is logged once by rule id, like a pattern that does not compile.
Compile onceEach pattern and flag pair is compiled once and kept (up to 512).
InputText longer than 4 KB is scanned in 4 KB windows that overlap by 512 characters, up to 64 KB per check. Text beyond 64 KB is not scanned, and the result carries scanTruncated: true. A pattern with ^ is tested on the first window only, and one with $ on the last window only, so an anchor never matches at a window edge.
MeasureA test slower than 50 ms is logged with its rule id (at most once every 10 minutes per rule) and counted.
  • Why 4 KB windows: measured on 2026-10-23, an ordinary email-shaped pattern \w+@\w+\.com takes 22 ms on 4 KB of one unbroken word and 358 ms on 16 KB, because the cost grows with the square of the length. 4 KB windows over 64 KB bound that worst case to about 0.4 s per rule.
  • Why 64 KB: it is the same slice the relay’s compliance scan (scanMaxKb) and the rule dry run (EVALUATE_LINE_MAX) already use, so the tester and the live scan agree.
  • Stored rules are not affected. Every stored pattern on dev and prod passes the shape check; the only one refused was a QA probe.
  • Rollback without a deploy: REGEX_GUARD_MODE=off restores the previous behaviour.
  • Redaction patterns served by the backend or a collector binding go through the same shape check; a refused one is skipped and the other kinds still mask. Field-name masking now allows up to 64 word characters around the name instead of an open-ended run.

The built-in email pattern was quadratic. [\w.+-]+@… ran in the redaction list, the framework catalogues (HIPAA, GDPR, GEN, ISO 27001, ISO 27701), the leak checks and the collector redactor. On one 64 KB word with no spaces it cost 6.5 s per pattern per request. The part before @ is now [\w.+-]{1,64}, which is the longest local part an email address may have, and the same text costs 31 ms. Valid addresses match exactly as before.

A binding scoped to SOC2 was described only by what it covers. A workspace forwarding PII, PCI and PHI unflagged therefore looked identical to one with nothing to find. src/warden/coverage.ts names what is not detected — “Not detected: PII, PCI, PHI … forwarded unflagged” — and distinguishes three states, not two: detected, measured-and-absent, and never looked for. A key bound to no framework reports measured: false rather than a clean bill of health.

⚠️ That module shipped as dead code, and a gap is not closed until a caller can reach the answer. coverage.ts computed the right complement and was imported by nothing but its own unit test — nine cases green, no route, no surface — so the operator whose key cannot see an SSN was told exactly as much as before the fix. It is the same defect as the log-analysis GET /limits route that an earlier use('/') shadowed: correct implementation, unreachable, tests passing either side of it.

It is now GET /internal/coverage?frameworks=soc2,gdpr on the relay, gated by X-Warden-Internal-Token and mounted before /v1/:provider/* — a wildcard registered first shadows everything after it. The response carries covered, notDetected, measured, detectable, a one-sentence warning, and libraryVersion / librarySource so a stale snapshot cannot answer anonymously.

⚠️ Coverage is patterns × framework → rules, and only the first was parameterised. The function took a pattern list so the live library could be measured, then read the bundled framework-to-rules map unconditionally — so a backend snapshot that changed which rules a framework activates was scored against the copy compiled into the image. Both inputs now default to the bundled values and both accept the live snapshot’s; the route passes getSnapshot() for both.

An absent frameworks parameter is a 400, never an answer: ?frameworks= (bound to nothing) is a real finding, while omitting it entirely means the caller never read the binding, and reporting “no framework” there would describe a gap that may not exist.

Verified against the deployed relay on 2026-09-29, not only in tests — no token 403, absent parameter 400, ?frameworks= 200 with measured: false, and ?frameworks=gen,pci,gdpr naming PII and PCI as covered with PHI, auth events, network flow, raw bodies and secrets as not detected.

⚠️ An unknown framework name used to be indistinguishable from one that covers nothing — found by that same live check, and fixed on 2026-09-29. frameworkToRules[fw] ?? [] swallowed a name the library has never heard of, and measured was set purely because the list was non-empty, so ?frameworks=bogus-name answered measured: true with an empty covered. One means “this regime genuinely detects nothing here”; the other means “I do not know that word” — the same collapsing of I looked and found nothing into I could not look that this section exists to prevent.

Unrecognised names now come back in their own unrecognised array, measured is false, and the warning says coverage for them is unknown, not zero. A binding holding both — ?frameworks=soc2, bogus-name — reports each half: soc2’s real gaps and the name that could not be read. Two properties are deliberate. Recognition is checked against the live snapshot’s map, not the bundled one, so a framework the backend has added is recognised and one it has dropped is not. And covered still holds whatever the recognised names cover, which makes notDetected an upper bound on the gaps — the only safe direction when part of a binding is unreadable.

A framework that is recognised and whose rules simply do not run on relay traffic stays measured: true with an empty covered: that is a measurement, and demoting it to “could not look” would be the same collapse pointed the other way.

Response-scope findings were already notify-only, so the strong-evidence check — which only ever spared a request from a hard block — had no effect there. A weak, identifier-only hit on the assistant’s own educational prose (“an API key looks like sk-…”) had nowhere softer to land than NOTIFY, and four of six plainly-benign questions were stored as flags. The Overview count was inflated by the model’s own teaching text, which is how operators learn to stop reading it.

A weak response-scope hit now contributes allow. It is still recorded as a finding and still fans out as a compliance event — it moves from flag to trace, nothing leaves the audit trail.

⚠️ The exemption is the load-bearing part. A pattern that declares no valuePatterns is token-by-design: system_prompt / training_data / model_weights are names with no value shape, so the token is the finding and a match on it cannot be “weak”. The first version of this softened every weak response hit and deleted a detection the product deliberately has — golden-corpus recall fell 100% → 80%. Measured today: 103 of the 106 relay-applicable patterns define a value shape and are softened; exactly three are exempt (aidatagov_prompt_leak, aidatagov_response_leak, m2614_log_management).

The gate is published twice — .action for the promotion regression gate, .strong per finding for the relay’s violation count — and nothing in the type system ties them together. w6Invariant.test.ts asserts the property rather than an example: a scan counts a finding if and only if its action is not allow.

…and the rung reaches the REQUEST scope too (2026-09-29)

Section titled “…and the rung reaches the REQUEST scope too (2026-09-29)”

⚠️ The rung above was gated on scope === 'response', and both surviving false NOTIFYs were request-scope. The retest measured the benign rate improving 4/6 → 2/6 and attributed the remainder to “topical judge calls”. It was not the judge. Live capture wr_04a2e5d8 (2026-09-29T09:05:29Z) records the prompt “What are best practices for rotating credentials on a schedule?” — 63 bytes, no secret in it — with judge.verdict: ALLOW and response.violationCount: 0. The response scan found nothing and the judge allowed it. The NOTIFY came from soc2_log_leak_credentials matching the bare word credentials in the question itself, at the one scope the softening did not reach.

The judge was measured separately and is clean: six security-topical benign prompts under SOC2 (benign-sec-*, now permanent eval cases) all return allow at confidence 1.00.

evaluateCompliance takes requestBody and responseBody through the same loop, so the exemption is unchanged — strong and definesAValue mean exactly what they meant — and only the scope condition is gone. The safety net was measured rather than assumed: with the compliance layer releasing a weak match, the judge returns BLOCK at confidence 1.00 on my password is hunter2, on a live sk-live-… key and on a postgres://user:pass@host connection string.

⚠️ The invariant test only ever evaluated responseBody, which is how this survived a green suite — the property held exactly where it was checked. It now runs the same corpus through both doors.

⚠️ One arm of the promotion regression gate had to be inverted, not deleted: an overbroad variableTokens list is now inert, so the case asserting it over-flags is now asserting it does not. The over-flagging arm is re-proven with a broad valuePattern, which is strong evidence by construction and is still rejected.

A framework that can flag must say what flags it

Section titled “A framework that can flag must say what flags it”

guidanceText is the only thing the AI judge is told about a framework. A framework whose guidanceAction is notify or block is claiming it can flag something — and if its text merely describes the regime, there is no condition to test, so the model’s only honest answer is allow. At confidence 1.00, which also means the ladder never escalates. The framework reads as bound and active on the console while contributing nothing to any verdict.

⚠️ M-26-14 spent 1001 characters on agency obligations and named no condition. The judge returned “no policy violated” on an employee SSN list, at full confidence — while m2614_log_management (relayApplicable, notify) flagged exactly that content. Two layers, opposite answers, and only the silent one was right. It was read as a model regression across three ladder swaps, because the case that exposed it was retired rather than explained.

The fix is the condition, written where the judge reads it, and it is not invented: it is clause (4) of the memo and that rule’s own hint. M-26-14 stays notify-only — the text still ends “it never blocks a request on M-26-14” — and gains a VIOLATED (notify) clause for an actual sensitive value written inline, plus an explicit not violated by a question ABOUT logging, nor by a field NAME with no value behind it. Framework version 1.2.0 → 1.3.0; the golden catalogue diff is 3 lines, all M-26-14, no rule changed.

Measured against the live dev library on the same day: 6 of 30 frameworks stated a condition. The other 24 are all notify-tier. Seventeen have relay-applicable rules, so their deterministic layer works and only their judge layer is inert; seven carry no relay-applicable rule at all (Colorado-AI, DORA, ISO-42001, NIS2, NIST-AI-RMF, NYC-LL144, TRAIGA) — for those the judge is the only possible layer, so today they detect nothing whatsoever on relay traffic.

Rewriting 24 compliance texts is a content review, not a refactor, so GuidanceStatesAConditionTest names them individually rather than inferring them. A new framework must state a condition; a named one that gains a condition must be removed from the list or the build fails. The count can only go down, and it is visible in the source instead of implied by a passing build.

runladderresult
beforedeepseek-v4-flash → qwen3.6-35b → glm-5.2-short97.8% (45/46) — miss: fw-m2614-posture expected notify, got allow at conf 1.00
aftersame100% (46/46), gate PASS, 0 regressions, improved: fw-m2614-posture → notify
with the 6 new benign casessame100% (52/52), benign false-positive rate 0.0%

⚠️ The gap report’s retest read 97.8% (44/45) and named fw-m2614-artifact. That case id no longer exists — it was split the same morning into fw-m2614-posture (notify, the posture framework) and fw-aidatagov-artifact (block, the framework that actually owns the DLP behaviour). The retest ran a stale case set, so the number matched by coincidence and the denominator did not.

The judge escalates on corroborated disagreement

Section titled “The judge escalates on corroborated disagreement”

A unanimous not-violated panel used to end the turn even when the deterministic rule layer and the compliance scanner had both hit. It now escalates — gated on strongEvidence rather than on any hit, so it does not multiply judge spend on exactly the weak matches the rung above is busy demoting.

The gate model. A rule set is ONE gate over all its rule types. Each NL / AI-judge policy, each selected compliance framework, and each (optional) deterministic rule carries its own user-selected action (block / notify / allow). Any rule that fires gates the request by its action; the strictest fired action wins (block > notify > allow). There is no separate per-type gate — the three layers compose into a single verdict.

A rule set (today: a business-rules policy) carries four dimensions: metadata, compliance frameworks, NL judge policies, and deterministic rules. Binding today = every ai_traffic business-rules policy that reaches the key (all keys unless narrowed by relayKeyIds); PolicyBundleBuilder merges them most-restrictive-wins. (Until 2026-10-15 the binding was the join table warden_relay_key_ruleset, since dropped.)

These behaviours keep the gate honest:

  • No always-on GEN floor. Only the frameworks the user actually selected gate a request. An unselected framework never gates — evaluateCompliance is handed frameworks: binding.frameworks ?? [] with no synthesized hygiene baseline. (Earlier builds applied GEN as an always-on floor; that is removed.)
  • Fail-closed escalates only to a configured action. On a judge timeout in fail_closed mode, the gate escalates to strictestConfiguredAction(judgeInput) — the strictest action that actually exists in this set’s policies. A notify-only set escalates to notify, never a hard block; fail_open leaves the verdict at allow.
  • Verdict cache invalidates on policy-text edits. Each policy’s cache fingerprint is id:action:sha256(policyText)[:12], so editing a policy’s wording (not just its action or id) invalidates the cached verdict, independent of whether the backend rotates policy ids on save.
  • Hybrid multi-turn gate — a blocked turn never re-blocks the rest of the conversation. Agentic clients (Cline/Continue/Cursor/…) resend the entire conversation on every request. Blocking on the full joined history meant that once anything sensitive was in the thread, every later turn — including a genuinely clean new question — was re-blocked (the “after one 403 it won’t answer any other prompt” bug). The gate now evaluates the latest user turn as the block-capable input (both the LLM judge and evaluateRequestCompliance), and scans the prior history notify-only: a leak found only in earlier history (already forwarded when it was first sent) is downgraded block → NOTIFY and surfaced in the audit trail, never blocked. A new leak in the latest turn still hard-blocks. Single-turn / completion (prompt/input) callers are unaffected (historyText is empty → identical to the old path). Turn-splitting lives in warden/turnSplit.ts (extractLatestUserText / extractHistoryText).
  • Verdict cache is keyed on the FULL prompt, not the truncated model input. The judge input is bounded to WARDEN_JUDGE_MAX_CHARS (default 12000, was a hard 2048 that both blinded the judge past ~2 KB and — because the same truncated slice fed the cache key — let two distinct requests sharing a long common prefix collide onto one cached verdict). The cache/coalesce key now hashes the full prompt (JudgeInput.cacheKeyBasis), so distinct prompts never share a verdict.
  • Single-flight judge. Concurrent identical evaluations (same rule set + prompt + policies) coalesce onto one Groq round-trip. Without this, N in-flight requests each fired their own non-deterministic Groq call and could cache divergent verdicts, so the same prompt returned 403 on one cluster worker and 200 on another. Combined with the shared Redis L2 verdict cache, workers now converge on a single verdict.

Decision flow on the relay hot path:

  1. LLM judge first. Every enabled NL policy and a synthesized policy per selected framework (HIPAA→PHI, SOC2→credentials, GDPR/GEN→PII/payment, M-26-14→prompt/response governance) are sent in one forced emit_verdicts Groq call. Each policy carries its own action (block/notify/allow); the relay composes the per-policy verdicts most-restrictive-wins. This is why selecting HIPAA enforces PHI handling without writing a rule.
  2. Model routing (escalation + resilience). See the table below — prod runs a 2-tier ladder: gpt-oss-120b is the default/primary, llama-3.3-70b is the low-confidence/error escalation, and gpt-oss-20b is an error-only resilience backup. The judge also escalates to the next tier whenever a tier call errors (a model failure never fails open to ALLOW). (The shipped code default, if no WARDEN_JUDGE_LADDER env is set, is a 3-tier gpt-oss-20b → gpt-oss-120b → llama ladder — that is what the offline eval below was run on.)
  3. Deterministic rules run only when the rule set’s rulesEnabled toggle is on (off by default) — they are an opt-in last-resort fallback, never the primary decision layer. rulesEnabled now gates ONLY the deterministic preEvaluate rules (regex / allowlist / rate-limit / token-budget / PII-secret presence / prompt-injection). The NL / AI-judge policies and the selected frameworks always evaluate, whether or not rulesEnabled is on — const rulesEnabled = binding.rulesEnabled === true; const pre = rulesEnabled ? preEvaluate(...) : <empty>.
  4. Compose most-restrictive across LLM + deterministic + the compliance pre-filter (block > notify > allow). A notify from any layer logs a violation event but never blocks. The LLM judge is the single event source when it produces a verdict (the violation shows scanMode: judge with the LLM’s plain-English reason); the deterministic compliance scan only logs when it is the fallback decider.
RoleModelWhen it runs
Primary / defaultopenai/gpt-oss-120b (WARDEN_JUDGE_LADDER[0])every request — judges all framework + NL policies in one emit_verdicts call
Escalationllama-3.3-70b-versatile (WARDEN_JUDGE_LADDER[1])when a consequential verdict is low-confidence (< WARDEN_JUDGE_CONFIDENCE_THRESHOLD, default 0.6), the model returns no usable verdict, or the primary tier errors; the relay settles on this stronger verdict
Resilience backupopenai/gpt-oss-20b (WARDEN_JUDGE_FALLBACK_MODEL)error-only — runs when a ladder tier call errors and the backup is not itself a ladder tier (true for this 2-tier ladder). It never competes on confidence or overrides a healthy verdict. Disabled when a caller pins a specific judgeModel.

The normal path is a single gpt-oss-120b call (latency/cost flat); escalation to the stronger tier is the exceptional path (low confidence or a tier error). If every LLM tier is exhausted, the deterministic compliance pattern scan is the final safety net (so known PHI/credential patterns are still blocked).

Ladder escalation now also triggers on tier ERRORS. When a cheaper ladder model call throws (e.g. Groq tool_use_failed, model unavailable, a malformed tool response yielding zero usable verdicts), the judge escalates to the next stronger tier instead of failing open — a cheap model choking on a hard or obfuscated prompt must never silently ALLOW the request. Only after every tier errors does the resilience backup (then the deterministic safety net) take over.

Robustness pre-passes. Before judging, deobfuscate() runs a base64 de-obfuscation pre-pass: it decodes readable-text base64 tokens and appends [decoded for inspection: …] so an encoded payload can’t evade the judge (binary/opaque blobs with a low printable ratio are left untouched, so legitimate base64 — images, opaque tokens — is unaffected). The judge system prompt additionally normalizes encoded/obfuscated content (base64 / hex / ROT13 / leetspeak / reversed text), matches paraphrases and topical/indirect references (not just exact keywords), and treats instruction-override / jailbreak / role-play-escape attempts ("ignore previous instructions", "you are now DAN", "developer mode") as violations of any anti-jailbreak policy.

Judge accuracy. ⚠️ Until 2026-09-29 this benchmark always exited 0. It printed a score and a table and run.ts never set an exit code, so wiring it into CI would have produced a green tick over any regression — worse than not running it at all. eval/warden-judge/gate.ts now compares a run against a recorded baseline and exits non-zero. Three of its rules are deliberate: a missing baseline is a failure (not a first run — “no baseline yet” and “the baseline was deleted” are indistinguishable at the file level); a removed case fails the gate (deleting the failing case must not buy a pass); and per-case comparison outranks the accuracy-drop threshold whenever a baseline exists. The live run is scheduled nightly, not per-push — 46 billed model calls is not a push-path check, and pretending otherwise is how it ended up wired nowhere. What runs on every push is the offline proof that the comparison can fail, using the real fw-m2614 regression as its fixture. The labeled judge eval (eval/warden-judge) improved from a baseline of 95.1% (39/41, two misses: a base64-encoded jailbreak that slipped to ALLOW, and a GEN card-number that landed NOTIFY instead of BLOCK — report-baseline.md) to 100.0% (42/42, report-final.md), with the new topical / legal / healthcare / support / jailbreak / framework categories all at 100%.

Verdict parsing note. The model echoes the bracketed policy id from the prompt ([fw:HIPAA]); the relay strips the surrounding brackets before matching it to the policy id (fw:HIPAA). Without this, a correct violated: true verdict would be silently dropped.

NL judge actions and framework verdicts are cached by ruleSetId + prompt hash + a policy fingerprint (id:action:sha256(policyText)[:12] per policy), so editing a policy’s wording, action, or id — or changing the framework set — invalidates stale verdicts.

The same unified rule sets also evaluate collected logs after the fact (post-hoc), not just AI requests pre-flight. A rule set carries an applies_to JSONB tag (["relay"] default / ["logs"] / both); a warden_rule_set whose applies_to contains "logs" applies to all of the workspace’s collector agents, and the new agent_rule_set join attaches a set to a specific agent on top of that. WardenService.resolveLogsBundle(workspaceId, agentId) reuses mergeRuleSetsInto (no relay key, no provider credential) and forces complianceNotifyOnly=true; it is exposed to the analyzer via the internal, token-guarded GET /internal/warden/logs/resolve?workspaceId&agentId. The per-agent attach/detach endpoints (/api/v1/warden/agents/{id}/rulesets) enforce agent ownership (403/404 cross-tenant).

The analyzer’s AgentLogScanner (see the Collector Agents page) evaluates each collected log line against that bundle through the SAME evaluators — evaluateCompliance (frameworks), preEvaluate (generic deterministic rules), and the Groq judge (NL policies) — but NOTIFY-only: block degrades to notify and the verdict is taken from the composed action, never JudgeResult.verdict. The verdict ceiling is Clean / Flagged, never Block (the data already landed). Findings persist to agent-flags-{ws} and surface in the agent’s Findings tab; the per-agent Business Rules tab reuses BusinessRulesModal with channel="logs" (Block removed from every action dropdown). The dry-run console hits POST /api/v1/warden/agents/{id}/evaluate-log → the analyzer’s POST /internal/scan/evaluate (identical evaluators → identical verdicts). The evaluators are vendored into the analyzer’s src/warden/ and must stay in sync with this service’s copies.

Relay (warden compose service): PORT_WARDEN=9600, AI_PROVIDER=neuralwatt, NEURALWATT_API_KEY, LLM_MODEL (the platform model; WARDEN_JUDGE_MODEL overrides it for the judge — both deepseek-v4.1-flash on dev), WARDEN_JUDGE_BASE_URL (default https://api.neuralwatt.com/v1), WARDEN_BACKEND_URL=http://backend:8080, WARDEN_REDIS_URL, WARDEN_INTERNAL_TOKEN, OPENSEARCH_URL/USERNAME/PASSWORD plus OPENSEARCH_CA_PATH (or OPENSEARCH_INSECURE_TLS=true on dev) whenever OPENSEARCH_URL is https against a private-CA cluster — without it every capture write is rejected by Node before it leaves the process, and the AI Warden reads as 0 requests / 0% coverage while the relay is serving traffic normally. The relay now announces that once, as an error, naming both variables; it was previously one console.warn per request, which is how dev ran with an empty audit trail unnoticed, LOGRITE_BACKEND_URL=http://backend:8080, WARDEN_JUDGE_TIMEOUT_MS=15000 (the TOTAL inline budget) and WARDEN_JUDGE_TIER_TIMEOUT_MS=9000 (per attempt; see judge retry and — see judge deadlines), WARDEN_JUDGE_ENABLED=true, WARDEN_JUDGE_MAX_CHARS=12000 (max chars of the latest-turn prompt sent to the LLM judge — the cache key hashes the full prompt regardless, so raising this only changes what the model reads), WARDEN_ASYNC_QUEUE_SIZE=500, UPSTREAM_TIMEOUT_MS=120000, WARDEN_FAIL_OPEN=open (open/closed/per_binding; true/false still honoured), WARDEN_CAPTURE_RAW=on_flagged (off/on_flagged/always, 25 KB cap — default changed from true), WARDEN_RULES_DIALECT=on (on/shadow/off — see regex dialect), WARDEN_BLOCK_STRICT=true (BLOCK-response mode — see block response), WARDEN_DISCORD_TIMEOUT_MS=5000 (how long one Discord notification may spend on the wire — short on purpose: the post is fire-and-forget so it cannot delay a response, but a socket held for 30s still occupies a handle, and under a burst that is thousands of them), RELAY_COMPLIANCE_*. One judge model: the judge is a single model, with no ladder and no cross-model fallback (WARDEN_JUDGE_LADDER and WARDEN_JUDGE_FALLBACK_MODEL are no longer read). WARDEN_JUDGE_CONFIDENCE_THRESHOLD=0.6 and WARDEN_BINDING_TTL_MS (per-key in-process binding cache; code default 60000, not set in the dev compose file). WARDEN_JUDGE_DEBUG=true logs each model’s raw + parsed verdicts (default off).

Backend: WARDEN_KMS_MASTER_KEY, WARDEN_INTERNAL_TOKEN (reuses existing OpenSearch host, JWT secret, mask.variables), and WARDEN_RELAY_URL (warden.relay.url, default https://warden.logrite.ai; set to http://warden:9600 on dev on 2026-10-14 and on prod at promotion — see the relay URL). The client.clientId config value is the fallback tenant for old JWT tokens (before workspaceId claim was added).

The /ai-warden page (sidebar, after Platforms) has three tabs (the former Violations tab was folded into Overview — every request already carries its verdict, frameworks, and matched rules, so a separate violations view was redundant):

TabPurpose
OverviewThe first / default tab (tab id is still requests). The single traffic and compliance log: a capture table over warden-requests-* with columns Date · Time · Provider · Model · Verdict · Frameworks · Developer · Latency. Verdict is a colored badge — ALLOW (green) / NOTIFY (amber, forwarded-but-flagged) / BLOCK (red); Frameworks are the compliance frameworks the request was scanned against; Developer is the X-Warden-User caller. Verdict filter pills (All / Allowed / Notify / Blocked) sit above the table next to provider/model/date filters — filter to Blocked or Notify to get exactly what the old Violations tab showed. The verdict filter matches the effective verdict the table shows, not just the raw stored field: Notify matches verdict==NOTIFY OR compliance.notify OR response.violationCount>0, and Allowed excludes those (back-compat for legacy rows captured as ALLOW before the relay tagged NOTIFY directly — see verdict filter). The provider filter lists every connectable provider (mirrors Settings & Keys — OpenAI, Azure OpenAI, Groq, Anthropic, Mistral). The date filter uses the shared CustomDatePicker (relative + absolute, LogRite-styled) instead of native datetime-local, and the table has a rows-per-page selector (10 / 15 / 20, default 15, server-paginated) shown only when there are more than 15 rows. Click any row for a centered detail modal, revamped (see below) — the single comprehensive view of a request: verdict + decision layer + developer, a Decision review breakdown (business / compliance / final gate badges), the AI judgement (judge verdict, model, escalation, and the LLM’s plain-English reasoning), matched rules, frameworks, redacted previews, the full raw request and response (see raw payloads), and — for flagged (block/notify) requests — a “Mark as false positive” suppress action (the violation stays stored but is excluded from the count).
Business RulesSince 2026-10-15 a policy table (PolicyTableEmbed) fixed to business-rules policies on AI traffic, with “Open in Rules & Policies →”. New and Edit open the shared PolicyEditorDrawer (Rules / Applies to / Test / History) with AI traffic preselected. RelayTestConsole stays below the table. The old BusinessRulesModal and RuleSetsView are deleted.
Settings & KeysThe last tab (tab id is still connect; renamed from “Connect & Keys”). Store a provider credential (AES-256-GCM), mint a relay key (the Key name field pre-fills a friendly auto-generated name like amber-macaron, with a reshuffle button; fully editable), and copy the base-URL-swap snippet. The mint form has no rule-set select: “Business-rules policies for AI traffic apply to this key automatically unless they are limited to other keys.” The Active relay keys table shows read-only policy chips (policyIds) and “Manage in Rules & Policies →”, plus inline rename, reveal, copy and delete (soft-revoke).

AIWarden.jsx renders exactly these three tabs, in order: requests (Overview, default) / rules (Business Rules) / connect (Settings & Keys, last), and does not import ComplianceConfig. The standalone Compliance config form, its All Bindings summary table, the duplicate standalone Test panel, and the separate Violations tab have been removed (ComplianceConfig.jsx is deleted; ViolationsView.jsx / ViolationDetailDrawer.jsx are no longer mounted — Overview’s verdict filter + detail modal cover the violation drill-down). The live relay test now lives in exactly one place — Business Rules (RelayTestConsole, below the policy table). All tabs are workspace-scoped: the UI reads currentWorkSpace.id from Redux. The tab writes ?tab= back to the URL. Mode and fail mode are policy settings under the editor’s collapsed Advanced section (mode default inline_blocking, failMode fail_open/fail_closed); saves no longer force inline_blocking.

The modal was a flat column of rows with two latency numbers at the bottom, and it shipped with three defects — two of which meant it rendered nothing at all.

  • Matched rules never rendered, for any record ever captured. The modal branched on r.matchedRules ({type, detail, action} objects) and nothing has ever written that; the relay writes matchedRuleIds, and latterly businessRuleIds / complianceRuleIds separately. All four shapes now render — including the tour fixture’s {id, name} — and the two layers stay apart, because flattening them undoes the split that made them legible.
  • A refusal rendered as a green Allow. The relay writes verdict: 'ERROR' when it refuses before any policy runs. effVerdict existed twice — in the modal and the table — and both copies fell through to ALLOW. There is now one copy, in requestDetailParts/privates.
  • judge.retries is read off a field nothing writes. Replaced with judge.usage.calls, which is on every judged record: a judge that answered on its second ladder tier made two calls.

What replaced the two latency rows:

  • a pipeline waterfall over latency.stages, degrading honestly for records that predate it (above);
  • a token panel over response.usage and judge.usage — data that has been on the record all along and appeared nowhere except as a workspace-wide total on the Overview header. When a provider reports nothing it says so; nothing is estimated;
  • a tools panel over tools.declared / callCount / resultCount / responseScope, listing the granted tools and marking which were actually called. declared is the only place the granted-vs-used (least-privilege) delta exists: a tool granted and never called leaves no trace in the conversation, so “nine available, one used” is otherwise unanswerable. Absent — never zeros — when the request involved no tools;
  • the relayed agent’s own conversation, drawn by the same TrajectoryView Cortex uses, so an operator who can read one trajectory can read the other.

When the relay BLOCKs a request it returns HTTP 403 with a provider-shaped error body and a stable code: "policy_block" (type: "warden_blocked" on the OpenAI-wire shape; permission_error on Anthropic). The message has two modes, selected by WARDEN_BLOCK_STRICT (blockEnvelope() in forwarder.ts):

WARDEN_BLOCK_STRICTCaller seesUse
true (default)A fixed, generic message — "Request blocked by Logrite AI Warden policy.". The specific policy / judge reason is not returned.Production — don’t leak the rule logic to the calling app/end-user.
false"Blocked by Logrite AI Warden: <reason>" — the framework/rule/judge reason, sanitised (below).Debugging / integration.

Either way the full reason is still recorded internally — in the warden-requests-* capture doc and the relay-compliance-events-* violation event — so the Overview detail modal always shows why a request was blocked even when the caller only got the strict code.

Missing/empty model — 400, not a passthrough 404 (2026-07-24)

Section titled “Missing/empty model — 400, not a passthrough 404 (2026-07-24)”

The relay is otherwise a pure passthrough (it forwards the caller’s JSON body verbatim), so a chat-completions request (messages array present) with a blank or absent model field used to reach the upstream provider and come back as that provider’s own error — e.g. Groq/OpenAI’s 404 model_not_found, "the model \` does not exist or you do not have access to it”. This is confusing once a client re-wraps it (agentic coding tools like **Cline**'s "OpenAI Compatible" provider profile default to a blank **Model ID** field, and re-label the upstream error with their own provider tag). The relay now validates model` up front on any chat-shaped body and returns a clean, actionable 400 instead of forwarding:

{"error":{"message":"model field is required and cannot be empty. Set the exact model id your client sends (e.g. \"llama-3.3-70b-versatile\" for Groq) — in Cline's \"OpenAI Compatible\" provider settings this is the \"Model ID\" field.","type":"warden_invalid_request","code":"missing_model"}}

Non-chat bodies (no messages array — e.g. GET /v1/models) are unaffected. This is a client-config fix, not a relay outage: the caller must set an explicit model id in whatever tool is calling the relay. src/warden/server.ts (ai-service, commit 90c540c).

Judge false positive — meta/educational questions about a framework (2026-07-24)

Section titled “Judge false positive — meta/educational questions about a framework (2026-07-24)”

The judge’s system prompt (buildMessages() in judge.ts) instructed the model that ANY policy — including the bundled HIPAA/SOC2/GDPR/M-26-14 “VIOLATED if the request CONTAINS <data>” guidance — covers paraphrases and indirect topic references, the same rule intended for genuine topic-ban NL policies (e.g. “no discussion of the Apollo program”). Applied to a CONTAINS-style DLP policy, this meant a purely educational question — e.g. “can you help me understand the HIPAA logs please” — mentioning the topic without containing any actual PHI got treated as an indirect reference and blocked (403 policy_block), while a near-identical question (“explain PHI leaks”) happened to pass.

The system prompt now explicitly splits judgment into two policy shapes:

  • CONTAINS-style / DLP policies (PHI/PII/credentials/payment-data guidance): violated only when the request contains an actual instance of that data, or explicitly asks to produce/reveal real data belonging to an actual person or system. A meta/definitional question about the category or regulation is NOT a violation.
  • TOPIC/SUBJECT-ban policies (a policy that explicitly forbids discussing a named topic): still catch paraphrases and indirect references, unchanged.

Jailbreak / instruction-override detection and the base64/encoded-payload decode step are untouched.

Added 3 regression cases to eval/warden-judge/cases.ts reproducing the false positive (see OPTIMIZATION.md methodology). Caveat surfaced during this fix: re-testing the exact live-blocking prompt in isolation (just the HIPAA policy alone, prod’s judge ladder) showed the original, unfixed judge already returned allow — 45/45 (100%) on the full eval suite both before and after this change. That means this fix is correct, zero-regression hardening of the CONTAINS/TOPIC distinction, but the live false positive a customer hit was likely caused by something specific to their bound rule set (additional frameworks/policies active in the same judge call, or a custom NL policy) rather than the bundled HIPAA guidance in isolation — worth checking the customer’s actual rule-set config if the false positive recurs after this deploys. src/warden/judge.ts (ai-service, commit 3f95fe8).

Until 2026-10-15, BusinessRulesModal.jsx was the single config surface. A rule set was either a preconfigured library OR custom — never both (source = 'library' | 'custom', with library_key set when it’s a library):

  • Preconfigured library (the easy path) — picked from the grouped library catalog (Compliance / Business verticals / System natives / Topic blocks; see curated libraries). The modal then showed a read-only data preview of exactly what the library enforced — its compliance frameworks, each AI-judge rule with its action badge, and the scan scope — with no custom inputs. Save snapshotted the library’s frameworks + nlPolicies and tagged source='library', libraryKey. Editing a library opened read-only, with a “Duplicate as custom” action to fork it into an editable set.
  • Custom rules — authored by the user: Name, Compliance frameworks (FrameworkPicker), a first-class AI-judge rules section (NlPolicyList, the primary layer), and an optional Advanced deterministic rules section behind a default-OFF rulesEnabled toggle (which gated only the deterministic rules — NL policies + frameworks always evaluated). Save tagged source='custom'.

The rule-sets table showed a Library / Custom badge per row and an Active on/off toggle that attached/detached the rule set to the workspace’s relay key(s) in one click.

Until 2026-10-15, starterRuleSets.js shipped eight curated templates, grouped in the picker via TEMPLATE_GROUPS. Each shipped NL prompt-type policies as the primary layer plus its compliance frameworks, with optional deterministic regex as the advanced fallback:

GroupTemplateWhat it does
Compliance frameworksHIPAA / Healthcare GuardPHI protection (SSN/MRN/patient IDs) + block clinical diagnosis & dosing advice
SOC 2 / PCIBlock payment-card data, credentials, and account numbers
GDPR GuardFlag EU personal data + data-subject / erasure requests
M-26-14 AI GovFederal OMB M-26-14: block PII, credentials, and AI-artifact extraction
Business verticalsLegal Advisor GuardBlock legal advice, opinion drafting, and binding-document review; protect client PII
Financial / Fintech GuardBlock personalized investment/financial advice; protect credentials, account & card data
Customer Support GuardFlag competitor mentions, block unauthorized promises, stop jailbreaks
System nativesAnti-Jailbreak GuardBlock prompt injection, instruction-override, role-play escapes, and encoding evasion
Enterprise ObserveAsync monitoring — flag legal/competitive risk, confidentiality markers, HR-sensitive topics

Template mode / failMode fields in starterRuleSets.js are decorative metadata only — save() forwards just frameworks, rules, and nlPolicies; mode/failMode are hardcoded as above.

There is no standalone Test tab — the live relay test lives in exactly one place: the RelayTestConsole in the Business Rules tab (below the rule-sets table), so you verify a rule set right where you build it. It fires a real request through the warden relay directly from the browser (no curl, no external app). CORS is pre-configured on the relay for all Logrite UI origins (https://app.logrite.ai, https://logrite.deployd.io, http://localhost:3000, http://localhost:3001).

Select a relay key from the dropdown (it lists all active keys; plaintext revealed on demand, provider derived from the key), click a Quick sample chip or type a custom message, and send. The browser calls warden.logrite.ai/v1/{provider}/chat/completions directly, and the inline result shows ALLOW (green) or BLOCK (red) with the fired rule name, round-trip latency, and a requestId. For a BLOCK, jump straight to the blocked request in the Overview tab (the deep-link filters it to Blocked).

SampleMessageExpected verdict (SOC2+HIPAA+GEN binding)
CleanWhat is 2 + 2?ALLOW
PHI / SSNPatient Jane Doe, SSN 123-45-6789, DOB 1985-03-15, diagnosed with hypertension.BLOCK — hipaa_log_leak_phi
PasswordMy password is SuperSecret123 and bearer token is eyJhbGciOiJIUzI1NiJ9.abcBLOCK — soc2_log_leak_credentials
Credit cardProcess payment for card 4532015112830366, CVV 123, exp 12/26.BLOCK — gen_log_leak_payment
Email (PII)Follow up with the patient at [email protected] regarding their prescription.BLOCK — gen_log_leak_pii

Verdicts depend on which templates are bound to the key’s project. A HIPAA-only binding blocks PHI but not passwords (no SOC2 rule active).

src/utils/constant.js
export const WARDEN_RELAY_URL = import.meta.env.VITE_WARDEN_RELAY_URL || 'https://warden.logrite.ai';

Override with VITE_WARDEN_RELAY_URL in .env.local to point at a local relay instance during development.

Beyond the in-browser relay test, the Business Rules test console renders a live cURL preview that mirrors the selected key/provider/model/message, so you can copy a ready-to-run command and replay any request outside the browser without writing it yourself. (The standalone Violations “Replay via relay” drawer was removed when the Violations tab was folded into Overview; the Step-3 console covers the same need.)

A provider-specific replay command uses the request’s provider to set the correct path:

Terminal window
# Example for a violation from a Groq-routed request
curl https://warden.logrite.ai/v1/groq/chat/completions \
-H "Authorization: Bearer lgw_live_YOUR_RELAY_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"llama-3.3-70b-versatile","messages":[{"role":"user","content":"Paste message that triggered the violation here"}]}'

Since violations store only redacted snippets (no raw content), you supply the original message. The drawer also shows the redactedSnippet — the redacted version is enough to know what field was caught, so you can reconstruct a representative test payload.

Connecting a client — the three things that are not guessable

Section titled “Connecting a client — the three things that are not guessable”

1. The path carries the PROVIDER name, not openai

Section titled “1. The path carries the PROVIDER name, not openai”

The relay mounts ANY /v1/:provider/*. :provider is the provider you registered the credential under, and the remainder of the path is forwarded verbatim to that provider’s own endpoint. So the path is:

https://warden.logrite.ai/v1/<provider-name>/chat/completions

/v1/openai/chat/completions is correct only when the provider really is openai. For a custom OpenAI-compatible endpoint registered as, say, neuralwatt, the working path is /v1/neuralwatt/chat/completions. An unknown :provider is refused with 400 {"type":"warden_unknown_provider"} naming the value it did not recognise — that 400 is the fastest way to confirm the name you registered.

2. The env-var names in the snippet are the SNIPPET’s, not your app’s

Section titled “2. The env-var names in the snippet are the SNIPPET’s, not your app’s”

The generated snippet uses placeholder names of the form <PROVIDER>_BASE_URL and <PROVIDER>_API_KEY. They are there to show you which two values change; they are not instructions to rename anything. Pasted as-is over an existing configuration they replace the application’s own variable names and the app then reads neither.

Keep your own names and change only the two values:

Terminal window
# your app already has these two — change the VALUES, not the names
OPENAI_BASE_URL=https://warden.logrite.ai/v1/<provider-name>
OPENAI_API_KEY=lgw_live_YOUR_RELAY_KEY

The relay key replaces the provider key in the client. Your real provider credential stays on Logrite and is attached to the relay key; it is never sent to the client.

3. GET /v1/<provider>/models works, and is the cheapest connectivity check

Section titled “3. GET /v1/<provider>/models works, and is the cheapest connectivity check”

The remainder-forwarding rule means every non-chat endpoint the provider offers is reachable too. models is the useful one: it needs no body, costs no tokens, and proves the relay key, the provider name and the upstream base URL are all correct in a single request.

Terminal window
curl -sS https://warden.logrite.ai/v1/<provider-name>/models \
-H "Authorization: Bearer lgw_live_YOUR_RELAY_KEY"

A request with no messages array skips the chat-completion validation entirely, so this cannot fail with missing_model.

Cloudflare rejects some default User-Agents (error 1010)

Section titled “Cloudflare rejects some default User-Agents (error 1010)”

warden.logrite.ai is behind Cloudflare, whose browser-integrity rule refuses a request whose User-Agent looks like an unattended script. Python’s urllib default is one of them, and the response is a bare error code: 1010 body with an HTTP 403 — not a JSON error — so a client that parses JSON reports a decode failure and not an access refusal.

Measured against dev-warden.logrite.ai on 2026-09-14: urllib with its default User-Agent gets 403 / error code: 1010; the same request with User-Agent: my-app/1.0 reaches the relay and gets its JSON (401 warden_unauthorized for a dud key); requests sends python-requests/2.32.3 and is likewise unaffected.

Set a User-Agent. Any non-default value works:

import urllib.request, json
req = urllib.request.Request(
"https://warden.logrite.ai/v1/<provider-name>/chat/completions",
data=json.dumps({"model": "...", "messages": [...]}).encode(),
headers={
"Authorization": "Bearer lgw_live_YOUR_RELAY_KEY",
"Content-Type": "application/json",
# Without this, Cloudflare answers 1010 before the relay is reached.
"User-Agent": "my-app/1.0",
},
)

requests, httpx, openai and curl all send a User-Agent of their own and are unaffected. This is an edge rule in front of the relay, not application behaviour: the relay never sees the request, so nothing appears in the request list for it.

Judge policy — plain English (policyText)

Section titled “Judge policy — plain English (policyText)”

Updated. The single policy_text blob is superseded by per-policy NL judge rules (warden_nl_judge_policy), each with its own action, sent together with framework-synthesized policies in one emit_verdicts call — see Unified rule set + LLM-first judging. In the UI you now add an “AI judge prompt” rule (a prompt-type row) instead of a separate textarea. policy_text is retained as a read fallback and auto-migrated to NL policies on deploy. The historical single-verdict behaviour below still describes the underlying judge call.

The judge takes natural-language policies and decides ALLOW / BLOCK / NOTIFY per policy on every relay request. The LLM is the primary decision-maker; deterministic rules are an opt-in fallback (disabled by default) that only contribute when the user enables them.

Your policyText → Groq judge system prompt → emit_verdict (ALLOW | BLOCK)

The judge is a forced function call (tool_choice: "forced", temperature 0) — it always calls emit_verdict exactly once with verdict: ALLOW | BLOCK, confidence: 0..1, and reason: string. Results are cached 30 minutes (keyed by ruleSetId + normalized prompt hash) so the same message for the same binding only hits Groq once.

The three judge modes still exist at the engine level, but mode and failMode are auto-managed and no longer user-facing — the save body hardcodes mode: inline_blocking, failMode: fail_open:

ModeWhen judge firesHot-path impact
inline_blockingBefore forwarding; each ladder tier gets WARDEN_JUDGE_TIER_TIMEOUT_MS (12000ms), capped by the remaining WARDEN_JUDGE_TIMEOUT_MS (15000ms)Can block synchronously; honors failMode on timeout (fail-closed escalates only to a configured action)
async_observeAfter forwarding (fire-and-forget via bounded queue)Zero hot-path latency; BLOCK verdict patches the capture doc retroactively
sampledOnly for the configured sampleRate % of requestsReduces cost on high-volume bindings

The ladder used to share one deadline. A tier that ran long consumed the whole budget and every remaining tier aborted the instant it started, so escalation — the reason the ladder exists — was unreachable exactly when a tier was misbehaving.

That is not only a latency bug. A ladder that produces no verdict returns errored, and a fail_closed binding escalates an errored judge to strictestConfiguredAction. A slow provider was therefore served to the caller as a policy decision: a 403 policy_block on a benign prompt. Measured on dev 2026-09-07, one healthy tier-1 round-trip to NeuralWatt takes ~3.8s — against a 5000ms budget for three tiers plus a fallback.

knobdefaultwhat it bounds
WARDEN_JUDGE_TIMEOUT_MS15000the whole ladder, and what the caller waits
WARDEN_JUDGE_TIER_TIMEOUT_MS12000 (6000 until 2026-10-08: deepseek-v4.1-flash decides in 2.9–5.3 s and half the live CareVault calls ran past 6 s)one tier, clamped to what is left of the total

A tier with less than 2s left is not started — it cannot complete a round-trip, and starting it only delays a verdict that cannot arrive. That floor deliberately does not apply to the first attempt: a small configured total must buy a short try, not zero calls and a blanket block on every request.

If you see 403 policy_block on prompts that are plainly fine, read the relay log before touching policy. Three tiers reporting Request was aborted. in immediate succession is a provider or deadline problem, not a verdict.

Judge quality is measured by the labeled eval in eval/warden-judge (ladder gpt-oss-20b → gpt-oss-120b → llama-3.3-70b-versatile, threshold 0.6):

RunAccuracyNotes
report-baseline.md95.1% (39/41)two misses — a base64-encoded jailbreak (→ ALLOW) and a GEN card number (→ NOTIFY instead of BLOCK)
report-final.md100.0% (42/42)topical / legal / healthcare / support / jailbreak / framework categories all 100%

Effective policies state the presence of the sensitive data or subject matter (“VIOLATED if the request CONTAINS …”) — the judge decides by presence, not stated intent. Topic policies work well stated positively (“only allow X”), and topical/paraphrase matches, jailbreak/role-play escapes, and base64-encoded content are all handled by the system prompt + deobfuscate() pre-pass (above), so they no longer need a separate deterministic rule.

# Keyword block
BLOCK any request that contains the word Apstine (case-insensitive). ALLOW all other requests.
# Topic restriction
This AI assistant is ONLY for questions about logging, software observability,
and compliance (SOC2, HIPAA, GDPR). BLOCK any off-topic request.
# Combined
BLOCK requests that mention competitor products (Datadog, Splunk, New Relic).
BLOCK requests containing Apstine. ALLOW everything else.
Request arrives → relay extracts relay key → resolves binding (LRU cache)
→ deterministic business rules run (content-regex, model allowlist, PII presence, etc.)
→ if not already hard-BLOCKed: judgeWithTimeout(policies, 5000ms) fires — primary gpt-oss-120b (escalates on low-confidence/error)
→ Groq returns verdict → compose(business ∧ compliance ∧ judge) → most restrictive
→ BLOCK → provider-shaped error response
→ ALLOW → forward to upstream provider

Credentials vs Relay Keys — key concepts

Section titled “Credentials vs Relay Keys — key concepts”

These are two distinct objects. Both show a ••••XXXX identifier but they store different things:

CredentialRelay Key
What it storesYour raw AI provider API key (sk-..., gsk_...)A Logrite-issued proxy key (lgw_live_...)
Last4 shownLast 4 chars of the provider API key (e.g. l4al)Last 4 chars of the relay key itself (e.g. WiNU)
UI displaygroq ••••l4al in Settings & Keysrelay ••••WiNU (groq) in Business Rules
PurposeAuthenticates to Groq/OpenAI/AnthropicIdentifies which requests go through AI Warden
RelationshipOne credential can back multiple relay keysEach relay key is bound to one credential

Why they look similar but differ: You might store one Groq credential (ending l4al) and then mint two relay keys (ending WiNU, iIH0) that both use it. The relay key dropdown in Business Rules only shows relay keys that have a live credential bound to them, labelled as relay ••••WiNU (groq).

Provider API keys are stored using AES-256-GCM envelope encryption (WARDEN_KMS_MASTER_KEY). Only the last 4 characters (last4) are stored in cleartext and surfaced in the UI for identification. The real key is decrypted only inside the relay on the hot path, never sent to the browser.

Credentials, relay keys and five other envelope tables (byo_llm_credential, agent_db_connections, observability_connections, salesforce_connections, discord_notifications) share one cipher, WardenCryptoService: a per-row data key (DEK) wrapped by the master key, with GCM AAD ws:<id> where the row is tenant-bound.

If WARDEN_KMS_MASTER_KEY changes without the rotation procedure below, every row sealed under the old key stops unwrapping: the decrypt call returns an error and the UI shows “Key could not be decrypted — re-store the credential”. The fix is then to delete the credential and re-create it with the current master key.

The backend accepts a second key, WARDEN_KMS_MASTER_KEY_PREVIOUS (warden.kms.previous-master-key; blank, or equal to the current key, means none):

  • New encryptions always wrap their DEK with the current key.
  • Decryption tries the current key and, only on a GCM tag mismatch and only when a previous key is set, the previous key. The ciphertext itself is never re-encrypted — only the DEK is re-wrapped.
  • Re-wrap at boot. WardenKekRewrapMigrator runs only when a previous key is set, once per current key: its marker is warden-kek-rewrap-v1-<fingerprint>, where the fingerprint is the first 12 hex characters of SHA-256 of the derived current key (never of the key material). It walks all seven envelope tables, re-wraps every DEK that only the previous key opens, under the same AAD, and logs [kms-rewrap] table=… rewrapped=… skipped=… per table. A row neither key opens is skipped with a WARN ([kms-rewrap] table=… id=… skipped: …) and left as it was.

Procedure:

  1. In one env change on the backend, set WARDEN_KMS_MASTER_KEY_PREVIOUS to the old key and WARDEN_KMS_MASTER_KEY to the new one; the change recreates the backend.
  2. Confirm the boot log has a [kms-rewrap] line for each of the seven tables, and that the skipped counts are what you expect (rows that were already unreadable stay unreadable).
  3. Remove WARDEN_KMS_MASTER_KEY_PREVIOUS (another recreate). From then on the old key opens nothing.

The env console keeps the last values of each variable in its wallet; after a rotation, forget the old key there too (POST /api/env/forget).

Each credential row in the Settings & Keys tab has a delete button (trash icon). The flow is:

  1. Click the trash icon — a confirmation prompt appears inline (“Delete this credential?”).
  2. Confirm → DELETE /api/v1/warden/credentials/{id} — backend hard-deletes the row.
  3. Any relay keys referencing this credential ID lose their upstream key and the relay returns an error on next use. Rotate relay keys after deleting a credential.

Shipped on dev and prod (ai-service / warden / ingest-shim da184506, backend 955701d5).

  • Policy merge on a relay key. Two policies with the same rule (type + config) on one key now keep the stricter action, and the entry carries that rule’s id and severity; a Monitor-only policy never raises mode or failMode (backend 1987c06f). Before, a Monitor-only policy with a lower id cancelled an On policy’s identical Block, and a Monitor-only policy could switch the key to inline_blocking / fail_closed. See Rules & Policies → Enforcement per feature.
  • Each rate_limit rule counts on its own. The counter key is rl:<relayKeyId>:<ruleId> and it is pruned by that rule’s own window (ai-service 5797960f, CX-10). Before, every rate_limit rule on a key shared rl:<relayKeyId> with one-hour pruning, so a policy with 3/min and 100/h rules blocked on the 100/h rule’s count of the last minute. A hit still answers 403 policy_block.
  • Binding resolve survives a backend outage (ai-service 5797960f, CX-15). The last good binding per key is served for up to WARDEN_BINDING_MAX_STALE_MS (default 900000 = 15 min) while the backend resolve fails, logged at most once a minute as [warden:bindingResolver] resolve failed (<msg>); serving the last good binding (Ns old). A failed resolve with nothing stale to serve still answers a retryable 503 and is cached for 10 s, so an outage costs one backend wait per key per 10 s instead of 15 s on every request. Before, every request during a backend deploy waited and failed.
  • Judge verdict cache is per workspace (ai-service 9c2ac231, CX-22): the key is <workspaceId>:<ruleSetId>:<library version>:<hash>, so the same prompt and policy text in two tenants never share a verdict.
  • decisionLayer: "async_observe" (ai-service 58b2f861, WD-10). A request on an async_observe binding that no rule blocked before forwarding, and that was queued for the out-of-band judge, is captured with decisionLayer: "async_observe" and the reason “No rule matched before forwarding; the AI judge reviews this request after it is forwarded and flags it if a policy applies.” It used to be recorded as fail_open, which also means “the judge could not answer”. fail_open now keeps only that meaning.
  • Seeded rows carry judge.outcome (WD-9): the demo seeder writes judge.outcome: "decided" for a seeded row whose judge ran. The demo-seeder service is report_only on the console, so seeded rows written before its next rebuild still lack the field.
  • Measured judge health (24 h, 2026-10-21): CareVault 303 — 1,125 of 1,130 requested judgements decided (99.6 %), no run of timeout; Worksoft 302 — 9 real requests, all not_requested (key 28 runs async_observe).
  • Kept as designed: a tool-order finding on a streamed response is recorded, never blocked (the bytes are already sent); the bounded async judge queue drops its oldest item when full; and failMode has no effect on an async_observe binding, because nothing waits for that judge.

A line-by-line security audit of the relay + collector agents landed a batch of fixes. Relay items:

Fail-safe decisions

  • The LLM judge fails closed on error, not just timeout. A Groq outage (all ladder tiers + fallback error) no longer resolves as a silent ALLOW for an inline_blocking + fail_closed binding — it escalates to the strictest configured action.
  • The whole relay hot path runs under one error boundary that honors WARDEN_FAIL_OPEN (previously read but never enforced) — an unexpected error can’t hang the client or crash the shared relay.
  • Deterministic leak matching over natural-language prose no longer hard-blocks on a bare identifier (“pan”, “tokenize”); only a real value / field:value context blocks. The LLM judge remains the decider.

Secrets & keys

  • Provider credentials and relay keys are encrypted with per-tenant AAD (GCM AAD binds ciphertext to the workspace). Legacy rows stay readable and are re-encrypted on deploy.
  • Relay-key reveal is elevated-role (Maintainer) gated and writes a governance-audit record. The plaintext key is returned once at mint/rotate (like a GitHub PAT); a Rotate control was added to the keys table.
  • The KMS master key is required by default (warden.kms.require-master-key → true) — a misconfigured deploy refuses to boot rather than encrypt under the published dev fallback.

Isolation

  • requireJwtWorkspace fails closed instead of resolving a claim-less token to the shared logrite tenant; the warden-requests-* cross-tenant wildcard fallback was removed; a legacy global-tenant relay index is derived from the key’s own binding, never a caller-supplied X-Warden-Project-Id.

Availability

  • A default per-relay-key request cap (WARDEN_RATE_LIMIT_PER_MIN, default 1200) protects provider + inline-judge spend from a leaked key. Internal/attribution headers and cookies are no longer forwarded upstream; large gzip/br responses are scanned correctly.
VarDefaultMeaning
WARDEN_FAIL_OPENtrueOn an unexpected relay error: open = forward, closed = block. Now actually enforced.
WARDEN_RATE_LIMIT_PER_MIN1200Per-relay-key request cap (0 disables). Per-replica.
warden.kms.require-master-keytrueRefuse to boot without WARDEN_KMS_MASTER_KEY (dev/sqlite-test profiles override to false).
WARDEN_KMS_MASTER_KEY_PREVIOUSunsetPrevious master key during a rotation (2026-10-16); see Rotating the master key.

What is stored, and what is masked (2026-10-07)

Section titled “What is stored, and what is masked (2026-10-07)”

Every text field AI Warden persists goes through one redactor (src/shared/redaction.ts) before it is written. Two strengths apply: all kinds (the nine value patterns below, plus field-name masking where noted) and secret kinds only (urlCredential, credentialPair, secretKey, opaqueToken — credentials, not personal data). The judge prompt (warden/judge/prompt.ts) also asks for a one-sentence reason “naming the rule and the kind of data involved; never quote or repeat any value from the conversation” — but the mask is what holds, because the model has quoted the very secret it flagged (contains admin:Hunter2).

Stored whereFieldMasking
warden-requests-*request.redactedPromptPreview, response.redactedCompletionPreviewfield names (WARDEN_MASK_VARIABLES) + all kinds, then clipped to 200 chars (captureDoc.ts)
warden-requests-*decisionReason, judge.reasoningjudge reasons: redactReason — field names + all kinds, then quoted values (a quoted span of 6–128 non-space characters holding a letter and a digit, e.g. credential 'Hunter2Winter!' → credential '***'; rule names like 'No secrets' stay readable). Masked once in composeVerdicts (warden/judge/verdicts.ts) — the single choke point every reason copy is made from
relay-compliance-events-*reason (judge events)the same masked judge reason
relay-compliance-events-*redactedSnippet (deterministic findings)the rule’s matched tokens + WARDEN_MASK_VARIABLES as field names, all kinds, 200 chars (complianceCheck.ts); judge events store ''
warden-requests-*request.rawBody, response.rawBodyredactRaw — credentials only: the secret kinds plus credential field names (password, passwd, passphrase, secret, apiKey, api_key, accessToken, refreshToken, clientSecret, privateKey), so password=Hunter2Winter! — no colon, invisible to the value patterns — becomes password=***. PII field names are deliberately absent: raw capture is admin forensics and keeps PII as captured. Masked before the 25 KB cap (…[truncated]), so a secret straddling the cut cannot survive as a fragment
relay-compliance-events-*rawRequest, rawResponsethe same redactRaw, same cap
Rediswarden:judge:* (shared judge-verdict cache)holds the already-masked judge result; entries written before 2026-10-07 are cleared by the backfill below
relay log[warden:async_observe] would_block …requestId, verdict and rule names only — never the reason prose (asyncJudgeQueue.ts)

Field-name masking is idempotent: a value that is already *** is left alone, and the value run stops at a backslash so a JSON escape inside a raw body is not read as part of the value. Before this, each pass ate up to 40 more characters after the field name, so re-masking never converged.

When raw is captured — WARDEN_CAPTURE_RAW (warden/config.ts):

ValueRaw bodies kept for
off (also false, 0)nothing
on_flagged (default; also flagged, or any unrecognised value)BLOCK and NOTIFY only — a caller that cannot state a verdict is treated as flagged
always (also true, 1)every request

The ordered pattern list lives in compliance/RedactionPatterns.java and reaches the relay on the Dynamic Compliance Library snapshot, field redaction (GET /internal/warden/library/snapshot). DEFAULT_REDACTION in src/shared/redaction.ts is the compiled-in copy used until a snapshot arrives (or when one carries no list) and must stay identical to the Java table — both test suites run the same corpus. A pattern the Node engine cannot compile is skipped, never fatal. Order matters: the credential shapes run first so a connection string keeps its scheme and host; the broad shapes run last.

#KindSecret kindExample
1urlCredentialyespostgres://admin:Hunter2@db:5432/x → postgres://***@db:5432/x
2credentialPairyesadmin:Hunter2 → *** (value must contain a letter and a digit, 4–128 chars)
3email[email protected] → ***
4ssn123-45-6789 → ***
5cardcard 4111111111111111 for the order → card *** for the order (the following space is kept)
6ibanDE89370400440532013000 → ***
7secretKeyyessk-… / gsk_… with 16+ trailing characters → ***
8opaqueTokenyesany 32+ character [A-Za-z0-9_-] run → ***
9longDigits123456789 (9+ digits) → ***

Left untouched: 10:30, localhost:8080, ISO timestamps such as 2026-10-07T05:17:58Z (measured against the compiled-in list).

Measured on dev (2026-10-07): a new relay capture holding postgres://admin:Hunter2@db/x and admin:Hunter2 was a NOTIFY whose preview read postgres://***@db/x … ***, with no hunter2 anywhere in the document, rawBody included.

Field-name masking runs before the value patterns wherever field names are passed (previews, judge reasons, snippets): WARDEN_MASK_VARIABLES (default password,accessToken,refreshToken,otp,pin,last4,ssn,ccNumber,ccNbr,creditCardNumber,creditCardNbr, mirror of the backend mask.variables). A field name’s following value is replaced: password: hunter2 → password=***.

Cortex queries the two Warden indices with a _source allow-list (tools/complianceData/definitions.ts):

  • search_warden_requests: @timestamp, requestId, provider, model, verdict, decisionLayer, decisionReason, compliance.frameworks, review.*, latency.totalMs, response.status, request.redactedPromptPreview, response.redactedCompletionPreview, callerUser.
  • search_compliance_events: @timestamp, timestamp, requestId, ruleId, rule, framework, action, finalDecision, scope, scanScope, severity, category, detectionLayer, reason, redactedSnippet.

Every returned example then passes a deep walk (fieldGrounding.ts#redact): any key starting with raw is dropped at any depth; promptText, requestBody, responseBody, apiKey, authorization, token, secret, credential (and their snake_case variants) become [redacted]; every remaining string is masked with all kinds and clipped to 500 chars. An old document or a missing allow-list still cannot hand raw capture to the model.

The deterministic id lookup goes through the same deep walk. Pasting a Warden request id into Cortex used to take a path (core/scope/idAcrossFamilies.ts#findIdOutsideLogs) that read warden-requests-* / relay-compliance-events-* by id without the allow-list and printed the document’s _source, rawRequest included — measured on dev. It now returns the redacted document (raw* dropped, every string masked); the check that the id really occurs in the record still runs on the unmasked document, so a token a mask would hide still finds its record.

Re-masking documents written before 2026-10-07

Section titled “Re-masking documents written before 2026-10-07”

src/scripts/wardenRedactBackfill.ts applies the masking the live writers now use to existing documents: reasons (decisionReason, judge.reasoning, reason) and redactedSnippet get redactReason without field-name masking (they also hold deterministic-rule prose such as “SSN detected”); previews and tools.records[].text get field names + all kinds; the four raw fields get redactRaw (secret kinds + credential field names).

Run it in the warden container, not ai-service: --apply clears warden:judge:* through WARDEN_REDIS_URL (database 0), and ai-service’s REDIS_URL points at database 1, where that cache does not live.

Terminal window
# dry run (default): reads and counts, writes nothing
docker exec <warden-container> node dist/scripts/wardenRedactBackfill.js --dry-run
# write the changes, then clear the Redis judge cache
docker exec <warden-container> node dist/scripts/wardenRedactBackfill.js --apply
# narrower scope (default: warden-requests-*,relay-compliance-events-*)
docker exec <warden-container> node dist/scripts/wardenRedactBackfill.js --apply --indices 'warden-requests-*'

It pages each concrete index 500 documents at a time (search_after on _id), writes with _bulk partial updates (retry_on_conflict: 3), and prints one JSON line per index — {"index":…,"scanned":…,"changed":…} — plus {"redis":"warden:judge:*","deleted":…} on --apply. Values are never printed. Bulk item failures go to stderr as {"index":…,"failed":…} and the exit code is 1. The first run on dev changed 1,677 documents; six still held a password because the judge had quoted it and the raw body carried password=… with no colon — the two shapes redactReason and redactRaw now cover. A second run changed 1,014. After the field-masking idempotency fix a --dry-run reports changed: 0 on every index, and _count?q=*hunter2* over warden-requests-*,relay-compliance-events-* is 0. Cortex questions about Warden blocks and their reasons, and an id lookup of the request, returned no Hunter2.

Verdicts in your own Discord channel (2026-09-30, sender moved 2026-10-07)

Section titled “Verdicts in your own Discord channel (2026-09-30, sender moved 2026-10-07)”

A workspace can point AI Warden at a Discord channel it owns and have verdicts arrive there as they happen. Configured in Settings → Notifications → Discord (moved from Integrations on 2026-10-07), one channel per workspace.

ConfigureSettings → Notifications → Discord (Maintainer or Owner)
APIGET/PUT/DELETE /api/v1/integrations/discord/{workspaceId}/connection, POST …/test
Stored indiscord_notifications, one row per workspace — DDL in migrations/2026-discord-notifications.sql
Sent bythe backend: DiscordEvents#wardenVerdict, parts in WardenVerdictAlerts, cap in WardenAlertThrottle
Categorywarden (“AI Warden alerts”) — must be selected on the channel
  • POST /internal/warden/capture — every relayed request’s capture doc passes through this endpoint, so it sees them all without the relay holding the webhook. Verdict, rule ids (matchedRuleIds), provider and model are read from the doc.
  • POST /internal/warden/capture/verdict — the async-judge patch. Only an escalation to BLOCK is announced; any other patched verdict is not news.

Both always answer 202, and the alert path never throws: a Discord outage, a deleted webhook or a 429 cannot fail a capture or touch a verdict.

# AI Warden alerts, then ## Blocked a request / ## Flagged a request / ## Allowed a request, and an embed with Verdict, Rules (up to 10 rule ids, then +N more), Model (provider · model) and Request — a link to /ai-warden?request=… that needs a Logrite login. Footer: Logrite AI Warden · opening the request needs a Logrite login.

Measured on dev (2026-10-07): with workspace 3103 temporarily connected to an ops channel, a PCI BLOCK captured at 09:35:37.187Z was delivered (Discord answered 204) at 09:35:38.10Z — under a second. The connection was removed afterwards.

Three independent toggles: Blocked (notifyBlocked) and Flagged (notifyFlagged, the NOTIFY verdict) are on by default; Allowed (notifyAllowed) is off. Allow is 93–95% of relay traffic, so it is opt-in and budgeted below the others.

  • Once per request and verdict. A repeated (workspace, requestId, verdict) is dropped and not counted — the relay can capture the same request twice. A NOTIFY later escalated to BLOCK is a different verdict and is announced again. A blank requestId is only rate-capped.
  • 20 per workspace per 10 minutes. ALLOW may spend at most 12 of the 20; the last 8 are reserved for BLOCK and NOTIFY, so clean traffic cannot starve a block.
  • Held-back messages are disclosed. They are counted, and the next message that goes out ends its footer with +N more since HH:MM UTC. A new window resets the spend, not the count.
  • These alerts skip the dispatcher’s per-webhook bucket — the throttle is their only cap (kept per backend process).

A Discord webhook URL needs no other authentication: anyone holding it can post into that channel as your integration, indefinitely, and Discord offers no way to see who did. It is therefore treated as a secret, not a setting.

  • Sealed at rest in one AES-256-GCM envelope, with a per-row key wrapped by WARDEN_KMS_MASTER_KEY and the GCM AAD bound to ws:<workspaceId> — the same envelope the Warden provider keys and the Salesforce refresh token use. A row lifted into another workspace’s database will not unwrap.
  • There is no read path. No API route returns it and nothing in the UI can hold one. The card shows a channelHint like …/webhooks/1234567890/…Abc9 instead — enough to tell two webhooks apart, not enough to post with. Saving with the field blank keeps the stored webhook and changes only the toggles.
  • Only Discord, only https, only /api/webhooks/…. The URL arrives in a request body and the server then posts to it from inside the estate, so without a host allow-list this endpoint would be a request-forgery primitive pointed at whatever host a caller named — a metadata address, or OpenSearch on localhost. Redirects are never followed, because a redirect is how an allow-listed host hands the request to one that is not.
  • Never logged. Not in an application log line, not in an audit document, not in an exception message — an exception from an HTTP client can carry the URL it was calling.

The same channel carries the rest of the product (2026-10-01)

Section titled “The same channel carries the rest of the product (2026-10-01)”

The backend also sends analyzer scans, log generation, Gatekeeper verdicts, collector-agent health and security changes to the same webhook. One integration, one channel, selectable per category — see the Discord catalogue. Every category, Warden included, goes through the same DiscordMessage builder, which sets allowed_mentions.parse to an empty list and clips every field to Discord’s limits.

The notification bell polls the 50 newest requests of the last 7 days every 5 minutes (hooks/useNotificationSync.js) and creates one item per request whose effective verdict (the one the Requests table shows) is BLOCK — “AI Warden blocked a request” — or NOTIFY — “AI Warden flagged a request”. A request-side BLOCK refused before any response (violationCount 0) is included. Gated by the workspace setting notify.warden.bell (Settings → Notifications, default on). Each item links to /ai-warden?request=<id>.

/ai-warden?request=<id> opens that request — in the Discord message and in the bell. The page switches to the Requests view and opens the request’s detail drawer, fetching the record by id when it is not on the loaded page. An id that is not in the caller’s workspace shows “That request is not available in this workspace” and opens nothing. Closing the drawer drops the parameter without a new history entry. Until frontend 52249631 the page read only ?tab=, so both links landed on the page and opened nothing.

The reply language is stated, because a model with no signal picks its own (2026-09-30)

Section titled “The reply language is stated, because a model with no signal picks its own (2026-09-30)”

A relay test sent from the console with the body {"model":"deepseek-v4-flash","messages":[{"role": "user","content":"abc"}]} came back in Chinese, twice, and then in English. Verdict ALLOW, HTTP 200, latency normal, nothing logged as a fault.

Nothing in the estate was wrong. The relay is a byte-identical passthrough — forwarder.ts forwards req.body as it arrived and pipes the provider’s bytes back without re-framing them — so the text was DeepSeek’s own output. A bare user message carries no language signal, and the reply language is then whatever the model’s prior happens to be. Because it is sampled rather than deterministic, it recurs at random: the kind of thing that gets investigated twice and found once.

Two properties are worth knowing:

  • The cURL the panel prints is built from the same function as the request it fires. That box is captioned “reflects your inputs”, and a command that omitted the pin would reproduce the original bug — so whoever copied it to verify the fix would see Chinese and conclude it never landed. One builder, asserted on the source, because divergence is a property of having two literals and no rendering of one can see the other.
  • Pinned, not mirrored. Cortex detects the user’s language itself (core/intent/replyLanguage.ts) precisely because asking a model to “reply in the same language” is unreliable. This console exists to show a verdict, so it states one language rather than negotiating.

The judge’s reason is pinned too, and that surface has teeth

Section titled “The judge’s reason is pinned too, and that surface has teeth”

The judge runs on the same deepseek / qwen / glm ladder, and its reason is rendered in the violation list and — in verbose mode — returned inside the 403 body. A Chinese reason there is a compliance record its own reader cannot act on, attached to a request that was blocked correctly.

The instruction sits in the reason field’s schema description in emit_verdicts, not in the system prompt. That prompt holds the CONTAINS-versus-topic shapes, the misspelling carve-outs and the confidence rules — everything that decides whether something is a violation. Wording guidance placed there is one more thing the model weighs against those; in the field schema it can only shape the string it annotates. A test asserts the decision rules stay free of it.

The relay console’s model picker (2026-10-01)

Section titled “The relay console’s model picker (2026-10-01)”

The Test the relay live console’s model field is now a dropdown of the selected key’s models. You can still type any name, because Azure is called by deployment name, a self-hosted endpoint serves whatever it serves, and providers ship models faster than any list.

  • A key with a stored credential: the provider’s live list for that key, from GET /api/v1/warden/credentials/{id}/models. The backend decrypts the key, calls the provider’s own models endpoint (the same request the key test makes) and caches the result for 10 minutes. The response is { provider, source: "live" | "curated", models, reason? } and never contains the key.
  • The provider cannot be asked: the curated list, with source: "curated" and the reason.
  • A pasted key: the curated list for the provider.

The curated lists live in one place, ModelCatalog. Both Warden’s /providers (as models) and Settings → AI Model (as suggestedModels) read it, replacing a second hard-coded copy in the UI that had already drifted.

A rule set can require that some tools run before others. Each entry of toolPreconditions names a tool, the tools that must have run first, and an action:

"toolPreconditions": [
{"tool": "release_payout", "requires": ["check_policy_limit"], "action": "block"}
]

Validation (400 naming the entry): tool names match ^[A-Za-z0-9_.-]{1,64}$, requires holds 1–10 names, action is block or notify, at most 20 entries. Stored as the jsonb column warden_rule_set.tool_preconditions (null reads as []); edited in the rule-set editor’s Tool order section (tool, “must run first” chips, Block/Notify).

The relay evaluates the rules on the model’s response. For each tool call the response asks for whose name equals a rule’s tool, every name in requires must already appear as an assistant tool call earlier in the request’s messages (or earlier in the same response). Otherwise:

  • block on a non-streamed response: the answer is withheld and the client gets HTTP 403 {"error":{"type":"warden_response_blocked","code":"tool_order_violation","message":"Blocked by <rule set>: release_payout was requested before check_policy_limit ran."}}, so the app never sees the payout call.
  • On a streamed response, or with notify: the request is recorded as NOTIFY with that reason (the stream has already reached the client).

The capture doc carries toolPreconditionViolations: [{tool, missing: [...], ruleSetId, action}], and the request detail’s Tools panel shows “release_payout ran before check_policy_limit — blocked by <rule set>”. Worked example: a claims agent that granted check_policy_limit but never called it before release_payout — the rule above stops the payout round.

Raw text of requests blocked for personal or health data

Section titled “Raw text of requests blocked for personal or health data”

A request blocked or flagged by a finding of category phi or pii, or by a judge violation of a HIPAA policy, keeps no raw text by default: no request.rawBody, response.rawBody or event rawRequest / rawResponse; the preview reads [withheld — blocked for PHI; 1234 bytes] and the tool timeline keeps only tool names and argument key names. The workspace setting warden.sensitiveRawCapture (default false; Settings → Privacy) keeps the raw text — turn it on only when your model runs inside your own network. Detection and verdicts are the same either way.

Every capture doc says what happened to the raw text in request.rawCapture = {mode, stored, reason}:

reasonMeaningRequest detail note
sensitive_blockwithheld for PHI/PII“Not stored: this request was blocked for personal or health data.”
allowed_on_flaggedallowed, and the key captures raw only for blocked/flagged rows“This request was allowed, and raw capture keeps only blocked or flagged requests.”
capture_offraw capture is off for the key“Raw capture is off for this relay key, so the conversation was never stored.”

stored: true when raw text was kept. The preview is labelled Prompt preview (masked). Existing docs were cleaned with scripts/wardenRedactBackfill.ts --drop-sensitive-raw.

What the relay masks is a workspace choice (Settings → Privacy → AI Warden redaction; keys in Settings):

  • warden.redaction.enabled (default true). false stores and shows text as sent at every mask site: previews, raw body, tool-call timeline, block message, compliance redactedSnippet, judge reasons (including the async judge queue).
  • warden.redaction.kinds and warden.redaction.fields choose what is masked; the raw body masks only the listed credential kinds (urlCredential, credentialPair, secretKey, opaqueToken).
  • The relay reads them from the relay-key binding (redaction {enabled, kinds, fields, patterns}); a binding without the block keeps the previous fixed masks exactly.
  • Each capture doc records request.redaction = {enabled, kinds}; when masking was off the request detail says “Masking was off for this workspace when this request was captured.”
  • Changing any of these keys writes the settings.update audit row with details.privacyChanges (key, old value, new value).

Findings, verdicts and the withholding above never depend on these switches.

Capture docs carry outcome: NOTIFY when the verdict is ALLOW but the composed decision is notify or a response-scope violation fired, else the verdict. The UI and Cortex’s search_warden_requests group by outcome, so response-scope notifies are no longer counted as allowed.

“Avg added latency” is Warden’s own cost

Section titled ““Avg added latency” is Warden’s own cost”

Capture docs write latency.wardenMs = latency.totalMs − latency.stages.upstreamMs (never negative). The Overview tile averages latency.wardenMs; before, it averaged latency.totalMs, which includes the model’s own time. Older docs without the field are not averaged.

  • GET /api/v1/warden/judge → {"model": "<model id>"}, read from the relay’s /health judgeModel (the judge role’s model), cached 60 s; {"model": null} when the relay is unreachable, and the UI then shows “Platform judge”. The hard-coded label is gone.
  • A policy’s settings never shows judgeModel (backend c49f545e). The relay leaves it off the judge input and runs its one pinned model, yet every business policy migrated from a rule set carries llama-3.3-70b-versatile there. PolicyMapper drops it from summaries and documents; the stored row is unchanged. /warden/judge is the only answer to “which model”.
  • The out-of-band judge (async_observe) has its own budget, WARDEN_JUDGE_ASYNC_TIMEOUT_MS (default 120000), instead of the 12 s inline tier budget. The deprecated groqModel alias was removed.
  • Every catalogue rule has a displayName (e.g. hipaa_log_leak_phi → “PHI in prompt, response or log”), served on GET /api/v1/warden/library/rules (optional libraryKey); the request detail shows the name with the id as a tooltip.

Binding rule sets to keys (superseded 2026-10-15)

Section titled “Binding rule sets to keys (superseded 2026-10-15)”

Relay keys no longer carry rule-set ids. A business-rules policy with the ai_traffic feature applies to every relay key of the workspace, including future ones, unless its relayKeyIds narrow it. Overview raises relay_key_unprotected for an active key that no policy reaches; such a key still fails open. See Rules & Policies.

  • Final decision follows the outcome. The capture doc’s review.response records the response-phase review (response scope and tool-order rules), and review.final equals outcome. Before this, a response or tool-order BLOCK left review.final = allow because the review covered only the request phase (ai-service 3cf0dc1).
  • Request detail shows why. The decision panel renders decisionReason and the deciding layer (for example tool_order), and its “Final” row reads outcome (frontend 3f09e19f).
  • The response scan reads authored text. When a model answers with content: null and puts its text in message.reasoning, the scan now reads that text (completionText.ts). Earlier it fell back to the JSON envelope, where the completion id looked like an opaque token and created like a long number, so every word-list hit looked value-backed.
  • Wording for un-held answers. A stream: false answer that was not held is no longer called “streamed” in the verdict text.
  • outcome is a keyword. The warden-requests index template maps outcome as keyword (backend 21b43cd2). It applies to indexes created after the change; older indexes keep their dynamic mapping, and Cortex falls back to outcome text or verdict grouping there.
  • “Avg added latency” shows — when unmeasured. GET /api/v1/warden/stats returns avgTotalMs: null when no doc in range carries latency.wardenMs (backend 3f75cee8), and the tile shows — instead of 0 ms (frontend e889f7c0). Seeded demo captures now carry latency.wardenMs.

Documented from ai-service main at c8e7ef98.

The inline judge (every mode except the out-of-band judgeAsync) used to hand its fallback to the flex lane, which answers in 55–65 s, with about 3 s of the 15 s deadline left — so a slow first call ended in an abort and, with fail-open, an unjudged forward. Now:

  • each inline attempt is one standard-lane round-trip bounded by WARDEN_JUDGE_TIER_TIMEOUT_MS (default 9000), inside the total WARDEN_JUDGE_TIMEOUT_MS (15000);
  • a failed first attempt is retried once, on the standard lane, with the same model — only when the failure is transient (429, 502, 503, a timeout or a reset) and at least 4 s of the deadline remain; after a 429 it waits min(Retry-After, remaining − 4 s) first;
  • the inline path never calls flex; judgeAsync keeps its single flex lane;
  • each attempt logs [warden:judge] attempt=<n> lane=standard ms=<elapsed> outcome=<ok|429|5xx|timeout|error>.

Fail-open is unchanged (user decision): if both attempts fail, the request is forwarded and the capture records the judge outcome.

  • Provider keys stay out of Redis. The resolved binding (which holds the decrypted provider key) is cached only in process; the shared warden:binding:* Redis entries are gone, and the relay deletes any left over at startup.
  • Withheld answers are checked whole. Tool-order (K7) and response rules read the entire withheld body (bounded by the withhold limit), not the first 64 KiB scan tee.
  • OpenAI Responses API. Tool-order rules read the Responses wire as well as Chat Completions: history from input items of type function_call, buffered answers from output[] function_call items, and streams from response.output_item.added events.
  • Provider mismatch. A key whose binding has no custom baseUrl used on another provider’s path (e.g. an OpenAI credential on /v1/anthropic/…) is refused with 400 warden_provider_mismatch. The test provider profile exists only under NODE_ENV=test.
  • Captures. An async-observe BLOCK or NOTIFY patches outcome and review.final along with verdict; a withheld response keeps only the withheld preview, not a completion preview; relay internal errors are captured under the binding’s workspace, also on fail-closed (type: warden_internal_error); forward-path judge captures carry the judge’s reason; a refused release with response violations is recorded as BLOCK.

API keys, MCP and the 13 October test report (2026-10-14)

Section titled “API keys, MCP and the 13 October test report (2026-10-14)”

The MCP test report of 13 October 2026 found that a customer could not run AI Warden through MCP. Backend 2f3e3cea, 2dc58675, cd061778; ai-service cc9175e. The MCP side is on MCP Server.

API keys reach Warden without the API chain

Section titled “API keys reach Warden without the API chain”
  • ApiKeyWorkspace.of(req) replaces readOnly. It reads ApiKeyAccessFilter.VERIFIED_KEY, the request attribute that filter sets on every chain after verifying the key, and returns the key’s workspace — or null on a reveal path (ApiKeyAccess.revealsCredential) and for a JWT. The safe-method check is gone: ApiKeyAccessFilter already answers a read key’s writes with 403 insufficient_scope before any controller runs. Callers: WardenControllerSupport.requireJwtWorkspace, GovernanceController, MaturityController.requireJwt, ObservabilityController, ObservabilityFeedController, and RelayComplianceController for GET /binding and GET /effective-ruleset only — the deprecated PUT /binding has no role floor and still requires a session.
  • Role floors evaluate the key’s minter. RoleFloorGuard.callerFrom maps a verified key to users.findById(createdByUserId) (401 invalid token when there is none), so a Warden write with a key minted by a Viewer is refused “Maintainer role or above…” exactly as the Viewer’s session would be. The same applies to AgentController delete.
  • Full and admin keys may write Warden configuration, subject to those floors (decision of 2026-10-14; the old comment in WardenLibraryController that relay-key and credential routes must never accept a credential is replaced). Relay-key /reveal stays closed to every key. A read key may also POST /api/v1/governance/verify and the policy dry runs /api/v1/policies/{id}/test and /api/v1/policies/effective/test (the old evaluate-log routes are removed).
  • Actors name the key. WardenControllerSupport.extractActor returns <minter email> (API key …<last4>) for a key, the JWT username otherwise; suppressions and every new audit row use it.

relay-compliance-events-<ws> had no REST read route, so MCP’s compliance_logs never saw Warden’s evidence (R2). Two routes in the new WardenEventsController, read auth as every Warden GET (session or key):

GET /api/v1/warden/compliance-events (WardenComplianceEventsQuery, one 400 naming every bad parameter):

ParamRule
hoursnumber or all; default 168; 0/all = all time
fromDate, toDateISO; win over hours; toDate requires fromDate
framework, rule, requestId, q≤200 chars; q is a simple_query_string over rule, framework, complianceRef, reason, callerUser
finalDecisionallow, notify or block
severitycritical, high, medium, low or info
relayKeyIdlong; a term on providerKeyId
from≥0
limit1–200, default 25
sortDirasc or desc (default)

The window is on timestamp, which every event carries. Exact string filters are bool.should [term <f>.keyword, term <f>], so dynamic-mapped and templated indices both match. Response {events, total, indexMissing}; each event keeps exactly eventId, requestId, timestamp, framework, rule, complianceRef, category, severity, scanScope, scanMode, detectionLayer, finalDecision, businessDecision, complianceDecision, provider, model, providerKeyId, reason, callerUser, falsePositiveSuppressed and redactedSnippet cut to 500 chars — never rawRequest, rawResponse or leakedFields. A missing index answers {"events":[],"total":0,"indexMissing":true}.

GET /api/v1/warden/compliance-events/stats?hours= (default 168, 0/all = all time) → {total, byFramework, byDecision, byRule (top 20), byScanMode, newest, oldest} ([{key,count}] lists; newest/oldest = max/min timestamp). Each dimension is aggregated on the field the live mapping makes aggregatable — the bare field when it is keyword/long, <f>.keyword when it is text with a keyword subfield, left out otherwise — because dev and prod map the family differently (below). A missing index answers zeros with indexMissing:true.

DELETE /api/v1/warden/violations/suppressions/{id} — floor warden-suppress (Developer). Sets the row’s status to removed and answers 204; a row that is absent, already removed or another tenant’s answers 404 {"error":"suppression_not_found","message":…}. Audit warden_suppression_removed {id, framework, rule}. The list still returns active rows only.

POST /api/v1/warden/relay-keys/{id}/test, body {model?: ≤200 chars, message?: ≤2000}, floor warden-keys (Maintainer). WardenRelayKeyTester decrypts the key and sends one message through this estate’s relay (warden.relay.url), 30 s timeout, with X-Warden-User: logrite-key-test:

ProviderPathAuth header
anthropic/v1/anthropic/messagesx-api-key
azure_openai—refused, 422
every other/v1/<provider>/chat/completionsAuthorization: Bearer

The body is {model, max_tokens: 16, messages: [{role: "user", content: message}]}; model defaults to the first id the credential’s model list returns, message to “Reply with the single word: ok”. It then polls the captured request 5 times, 1 s apart, for the verdict.

Response: {ok, reachable, httpStatus, requestId, verdict, captured, latencyMs, provider, model, error} — ok on a 2xx; reachable also when the relay answered warden_blocked; verdict null and captured:false when the capture had not landed in 5 polls; error the provider or relay message (≤500 chars). An unreachable relay gives ok:false, reachable:false, httpStatus:null and relay unreachable: …. Audit warden_relay_key_tested {keyId, httpStatus, verdict}.

RefusalStatus
relay_key_not_found404
relay_key_not_active409
relay_key_has_no_credential409
relay_key_not_testable (no encrypted copy of the key)409
test_not_supported_for_provider (azure_openai)422
model_required — “model is required: this credential lists no models”400

Every refusal body is {error, message}.

Written through GovernanceAuditService.record (via WardenAuditTrail) with actor = extractActor(req), next to the existing warden_relay_key_minted and warden_relay_key_attached:

ActionDetails
warden_credential_addedcredentialId, provider, last4
warden_credential_removedcredentialId
warden_credential_testedcredentialId, ok
warden_relay_key_rotated / _revokedkeyId
warden_relay_key_updatedkeyId, fields — a PATCH’s changed fields other than ruleSetIds; not written when none
warden_relay_key_testedkeyId, httpStatus, verdict
warden_agent_ruleset_attached / _detachedRemoved 2026-10-15 with the per-agent routes; policy writes audit policy_*
warden_suppression_removedid, framework, rule

The backend calls its own estate’s relay

Section titled “The backend calls its own estate’s relay”

warden.relay.url defaults to https://warden.logrite.ai, and dev set no WARDEN_RELAY_URL — so dev’s library sync, and the new key test, called the prod relay. Dev’s backend has WARDEN_RELAY_URL=http://warden:9600 since 2026-10-14; prod gets the same at promotion. The name resolves from both estates’ backend containers.

Compliance events carry @timestamp, and a template

Section titled “Compliance events carry @timestamp, and a template”

A time window on relay-compliance-events-* returned 0 even for 90 days (R4): the events carried only timestamp, and anything defaulting to @timestamp — MCP’s analytics_query, Discover — filtered on an unmapped field.

  • The relay writes both. fanout.ts (both event builders) and asyncJudgeQueue.ts compute the ISO instant once per call and write it to timestamp and @timestamp.
  • Existing events are backfilled per estate with an _update_by_query over relay-compliance-events-* that sets @timestamp from timestamp where it is missing, run after the relay change is live. Check: _count with must_not exists @timestamp is 0.
  • RelayComplianceEventsTemplateEnsurer (sibling of WardenRequestsTemplateEnsurer, same idempotent PUT, fail-soft, off with warden.template.ensure.enabled=false) installs _index_template/relay-compliance-events at priority 200 for new indices: @timestamp and timestamp as date; the enum fields (framework, rule, finalDecision, severity, scanMode, requestId, …) bare keyword; redactedSnippet index:false; rawRequest and rawResponse stored but not indexed; any other string text + keyword (ignore_above 256).