AI Warden
Overview
Section titled “Overview”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.
Architecture
Section titled “Architecture”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 aworkspaceIdclaim (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-serviceon port 9600, its own container and compose service behindwarden.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).
| Service | Port | Notes |
|---|---|---|
| AI Warden relay | 9600 | Container built from logrite-ai-service (Dockerfile.warden), warden.logrite.ai / dev-warden.logrite.ai |
| Backend control plane | 8080 | /api/v1/warden/*, /internal/warden/* |
API surface
Section titled “API surface”Control plane (JWT-gated, /api/v1/warden)
Section titled “Control plane (JWT-gated, /api/v1/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.
| Method | Path | Purpose |
|---|---|---|
| GET | /providers | Supported 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}, /stats | Tenant-scoped OpenSearch capture + aggregations |
| GET | /compliance-events, /compliance-events/stats | Compliance 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}/test | Send one test message through the relay with that key (2026-10-14) |
Internal (relay → backend, not public)
Section titled “Internal (relay → backend, not public)”| Method | Path | Auth |
|---|---|---|
| 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) |
Relay data plane (port 9600)
Section titled “Relay data plane (port 9600)”| Method | Path | Purpose |
|---|---|---|
| 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).
Multi-tenancy
Section titled “Multi-tenancy”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_bindinghas atenant_idcolumn (migration2026-warden-tenancy.sql). All read/write queries now requiretenantId— cross-tenant enumeration of bindings via guessableprojectId(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(markerwarden-legacy-tenant-retire-v1) revokes everyactiverelay key whosetenant_idis not numeric and writes a governance auditwarden_relay_key_revokedwith reasonlegacy_tenant_retiredfor 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 gets401 warden_unauthorized. - Rule sets archived, then dropped. The 11
logriterule 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 whichlogrite.policy.drop-legacy-tables=truewas set on dev and prod — see Rules & Policies.
Data model
Section titled “Data model”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 viaPATCH /relay-keys/{id} { name }. Legacy keys keepname = NULLand 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.
Pipeline stage timings (latency.stages)
Section titled “Pipeline stage timings (latency.stages)”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.
Capture fields (warden-requests)
Section titled “Capture fields (warden-requests)”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.FIELDSis 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.parseread only top-level mapping properties, soprops.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 thetextvariant 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 byWardenFieldResolverTest.
Compliance rules
Section titled “Compliance rules”The relay scans against the analyzer’s shared leakPatterns module (vendored byte-identical). Frameworks map to relay canonicalNames:
| Framework | Relay rules |
|---|---|
| HIPAA | hipaa_log_leak_phi (critical → block) |
| SOC2 | soc2_log_leak_credentials (critical → block) |
| GDPR | gen_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 |
| GEN | gen_log_leak_pii (high → notify), gen_log_leak_payment (critical → block) |
| M-26-14 | m2614_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-Gov | aidatagov_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:
| Layer | What it does now |
|---|---|
src/warden/regexDialect.ts | Translates 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 content | The 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.java | The 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, andscripts/warden-setup.cjswas 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.patternand 15 under a legacyconfig.value.ruleEngine.tsreadsString(rule.config?.pattern ?? '')and thenif (!raw) return { hit: false }— so those 15 exit on the first line, beforenormalizePattern, and without incrementing the invalid or untranslatable counters this section introduced. They areenabled = trueand 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 zerowarden_relay_key_rulesetbindings, so no traffic reaches them, and customer tenants2903/2905hold onlypattern-keyed sets. Nothing writes the legacy shape today: the UI’stoPayloadRulemapsvalue → config.patternon save, so these rows predate that translation. Open, not fixed — counting the drop is a smallai-servicechange, 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.
| Guard | Rule |
|---|---|
| Shape | Refused 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 once | Each pattern and flag pair is compiled once and kept (up to 512). |
| Input | Text 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. |
| Measure | A 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+\.comtakes 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=offrestores 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.
Coverage is reported as negative space
Section titled “Coverage is reported as negative space”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.
Evidence has a rung below notify
Section titled “Evidence has a rung below notify”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.
Live eval, measured
Section titled “Live eval, measured”| run | ladder | result |
|---|---|---|
| before | deepseek-v4-flash → qwen3.6-35b → glm-5.2-short | 97.8% (45/46) — miss: fw-m2614-posture expected notify, got allow at conf 1.00 |
| after | same | 100% (46/46), gate PASS, 0 regressions, improved: fw-m2614-posture → notify |
| with the 6 new benign cases | same | 100% (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.
Unified rule set + LLM-first judging
Section titled “Unified rule set + LLM-first judging”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.)
Gate correctness
Section titled “Gate correctness”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 —
evaluateComplianceis handedframeworks: 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_closedmode, the gate escalates tostrictestConfiguredAction(judgeInput)— the strictest action that actually exists in this set’s policies. A notify-only set escalates tonotify, never a hard block;fail_openleaves the verdict atallow. - 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 downgradedblock → NOTIFYand 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 (historyTextis empty → identical to the old path). Turn-splitting lives inwarden/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:
- 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_verdictsGroq 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. - Model routing (escalation + resilience). See the table below — prod runs a 2-tier ladder:
gpt-oss-120bis the default/primary,llama-3.3-70bis the low-confidence/error escalation, andgpt-oss-20bis 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 noWARDEN_JUDGE_LADDERenv is set, is a 3-tiergpt-oss-20b → gpt-oss-120b → llamaladder — that is what the offline eval below was run on.) - Deterministic rules run only when the rule set’s
rulesEnabledtoggle is on (off by default) — they are an opt-in last-resort fallback, never the primary decision layer.rulesEnablednow gates ONLY the deterministicpreEvaluaterules (regex / allowlist / rate-limit / token-budget / PII-secret presence / prompt-injection). The NL / AI-judge policies and the selected frameworks always evaluate, whether or notrulesEnabledis on —const rulesEnabled = binding.rulesEnabled === true; const pre = rulesEnabled ? preEvaluate(...) : <empty>. - Compose most-restrictive across LLM + deterministic + the compliance pre-filter (
block > notify > allow). Anotifyfrom any layer logs a violation event but never blocks. The LLM judge is the single event source when it produces a verdict (the violation showsscanMode: judgewith the LLM’s plain-English reason); the deterministic compliance scan only logs when it is the fallback decider.
Model routing (judge)
Section titled “Model routing (judge)”| Role | Model | When it runs |
|---|---|---|
| Primary / default | openai/gpt-oss-120b (WARDEN_JUDGE_LADDER[0]) | every request — judges all framework + NL policies in one emit_verdicts call |
| Escalation | llama-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 backup | openai/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 correctviolated: trueverdict 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.
Post-hoc logs channel (collector agents)
Section titled “Post-hoc logs channel (collector agents)”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.
Environment
Section titled “Environment”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).
Frontend
Section titled “Frontend”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):
| Tab | Purpose |
|---|---|
| Overview | The 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 Rules | Since 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 & Keys | The 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 revamped Request detail
Section titled “The revamped Request detail”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 writesmatchedRuleIds, and latterlybusinessRuleIds/complianceRuleIdsseparately. 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.effVerdictexisted twice — in the modal and the table — and both copies fell through toALLOW. There is now one copy, inrequestDetailParts/privates. judge.retriesis read off a field nothing writes. Replaced withjudge.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.usageandjudge.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.declaredis 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
TrajectoryViewCortex uses, so an operator who can read one trajectory can read the other.
This widened what the gate SEES, on the notify side only. extractPromptText used to map
messages[].content alone, so an assistant message that is a pure tool call (content: null)
contributed nothing — every tool name and argument an agentic caller sent was invisible to the
compliance scan and to the judge, while role: "tool" results were scanned all along.
Tool content now reaches the scan, and it can only ever raise a flag. extractLatestUserText
still returns the latest user message, and latestText is the sole block-capable input —
evaluateRequestCompliance scans history with notifyOnly: true, and buildJudgeInput is
handed latestText. So a secret in a tool argument produces a NOTIFY with findings, never a
BLOCK: agentic clients resend their whole history every turn and tool arguments are
model-authored, so a block there would wedge a customer’s agent loop. Pinned by
tests/unit/warden/toolCalls.test.ts.
The tools[] definition array is still deliberately not scanned — definitions are
boilerplate resent every turn and false-positive on words like token. Their names are
recorded structurally as tools.declared instead.
Documents captured before this still fall back to reading request.rawBody, and the UI
says so rather than guessing. A JSON.parse failure does not fall back to a regex — a
truncated Cline body contains "role":"tool" inside its own system prompt’s documentation,
and scavenging it would render turns that never happened. The tab reports “capped at 25 KB,
inside message 1 of 14”, using request.messageCount, which is computed from the full parse
and is accurate even when the stored body is not.
Block response — strict vs verbose
Section titled “Block response — strict vs verbose”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_STRICT | Caller sees | Use |
|---|---|---|
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. |
Verbose mode used to return the reason verbatim, and that reason is assembled from two very different sources.
One is Logrite’s own metadata — rule ids, decision-layer names, index names, internal hosts and routes. Strict mode exists because those “leak the rule logic”; verbose mode is the setting that leaked them, and it is the mode you are told to turn on when a block needs debugging, so it is on exactly when someone is reading.
The other is the judge’s own prose about the prompt it just blocked — and a model explaining why something is protected health information quotes the protected health information. That string lands in a 403 body, which your SDK logs, your APM captures and your error tracker ships to a third party. The gate stopped the value reaching one provider and copied it into your logging pipeline instead. The block worked and the data still moved.
Since 2026-10-07 the judge is told never to quote or repeat a value from the conversation, and its reason is masked once, at compose time, before it is stored or returned anywhere — see What is stored, and what is masked. The 403 sanitiser below still runs on top.
The reason now passes through the same redactor the platform uses everywhere else: internal
hostnames, routes, env-var names, table and index names, repository names and container names are
removed, and value-shaped secrets and PII — keys, card numbers, national ids, email addresses —
are redacted. A reason that redaction has emptied falls back to the generic strict message rather
than shipping Blocked by Logrite AI Warden: [redacted] in [redacted], which tells an integrator
nothing and advertises that something was cut.
It is deliberately conservative, and it is conservative in prose it did not write: soc2: SSN detected in prompt comes back as soc2: SSN=*** in prompt, because the redactor reads
SSN <word> as an identifier-and-value pair. One clipped word in a debugging message costs less
than a customer’s SSN in an error body, and a second gentler redactor for this one surface would
be the copy that ships the secret.
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).
NL-first wizard
Section titled “NL-first wizard”BusinessRulesModal.jsx and this wizard are deleted. Business rules are edited in the shared
policy editor drawer — see Rules & Policies. Kept building blocks:
NlPolicyList, RuleRow, FrameworkPicker, LibraryRulesInline.
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+nlPoliciesand taggedsource='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-OFFrulesEnabledtoggle (which gated only the deterministic rules — NL policies + frameworks always evaluated). Save taggedsource='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.
Curated business templates
Section titled “Curated business templates”The starter templates moved to RulesPolicies/model/starterPolicies.js and open as policy drafts
(status Monitor only, all features) from the Policies section’s template gallery.
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:
| Group | Template | What it does |
|---|---|---|
| Compliance frameworks | HIPAA / Healthcare Guard | PHI protection (SSN/MRN/patient IDs) + block clinical diagnosis & dosing advice |
| SOC 2 / PCI | Block payment-card data, credentials, and account numbers | |
| GDPR Guard | Flag EU personal data + data-subject / erasure requests | |
| M-26-14 AI Gov | Federal OMB M-26-14: block PII, credentials, and AI-artifact extraction | |
| Business verticals | Legal Advisor Guard | Block legal advice, opinion drafting, and binding-document review; protect client PII |
| Financial / Fintech Guard | Block personalized investment/financial advice; protect credentials, account & card data | |
| Customer Support Guard | Flag competitor mentions, block unauthorized promises, stop jailbreaks | |
| System natives | Anti-Jailbreak Guard | Block prompt injection, instruction-override, role-play escapes, and encoding evasion |
| Enterprise Observe | Async monitoring — flag legal/competitive risk, confidentiality markers, HR-sensitive topics |
Template
mode/failModefields instarterRuleSets.jsare decorative metadata only —save()forwards justframeworks,rules, andnlPolicies; mode/failMode are hardcoded as above.
In-browser relay test
Section titled “In-browser relay test”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).
Sample payloads
Section titled “Sample payloads”| Sample | Message | Expected verdict (SOC2+HIPAA+GEN binding) |
|---|---|---|
| Clean | What is 2 + 2? | ALLOW |
| PHI / SSN | Patient Jane Doe, SSN 123-45-6789, DOB 1985-03-15, diagnosed with hypertension. | BLOCK — hipaa_log_leak_phi |
| Password | My password is SuperSecret123 and bearer token is eyJhbGciOiJIUzI1NiJ9.abc | BLOCK — soc2_log_leak_credentials |
| Credit card | Process 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).
Frontend constant
Section titled “Frontend constant”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.
cURL / Postman testing
Section titled “cURL / Postman testing”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.)
Replay via relay — cURL shape
Section titled “Replay via relay — cURL shape”A provider-specific replay command uses the request’s provider to set the correct path:
# Example for a violation from a Groq-routed requestcurl 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”Added after a customer test lost time to each of these in turn (findings W-6 and W-7).
Every statement below is asserted by tests/unit/warden/relayClientContract.test.ts in
logrite-ai-service, against the relay’s own routing table — so a change to the relay that
contradicts this page fails the build rather than silently leaving the page wrong.
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.
The model id must be one the upstream provider knows. gpt-4o-mini against a non-OpenAI
endpoint returns model_not_found from that provider, relayed through unchanged — this is not a
relay error and no rule fired.
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:
# your app already has these two — change the VALUES, not the namesOPENAI_BASE_URL=https://warden.logrite.ai/v1/<provider-name>OPENAI_API_KEY=lgw_live_YOUR_RELAY_KEYThe 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.
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_textblob is superseded by per-policy NL judge rules (warden_nl_judge_policy), each with its own action, sent together with framework-synthesized policies in oneemit_verdictscall — see Unified rule set + LLM-first judging. In the UI you now add an “AI judge prompt” rule (aprompt-type row) instead of a separate textarea.policy_textis 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.
How it works
Section titled “How it works”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:
| Mode | When judge fires | Hot-path impact |
|---|---|---|
inline_blocking | Before 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_observe | After forwarding (fire-and-forget via bounded queue) | Zero hot-path latency; BLOCK verdict patches the capture doc retroactively |
sampled | Only for the configured sampleRate % of requests | Reduces cost on high-volume bindings |
Judge deadlines: total vs per-tier
Section titled “Judge deadlines: total vs per-tier”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.
| knob | default | what it bounds |
|---|---|---|
WARDEN_JUDGE_TIMEOUT_MS | 15000 | the whole ladder, and what the caller waits |
WARDEN_JUDGE_TIER_TIMEOUT_MS | 12000 (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.
Writing effective policies
Section titled “Writing effective policies”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):
| Run | Accuracy | Notes |
|---|---|---|
report-baseline.md | 95.1% (39/41) | two misses — a base64-encoded jailbreak (→ ALLOW) and a GEN card number (→ NOTIFY instead of BLOCK) |
report-final.md | 100.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.
Example policies
Section titled “Example policies”# Keyword blockBLOCK any request that contains the word Apstine (case-insensitive). ALLOW all other requests.
# Topic restrictionThis AI assistant is ONLY for questions about logging, software observability,and compliance (SOC2, HIPAA, GDPR). BLOCK any off-topic request.
# CombinedBLOCK requests that mention competitor products (Datadog, Splunk, New Relic).BLOCK requests containing Apstine. ALLOW everything else.How the judge fires for inline_blocking
Section titled “How the judge fires for inline_blocking”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 providerCredentials 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:
| Credential | Relay Key | |
|---|---|---|
| What it stores | Your raw AI provider API key (sk-..., gsk_...) | A Logrite-issued proxy key (lgw_live_...) |
| Last4 shown | Last 4 chars of the provider API key (e.g. l4al) | Last 4 chars of the relay key itself (e.g. WiNU) |
| UI display | groq ••••l4al in Settings & Keys | relay ••••WiNU (groq) in Business Rules |
| Purpose | Authenticates to Groq/OpenAI/Anthropic | Identifies which requests go through AI Warden |
| Relationship | One credential can back multiple relay keys | Each 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 (endingWiNU,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 asrelay ••••WiNU (groq).
Settings & Keys — encryption and delete
Section titled “Settings & Keys — encryption and delete”Credential encryption
Section titled “Credential encryption”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.
Rotating the master key (2026-10-16)
Section titled “Rotating the master key (2026-10-16)”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.
WardenKekRewrapMigratorruns only when a previous key is set, once per current key: its marker iswarden-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:
- In one env change on the backend, set
WARDEN_KMS_MASTER_KEY_PREVIOUSto the old key andWARDEN_KMS_MASTER_KEYto the new one; the change recreates the backend. - Confirm the boot log has a
[kms-rewrap]line for each of the seven tables, and that theskippedcounts are what you expect (rows that were already unreadable stay unreadable). - 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).
This is the code path only. No rotation has been done on either estate. Leaving the previous key set is harmless to correctness but keeps the old key able to decrypt — remove it as soon as step 2 is confirmed.
Delete credential button
Section titled “Delete credential button”Each credential row in the Settings & Keys tab has a delete button (trash icon). The flow is:
- Click the trash icon — a confirmation prompt appears inline (“Delete this credential?”).
- Confirm →
DELETE /api/v1/warden/credentials/{id}— backend hard-deletes the row. - 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.
Rules & Policies audit (2026-10-21)
Section titled “Rules & Policies audit (2026-10-21)”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’sidandseverity; a Monitor-only policy never raisesmodeorfailMode(backend1987c06f). 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 toinline_blocking/fail_closed. See Rules & Policies → Enforcement per feature. - Each
rate_limitrule counts on its own. The counter key isrl:<relayKeyId>:<ruleId>and it is pruned by that rule’s own window (ai-service5797960f, CX-10). Before, everyrate_limitrule on a key sharedrl:<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 403policy_block. - Binding resolve survives a backend outage (ai-service
5797960f, CX-15). The last good binding per key is served for up toWARDEN_BINDING_MAX_STALE_MS(default900000= 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-service58b2f861, WD-10). A request on anasync_observebinding that no rule blocked before forwarding, and that was queued for the out-of-band judge, is captured withdecisionLayer: "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 asfail_open, which also means “the judge could not answer”.fail_opennow keeps only that meaning.- Seeded rows carry
judge.outcome(WD-9): the demo seeder writesjudge.outcome: "decided"for a seeded row whose judge ran. The demo-seeder service isreport_onlyon 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 oftimeout; Worksoft 302 — 9 real requests, allnot_requested(key 28 runsasync_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
failModehas no effect on anasync_observebinding, because nothing waits for that judge.
Security hardening (Jul 2026 audit)
Section titled “Security hardening (Jul 2026 audit)”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
ALLOWfor aninline_blocking+fail_closedbinding — 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:valuecontext 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
requireJwtWorkspacefails closed instead of resolving a claim-less token to the sharedlogritetenant; thewarden-requests-*cross-tenant wildcard fallback was removed; a legacy global-tenant relay index is derived from the key’s own binding, never a caller-suppliedX-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.
New / changed environment
Section titled “New / changed environment”| Var | Default | Meaning |
|---|---|---|
WARDEN_FAIL_OPEN | true | On an unexpected relay error: open = forward, closed = block. Now actually enforced. |
WARDEN_RATE_LIMIT_PER_MIN | 1200 | Per-relay-key request cap (0 disables). Per-replica. |
warden.kms.require-master-key | true | Refuse to boot without WARDEN_KMS_MASTER_KEY (dev/sqlite-test profiles override to false). |
WARDEN_KMS_MASTER_KEY_PREVIOUS | unset | Previous 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 where | Field | Masking |
|---|---|---|
warden-requests-* | request.redactedPromptPreview, response.redactedCompletionPreview | field names (WARDEN_MASK_VARIABLES) + all kinds, then clipped to 200 chars (captureDoc.ts) |
warden-requests-* | decisionReason, judge.reasoning | judge 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.rawBody | redactRaw — 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, rawResponse | the same redactRaw, same cap |
| Redis | warden: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):
| Value | Raw 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 |
Before 2026-10-07 the compliance events written by fanout.ts could carry rawRequest/rawResponse
regardless of WARDEN_CAPTURE_RAW. Each event’s own finalDecision now gates its raw fields,
so on_flagged means the same thing on both indices.
One redaction list, owned by the backend
Section titled “One redaction list, owned by the backend”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.
| # | Kind | Secret kind | Example |
|---|---|---|---|
| 1 | urlCredential | yes | postgres://admin:Hunter2@db:5432/x → postgres://***@db:5432/x |
| 2 | credentialPair | yes | admin:Hunter2 → *** (value must contain a letter and a digit, 4–128 chars) |
| 3 | email | [email protected] → *** | |
| 4 | ssn | 123-45-6789 → *** | |
| 5 | card | card 4111111111111111 for the order → card *** for the order (the following space is kept) | |
| 6 | iban | DE89370400440532013000 → *** | |
| 7 | secretKey | yes | sk-… / gsk_… with 16+ trailing characters → *** |
| 8 | opaqueToken | yes | any 32+ character [A-Za-z0-9_-] run → *** |
| 9 | longDigits | 123456789 (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=***.
What Cortex can read from Warden
Section titled “What Cortex can read from Warden”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.
# dry run (default): reads and counts, writes nothingdocker exec <warden-container> node dist/scripts/wardenRedactBackfill.js --dry-run# write the changes, then clear the Redis judge cachedocker 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.
| Configure | Settings → Notifications → Discord (Maintainer or Owner) |
| API | GET/PUT/DELETE /api/v1/integrations/discord/{workspaceId}/connection, POST …/test |
| Stored in | discord_notifications, one row per workspace — DDL in migrations/2026-discord-notifications.sql |
| Sent by | the backend: DiscordEvents#wardenVerdict, parts in WardenVerdictAlerts, cap in WardenAlertThrottle |
| Category | warden (“AI Warden alerts”) — must be selected on the channel |
Where it is sent from
Section titled “Where it is sent from”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.
What arrives
Section titled “What arrives”# 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.
A Discord channel is usually the widest audience in a company, often including contractors, and a request that tripped a HIPAA rule contains protected health information by definition. The decision reason is not even passed to the alert code: the judge’s one-line reason is exactly where a quoted credential was once found stored. The console behind the link shows it, masked.
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.
Which verdicts, and the cap
Section titled “Which verdicts, and the cap”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 blankrequestIdis 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).
The webhook URL is a credential
Section titled “The webhook URL is a credential”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_KEYand the GCM AAD bound tows:<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
channelHintlike…/webhooks/1234567890/…Abc9instead — 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.
Until 2026-10-07 the relay posted verdicts itself (warden/notify/discord.ts) with a webhook URL
and event list delivered on the binding resolve. That sender and the binding fields
discordWebhookUrl / discordEvents were deleted; the webhook URL now never leaves the backend.
The in-app bell (2026-10-07)
Section titled “The in-app bell (2026-10-07)”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.
The relay must not add, remove or reorder a message in a request it is proxying. A customer’s
payload is their own — their token count, their positional system prompt, their contract with their
provider — and rewriting it inside a compliance gateway would be a far worse defect than the one
being fixed. So the pin lives in the console’s own request builder
(AIWarden/components/relayTestRequest.js) and nowhere near the forward path.
It also cannot move a verdict. The gate blocks on the latest user turn
(turnSplit.ts#extractLatestUserText), so a leading system message joins only the notify-only
history scan. The console’s blocking presets — jailbreak, SSN, credentials — block exactly as
before, and a test pins the user turn as last for that reason.
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.
Close-out changes (2026-10-09)
Section titled “Close-out changes (2026-10-09)”Tool order rules
Section titled “Tool order rules”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:
blockon 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}:
reason | Meaning | Request detail note |
|---|---|---|
sensitive_block | withheld for PHI/PII | “Not stored: this request was blocked for personal or health data.” |
allowed_on_flagged | allowed, and the key captures raw only for blocked/flagged rows | “This request was allowed, and raw capture keeps only blocked or flagged requests.” |
capture_off | raw 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.
Redaction switches (2026-10-09)
Section titled “Redaction switches (2026-10-09)”What the relay masks is a workspace choice (Settings → Privacy → AI Warden redaction; keys in Settings):
warden.redaction.enabled(defaulttrue).falsestores and shows text as sent at every mask site: previews, raw body, tool-call timeline, block message, complianceredactedSnippet, judge reasons (including the async judge queue).warden.redaction.kindsandwarden.redaction.fieldschoose 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.updateaudit row withdetails.privacyChanges(key, old value, new value).
Findings, verdicts and the withholding above never depend on these switches.
One outcome field
Section titled “One outcome field”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.
Judge model and rule names
Section titled “Judge model and rule names”GET /api/v1/warden/judge→{"model": "<model id>"}, read from the relay’s/healthjudgeModel(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
settingsnever showsjudgeModel(backendc49f545e). The relay leaves it off the judge input and runs its one pinned model, yet every business policy migrated from a rule set carriesllama-3.3-70b-versatilethere.PolicyMapperdrops it from summaries and documents; the stored row is unchanged./warden/judgeis 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 deprecatedgroqModelalias was removed. - Every catalogue rule has a
displayName(e.g.hipaa_log_leak_phi→ “PHI in prompt, response or log”), served onGET /api/v1/warden/library/rules(optionallibraryKey); 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.
Close-out fix wave 2 (2026-10-09)
Section titled “Close-out fix wave 2 (2026-10-09)”- Final decision follows the outcome. The capture doc’s
review.responserecords the response-phase review (response scope and tool-order rules), andreview.finalequalsoutcome. Before this, a response or tool-order BLOCK leftreview.final = allowbecause the review covered only the request phase (ai-service 3cf0dc1). - Request detail shows why. The decision panel renders
decisionReasonand the deciding layer (for exampletool_order), and its “Final” row readsoutcome(frontend 3f09e19f). - The response scan reads authored text. When a model answers with
content: nulland puts its text inmessage.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 andcreatedlike a long number, so every word-list hit looked value-backed. - Wording for un-held answers. A
stream: falseanswer that was not held is no longer called “streamed” in the verdict text. outcomeis a keyword. Thewarden-requestsindex template mapsoutcomeaskeyword(backend 21b43cd2). It applies to indexes created after the change; older indexes keep their dynamic mapping, and Cortex falls back tooutcometext orverdictgrouping there.- “Avg added latency” shows — when unmeasured.
GET /api/v1/warden/statsreturnsavgTotalMs: nullwhen no doc in range carrieslatency.wardenMs(backend 3f75cee8), and the tile shows—instead of0 ms(frontend e889f7c0). Seeded demo captures now carrylatency.wardenMs.
Audit changes (2026-10-10)
Section titled “Audit changes (2026-10-10)”Documented from ai-service main at c8e7ef98.
Inline judge retry (2026-10-10)
Section titled “Inline judge retry (2026-10-10)”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 totalWARDEN_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;
judgeAsynckeeps 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.
Relay correctness
Section titled “Relay correctness”- 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
inputitems of typefunction_call, buffered answers fromoutput[]function_callitems, and streams fromresponse.output_item.addedevents. - Provider mismatch. A key whose binding has no custom
baseUrlused on another provider’s path (e.g. an OpenAI credential on/v1/anthropic/…) is refused with 400warden_provider_mismatch. Thetestprovider profile exists only underNODE_ENV=test. - Captures. An async-observe BLOCK or NOTIFY patches
outcomeandreview.finalalong withverdict; 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’sreason; 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”Measured on 2026-10-14 with an lgp_live_ key: GET /api/v1/warden/stats, /relay-keys,
/rulesets, /credentials, /providers, /requests, /governance/audit,
/maturity/assessment and /observability/connections all answered 401 “this endpoint does
not accept API keys”; only /library/frameworks answered 200. The cause:
ApiKeyWorkspace.readOnly read an ApiKeyPrincipal from the SecurityContext, which only the
API-chain filter sets — and SECURITY_API_CHAIN_ENABLED is off on both estates. MCP listed
these actions to keys, so every one failed live.
ApiKeyWorkspace.of(req)replacesreadOnly. It readsApiKeyAccessFilter.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:ApiKeyAccessFilteralready answers a read key’s writes with 403insufficient_scopebefore any controller runs. Callers:WardenControllerSupport.requireJwtWorkspace,GovernanceController,MaturityController.requireJwt,ObservabilityController,ObservabilityFeedController, andRelayComplianceControllerforGET /bindingandGET /effective-rulesetonly — the deprecatedPUT /bindinghas no role floor and still requires a session.- Role floors evaluate the key’s minter.
RoleFloorGuard.callerFrommaps a verified key tousers.findById(createdByUserId)(401invalid tokenwhen 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 toAgentControllerdelete. - Full and admin keys may write Warden configuration, subject to those floors (decision of
2026-10-14; the old comment in
WardenLibraryControllerthat relay-key and credential routes must never accept a credential is replaced). Relay-key/revealstays closed to every key. A read key may also POST/api/v1/governance/verifyand the policy dry runs/api/v1/policies/{id}/testand/api/v1/policies/effective/test(the oldevaluate-logroutes are removed). - Actors name the key.
WardenControllerSupport.extractActorreturns<minter email> (API key …<last4>)for a key, the JWT username otherwise; suppressions and every new audit row use it.
Compliance-event reads
Section titled “Compliance-event reads”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):
| Param | Rule |
|---|---|
hours | number or all; default 168; 0/all = all time |
fromDate, toDate | ISO; win over hours; toDate requires fromDate |
framework, rule, requestId, q | ≤200 chars; q is a simple_query_string over rule, framework, complianceRef, reason, callerUser |
finalDecision | allow, notify or block |
severity | critical, high, medium, low or info |
relayKeyId | long; a term on providerKeyId |
from | ≥0 |
limit | 1–200, default 25 |
sortDir | asc 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.
Removing a suppression
Section titled “Removing a suppression”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.
Testing a relay key
Section titled “Testing a relay key”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:
| Provider | Path | Auth header |
|---|---|---|
anthropic | /v1/anthropic/messages | x-api-key |
azure_openai | — | refused, 422 |
| every other | /v1/<provider>/chat/completions | Authorization: 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}.
| Refusal | Status |
|---|---|
relay_key_not_found | 404 |
relay_key_not_active | 409 |
relay_key_has_no_credential | 409 |
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}.
GET /credentials used to blank the envelope fields (ciphertext, iv, auth tag, wrapped DEK) on
the entities the repository returned, “for the response”. With open-session-in-view those
entities stay managed for the whole request, so any later write in the same request flushes the
blanks to the database. The relay-key test is such a request — it lists credentials, then
inserts its audit row — and one test_key on dev wiped all three provider credentials of
workspace 3103, including the one eight relay keys forward through. Since backend 098dc897
the list answers detached copies (the shape create already returned) and never touches the
stored row; WardenCredentialListingTest pins it. The neuralwatt row was re-encrypted through
the backend’s own create path and copied back; two placeholder openai test rows (last4 xxxx,
KEY>) could not be restored, their plaintext never having been known. The test was never run
on prod before the fix.
Audit rows that were missing
Section titled “Audit rows that were missing”Written through GovernanceAuditService.record (via WardenAuditTrail) with
actor = extractActor(req), next to the existing warden_relay_key_minted and
warden_relay_key_attached:
| Action | Details |
|---|---|
warden_credential_added | credentialId, provider, last4 |
warden_credential_removed | credentialId |
warden_credential_tested | credentialId, ok |
warden_relay_key_rotated / _revoked | keyId |
warden_relay_key_updated | keyId, fields — a PATCH’s changed fields other than ruleSetIds; not written when none |
warden_relay_key_tested | keyId, httpStatus, verdict |
warden_agent_ruleset_attached / _detached | Removed 2026-10-15 with the per-agent routes; policy writes audit policy_* |
warden_suppression_removed | id, 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) andasyncJudgeQueue.tscompute the ISO instant once per call and write it totimestampand@timestamp. - Existing events are backfilled per estate with an
_update_by_queryoverrelay-compliance-events-*that sets@timestampfromtimestampwhere it is missing, run after the relay change is live. Check:_countwithmust_not exists @timestampis 0. RelayComplianceEventsTemplateEnsurer(sibling ofWardenRequestsTemplateEnsurer, same idempotent PUT, fail-soft, off withwarden.template.ensure.enabled=false) installs_index_template/relay-compliance-eventsat priority 200 for new indices:@timestampandtimestampasdate; the enum fields (framework,rule,finalDecision,severity,scanMode,requestId, …) barekeyword;redactedSnippetindex:false;rawRequestandrawResponsestored but not indexed; any other stringtext+keyword(ignore_above256).
Measured live on 2026-10-14: dev’s relay-compliance-events-* follow a hand-installed template,
logrite-relay-compliance-events (priority 100), with bare keyword enums; prod’s are dynamically
mapped — text with a .keyword subfield, providerKeyId as long. The new template is
transcribed from dev’s and outranks it, but applies only to indices created after it. Anything
that aggregates this family must therefore read the live mapping, as the stats route does; the
event filters match both shapes.