Security
The security layer adds three independent, composable features to a Rag.NET pipeline:
- RBAC on chunks — filters retrieved chunks to those the current caller is allowed to see.
- PII detection and redaction — scrubs personal data from chunk text at ingest time, before embeddings are stored.
- Audit log — records every retrieval and answer event to a SQLite database for compliance and forensics.
All three are opt-in. Register any combination of them through the RagBuilder API. They have no mandatory coupling — you can use the audit log without RBAC, or PII redaction without the audit log.
Security posture
Rag.NET is a RAG library with opt-in security features, not a security product. Everything on this page is off until you register it. This section states what the project claims, what it leaves to you, and where its current dependency advisories stand — read it before the feature guides below.
The four families, and where each is documented
| Family | Defends against | Documented |
|---|---|---|
| RBAC on chunks | a caller retrieving chunks they should not see | below |
| PII detection and redaction | personal data reaching the vector store | below |
| Audit log | having no record of what was retrieved or answered | below |
| Prompt-injection defences — chunk and query sanitisation, retrieval guards, prompt hardening | attacker-controlled content hijacking the model at query time | below |
All four families are documented on this page.
Defaults that fail open
Two features on this page do nothing by default when the metadata they depend on is absent. Both are deliberate, both are documented in their own sections, and both will silently protect nothing if you register them over content that was ingested before you did.
RBAC: untagged chunks are world-readable
Chunks that do not carry the key are world-readable and pass through for every caller.
A chunk without an allowed_roles metadata key is visible to everyone. If you register RBAC
expecting deny-by-default, you do not have it.
This is deliberate. The alternative — deny anything untagged — would hide every previously-ingested chunk the moment RBAC is registered on an existing corpus, turning a security feature into a silent outage. Tagging at ingest is the mechanism, and untagged content is treated as public because that is what it was before you turned the feature on.
If you need deny-by-default, tag every document at ingest and treat an untagged chunk as a bug in your ingestion, not in your retrieval.
Trust levels: untagged chunks are internal
UseTrustLevelGuard reads a trust_level metadata key and treats its absence as internal —
the most trusted value. Registered over a corpus ingested without trust tagging, it drops nothing.
Same reasoning as RBAC, same consequence: the feature is real, and it protects exactly the content
you tagged. See UseTrustLevelGuard treats untagged content as trusted.
A third thing worth knowing, which is narrower scope rather than a fail-open default:
UseQuerySanitiser applies to AskAsync and AskStreamingAsync and not to RetrieveAsync — so
a retrieval-only caller gets no query sanitisation. See
UseQuerySanitiser does not apply to RetrieveAsync.
What the library does not do
It filters retrieved chunks, redacts at ingest, records an audit trail, and sanitises text. It does not:
- authenticate your end users — you supply an
ICallerContext; the library never establishes who the caller is, - encrypt anything at rest — that belongs to your vector store and your disk,
- manage keys or secrets — API keys are read from your configuration,
- secure the backing store — an unsecured Qdrant or pgvector reachable from the internet is reachable whatever this library does.
The hosting surfaces force an authentication decision
Four packages expose a network surface: Rag.NET.Api, Rag.NET.Api.Grpc, Rag.NET.Mcp and
Rag.NET.Mcp.AspNetCore. Rag.NET.Mcp.Tool is a self-contained executable of the same server,
configured from appsettings.json or the environment.
Rag.NET.Api.Client and Rag.NET.Api.Grpc.Client consume rather than serve, and their security
relevance is the mirror image: they hold the API key. Where it comes from, how it reaches the
process, and whether it ends up in a log or a crash dump are the consuming application's
responsibility — the clients read it from the configuration you give them and send it on every
call.
None of them serves an unauthenticated surface by accident, and the two mechanisms differ:
Rag.NET.Mcp.AspNetCoreattaches its API-key filter to the same convention builder that maps the endpoints, so "mapped but unauthenticated" is not expressible.MapRagNetMcpthrows if the transport was never configured.Rag.NET.Apicannot do that — registration and mapping happen on two builders that cannot see each other — soMapRagNetApidetects instead, throwing when options are missing, when the authentication middleware is absent, and when any of its routes has been made auth-exempt.
AllowAnonymous exists on the MCP transport as a real opt-out for a host already behind an
authenticating gateway. The guard separates someone decided this from nobody thought about it;
it does not make the anonymous case impossible.
The limitation worth knowing: the API key is a shared secret, not an identity. Every client presenting it is indistinguishable from every other, and there is no revocation short of changing the key and redeploying everything that holds it. It is a deployment boundary, not an authorization model — use it behind a gateway that does identity if you need per-client control.
Dependency advisories
As of 2026-09-11 the repository carries five open Dependabot advisories. None of them is in the dependency closure of any published NuGet package.
| Package | Severity | Where it lives | Patch |
|---|---|---|---|
image-size (×2) | high | Docusaurus, which builds this documentation site | none available |
nltk | high | benchmarks/library-comparison-python/, a comparison harness | none available |
qs (×2) | medium | webpack-dev-server, reached only by npm start | pinned to 6.16.0 here |
If you install any Rag.NET package, none of the above enters your dependency graph — they belong to
this repository's own tooling. The two unpatched advisories have no fix available upstream; the one
that did has been pinned in package.json's overrides block.
Watching the model boundary
Every security feature on this page acts before the model is called. Sanitisers run at ingest and before retrieval, guards run on retrieved chunks, prompt hardening runs at assembly. Nothing in Rag.NET inspects what comes back.
IConfidenceScorer looks like it might, and does not: it scores whether a sentence is supported by
the retrieved context, on a 0–1 scale, and fails open at 1.0 when it cannot score. That is a
groundedness signal for answer quality. It is not an inspection of the response for a secret.
The concrete consequence: a credential that survives ingest-time redaction, sits in a chunk, and gets summarised back to a user is not seen by any part of this library.
The shape of the answer
Rag.NET resolves IChatClient from DI. Anything that decorates IChatClient therefore sits between
Rag.NET and the model, and sees both directions — the prompt as sent, and the response as returned.
That is the insertion point for a monitor, and it needs nothing from Rag.NET: no package reference,
no abstraction, no integration.
A worked example
AI.Sentinel is one such monitor — IChatClient
middleware that scans both directions through a detector pipeline and can block, alert or log.
Disclosure: AI.Sentinel is written by the same author as Rag.NET. It is named here because it is a concrete example that was actually tested against this library, not because Rag.NET depends on it or recommends it over alternatives. Any
IChatClientdecorator composes the same way.
// 1. The monitor, wrapping your provider client.
// Severity policy and detector configuration are AI.Sentinel's own -- see its documentation.
services.AddAISentinel(opts => { /* ... */ });
services.AddChatClient(new OpenAIChatClient(/* ... */)).UseAISentinel();
// 2. Rag.NET afterwards. The order matters -- see below.
services.AddRagNet(b => b
.UseRbac()
.UseCostBudgeting(o => o.DailyLimit = 10m));
Register the monitor first
Rag.NET's own IChatClient decorators rewrite the DI descriptor, so they can only wrap what is
already registered. UseCostBudgeting and UseFallbackChain both do this. Register your monitor
after them and Rag.NET's decorator wraps nothing.
That is not silent. Resolving the pipeline throws:
UseCostBudgeting is not applied to the IChatClient this container resolves. It decorates whatever is registered at the moment it runs, and this IChatClient was registered (or replaced) afterwards, so the feature UseCostBudgeting configures is silently absent. Move the UseCostBudgeting call after the IChatClient registration. This is checked when the RAG pipeline is resolved because a registration made later cannot be seen at registration time.
Two details worth knowing, both measured on 2026-09-11:
- The check runs when the pipeline is resolved, not when
IChatClientis. Resolving the chat client alone succeeds and hands back the bare monitor, so a smoke test that only resolvesIChatClientwill report a composition that is in fact broken. ResolveIRagPipeline. - In the correct order, Rag.NET's decorators wrap around the monitor — the resolved client is
CostTrackingChatClient→ your monitor → your provider. That is the right nesting: the monitor sits closest to the model, so it sees the prompt exactly as sent and the response exactly as returned.
What overlaps, and what that costs
A monitor is not purely additive with the defences on this page.
| Rag.NET | A model-boundary monitor | |
|---|---|---|
| Prompt injection | query sanitisers, retrieval guards, prompt hardening — before the call | detectors on the assembled prompt — at the call |
| PII | redacted at ingest, before embedding | detected in the response |
| Credentials in a response | not covered | covered |
| Groundedness | IConfidenceScorer against retrieved context | hallucination detectors |
Prompt injection is covered twice, by different means. Whether that is defence in depth or duplicated cost depends on your configuration — two LLM-backed passes over every query is a real expense. Rag.NET's regex sanitisers are cheap; its LLM sanitiser and a monitor's LLM-escalating detectors are not. Decide deliberately rather than enabling both because each page recommends it.
Verify detection against your own configuration
Registration is not protection, and the two look identical from outside. A monitor with detectors registered but a dependency unset — an embedding generator, a classifier client — can scan clean and report nothing wrong. That was observed in a bare configuration during the 2026-09-11 testing, with injection detectors present.
Send a known-bad prompt through your configured pipeline and confirm it is caught, before relying on it. This applies to Rag.NET's own sanitisers equally.
Version compatibility, as measured
AI.Sentinel 2.0.1 targets net8.0/net9.0 while Rag.NET targets net10.0, and it builds against
ZeroAlloc.Mediator 4.1.4 and ZeroAlloc.ValueObjects 1.7.1 where Rag.NET pins 5.0.1 and 2.0.5 —
two major versions apart, which NuGet resolves in favour of the higher.
Verified on 2026-09-11 against those exact versions: the container builds, IChatClient resolves
to SentinelChatClient, 55 detectors resolve and construct, and a scan completes without
MissingMethodException or TypeLoadException.
What that check does not cover: it exercised construction and the scan path, not every detector's internals. A detector reaching a changed API only on an alert path was not reached. Both packages move independently, so treat this as a dated observation rather than a standing guarantee, and re-run it for the versions you actually deploy.
Prompt injection defences
Indirect prompt injection is the primary security risk specific to RAG: attacker-controlled content — a document, a scraped page, an email — carries instructions that hijack the model when a query happens to retrieve it. The content is data to you and instructions to the model.
Rag.NET.Security defends in four layers. They are only comprehensible positionally, so this
table is in pipeline order rather than alphabetical:
| Layer | Interface | Registration | Runs |
|---|---|---|---|
| Chunk sanitisation | IChunkSanitiser | UseChunkSanitiser / UseLlmChunkSanitiser | at ingest, before embedding |
| Query sanitisation | IQuerySanitiser | UseQuerySanitiser / UseLlmQuerySanitiser | before retrieval |
| Retrieval guards | IRetrievalGuard | UseRetrievalGuard / UseTrustLevelGuard | on retrieved chunks |
| Prompt hardening | answer-engine decorator | UsePromptHardening | at answer assembly |
All are opt-in and independent. Registering none of them is the default.
Two extension points you already know
UseRbac registers an IRetrievalGuard. RbacRetrievalGuard is the same kind of object as
RegexRetrievalGuard and TrustLevelRetrievalGuard, in the same chain — so RBAC and the injection
guards compose by registration order like any other chain, and there is no separate "RBAC pipeline"
to reason about.
IChunkSanitiser is shared with PII redaction. UsePiiDetection and UseChunkSanitiser register
into the same ordered chain, so the chaining rules in
Chaining regex and LLM detection apply unchanged — sanitisers run
in registration order, and each sees the previous one's output.
The regex/LLM pairing
Three of the four layers ship a cheap deterministic implementation and an expensive semantic one, registerable independently or together. This is the same shape as PII detection, and the same trade-off: regex is free and literal, the LLM variant costs a model call per item and catches paraphrase.
The regex implementations share one pattern, case-insensitive with a 1000 ms match timeout. It
targets role-switch phrases (ignore previous instructions, you are now, act as, disregard,
new instructions, system prompt) and delimiter injection (<|system|>, <|user|>, [INST],
### instruction). Matches are replaced with [REDACTED] and logged with the matched pattern.
They fail open. If sanitisation throws — a regex timeout on a pathological input, an LLM call
failing — the original text is returned unchanged and the failure is logged. A sanitiser that threw
would take the whole request down; one that returns unsanitised text does not, and says so in the
log. The Use*Llm* variants resolve IChatClient from DI and throw at container resolution if none
is registered.
UseQuerySanitiser does not apply to RetrieveAsync
Query sanitisation is applied by a pipeline decorator that wraps AskAsync and AskStreamingAsync.
RetrieveAsync forwards the query unchanged.
If you use Rag.NET for retrieval only — fetching chunks and generating elsewhere — registering
UseQuerySanitiser has no effect on that path. There is a reasonable argument for it: injection
hijacks a model, RetrieveAsync reaches none, and redacting act as from a legitimate query about
acting would cost recall for no security gain. Sanitise at your own generation boundary if that is
where your model call happens.
UseTrustLevelGuard treats untagged content as trusted
A chunk with no trust_level metadata is read as internal — the most trusted value. So
registering the guard over a corpus that was ingested without trust tagging drops nothing, and
does so silently.
This is the same shape as RBAC's world-readable default, and for the same reason: a guard that hid every untagged chunk the moment it was registered would turn a security feature into an outage.
trust_level is set at ingest, by whatever pulls the content — a web crawler or email connector
should mark what it fetches as external or untrusted. TrustLevelGuardOptions then decides what
happens: DropUntrusted (default true) removes untrusted chunks, and WarnOnExternal (default
true) logs when external ones are retrieved.
Prompt hardening
UsePromptHardening decorates the answer engine with a system prefix instructing the model to treat
retrieved content strictly as data. The default says so explicitly; PromptHardeningOptions.SystemPrefix
replaces it.
This is the layer that assumes the others leaked. It costs nothing per request and is the cheapest thing on this page.
Confirming a guard actually ran
Both retrieval guards emit a ragnet.security.guard activity carrying security.guard.type
(regex, trustlevel) and security.guard.action (redact, drop). Redactions and drops are also
logged with the document id.
Registration is not protection — a guard registered over untagged content, or a sanitiser whose pattern does not match your attacker, is silent in exactly the way a working one is. Send known-bad content through and confirm the activity fires before relying on it.
Composing the layers
services.AddRagNet(b => b
.UseChunkSanitiser() // ingest: strip injection patterns before embedding
.UseQuerySanitiser() // pre-retrieval: strip them from the query too (AskAsync only)
.UseRetrievalGuard() // retrieved chunks: redact what survived
.UseTrustLevelGuard(o => o.DropUntrusted = true)
.UsePromptHardening()); // answer: tell the model the content is data
Each is independent — register the layers you want. The LLM variants (UseLlmChunkSanitiser,
UseLlmQuerySanitiser) slot in beside their regex counterparts and require a registered
IChatClient.
RBAC on Chunks
Role-based access control filters retrieved chunks based on an allowed_roles metadata key. Chunks that do not carry the key are world-readable and pass through for every caller.
Tagging documents at ingest
Attach an allowed_roles entry in DocumentMetadata.Tags when ingesting a document. The value is a comma-separated, case-insensitive list of role names:
await pipeline.IngestAsync(stream, new DocumentMetadata
{
DocumentId = new DocumentId("hr-handbook-2024"),
FileName = "hr-handbook-2024.pdf",
Tags = new Dictionary<string, MetadataValue>
{
["allowed_roles"] = "hr,finance",
},
});
MetadataBehavior propagates all Tags entries into each chunk's metadata dictionary at ingest time. After ingestion every chunk produced from the document carries allowed_roles = "hr,finance" in its stored metadata.
Chunks from documents that have no allowed_roles tag are always returned, regardless of who is calling.
ICallerContext
RbacRetrievalGuard resolves the caller's roles at retrieval time by calling ICallerContext.GetRoles(). The interface is framework-agnostic:
public interface ICallerContext
{
IReadOnlyList<string> GetRoles();
}
Implement it as a singleton. For non-web hosts (console, worker service, batch job) use AsyncLocal<IReadOnlyList<string>> to flow roles per logical call:
public sealed class AsyncLocalCallerContext : ICallerContext
{
private static readonly AsyncLocal<IReadOnlyList<string>> _roles = new();
public static void SetRoles(IReadOnlyList<string> roles) =>
_roles.Value = roles;
public IReadOnlyList<string> GetRoles() =>
_roles.Value ?? [];
}
Register it before calling UseRbac():
services.AddSingleton<ICallerContext, AsyncLocalCallerContext>();
services.AddRagNet(b => b.UseRbac());
ASP.NET Core integration
The Rag.NET.Security.AspNetCore package provides ClaimsPrincipalCallerContext, which reads roles from the current ClaimsPrincipal via IHttpContextAccessor. Call AddRagNetAspNetCoreSecurity() after AddRagNet:
services.AddRagNet(b => b.UseRbac());
services.AddRagNetAspNetCoreSecurity();
AddRagNetAspNetCoreSecurity also calls AddHttpContextAccessor(), so no separate registration is needed.
Registration
services.AddRagNet(b => b
.UseRbac());
Note:
UseRbac()resolvesICallerContextas a required service. If no implementation is registered, the application will throwInvalidOperationExceptionat first retrieval. Always registerICallerContextbefore the pipeline is first used — either viaAddRagNetAspNetCoreSecurity()or a custom implementation.
PII Detection and Redaction
PII detection protects stored embeddings by scrubbing personal data from chunk text at ingest time, before any data reaches the vector store. It is implemented by IChunkSanitiser and runs as part of the ingestion pipeline.
Two modes are available: regex-based and LLM-based. They can be registered independently or chained — multiple IChunkSanitiser registrations are applied in order.
Regex detection (UsePiiDetection)
Uses compiled regular expressions to find and replace PII patterns. All built-in patterns are active by default:
services.AddRagNet(b => b
.UsePiiDetection());
Built-in patterns
| Pattern | Placeholder | Matches |
|---|---|---|
PiiPatterns.Email | [EMAIL] | RFC 5321 email addresses |
PiiPatterns.Phone | [PHONE] | US phone numbers (various formats, optional country code) |
PiiPatterns.Ssn | [SSN] | US Social Security numbers (\d{3}-\d{2}-\d{4}) |
PiiPatterns.CreditCard | [CREDIT_CARD] | Visa, Mastercard, Discover, Amex card numbers |
PiiPatterns.IpAddress | [IP_ADDRESS] | IPv4 and IPv6 addresses |
A chunk containing "Contact john@example.com or call 555-867-5309" becomes "Contact [EMAIL] or call [PHONE]".
Configuring patterns
Add custom patterns or remove built-ins by configuring PiiDetectionOptions:
services.AddRagNet(b => b
.UsePiiDetection(o =>
{
// Remove SSN detection
o.Patterns.Remove(PiiPatterns.Ssn);
// Add a custom pattern — Dutch BSN number
o.Patterns.Add(new PiiPattern
{
Placeholder = "[BSN]",
RegexPattern = @"\b\d{9}\b",
});
}));
PiiDetectionOptions.Patterns is pre-populated with PiiPatterns.Defaults. Any modification before IChunkSanitiser is constructed takes effect immediately — patterns are compiled once at construction time.
Timeout protection
Each regex is evaluated with a 1-second timeout. If a regex times out (pathological backtracking on a long chunk), the sanitiser logs a warning and returns the original text unchanged. Downstream processing is never blocked.
LLM detection (UseLlmPiiDetection)
Asks a registered IChatClient to identify and redact PII. Covers patterns that are difficult to express as regular expressions (names, addresses, contextual identifiers):
services.AddRagNet(b => b
.UseLlmPiiDetection());
An IChatClient must be registered in DI before calling UseLlmPiiDetection().
Chaining regex and LLM detection
Register both to apply regex redaction first, then pass the partially-redacted text to the LLM:
services.AddRagNet(b => b
.UsePiiDetection() // regex pass — fast, deterministic
.UseLlmPiiDetection()); // LLM pass — catches names, addresses, context-dependent PII
Sanitisers are applied in registration order. The LLM sees already-redacted text, which reduces both cost and hallucination risk.
Audit Log
The audit log captures every retrieval and answer event to a SQLite database. It is designed for compliance scenarios: who retrieved which chunks, when, and (optionally) what they asked.
What is captured
AuditRetrievalEvent — written after every RetrieveAsync call:
| Field | Type | Notes |
|---|---|---|
RequestId | string | Shared with the corresponding AuditAnswerEvent |
Timestamp | DateTimeOffset | UTC timestamp of the retrieval |
CallerRoles | IReadOnlyList<string> | Roles from ICallerContext, or empty list when RBAC is not active |
Chunks | IReadOnlyList<AuditChunkRef> | DocumentId, ChunkIndex, and Score for each returned chunk |
Query | string? | Raw query text — only populated when LogQueryText = true |
AuditAnswerEvent — written after every AskAsync call:
| Field | Type | Notes |
|---|---|---|
RequestId | string | Matches the RequestId of the preceding AuditRetrievalEvent |
Timestamp | DateTimeOffset | UTC timestamp of answer generation |
Answer | string? | Generated answer text — only populated when LogAnswerText = true |
Privacy defaults
By default neither the query text nor the answer text is stored:
public sealed class AuditLogOptions
{
public bool LogQueryText { get; set; } = false;
public bool LogAnswerText { get; set; } = false;
public string DatabasePath { get; set; } = "rag-audit.db";
}
Enable them explicitly when your compliance policy requires it:
services.AddRagNet(b => b
.UseSqliteAuditLog(o =>
{
o.LogQueryText = true;
o.LogAnswerText = true;
o.DatabasePath = "/var/data/audit.db";
}));
Correlation
AuditRetrievalBehavior generates a RequestId (a Guid) after retrieval and stores it in AuditCorrelationContext, which is an AsyncLocal-backed singleton. AuditAnswerEngineDecorator reads the same RequestId from AuditCorrelationContext when writing the answer event. No extra setup is required — the correlation is automatic for any call that goes through AskAsync (which calls retrieval then generation in sequence).
If you call RetrieveAsync and AskAsync independently (e.g., streaming scenarios), the RequestId flows correctly as long as both calls share the same async execution context.
Registration
services.AddRagNet(b => b
.UseSqliteAuditLog(o =>
{
o.LogQueryText = true;
o.DatabasePath = "/var/data/audit.db";
}));
The SQLite database and tables are created lazily on the first write. Write failures are logged as warnings and never thrown — the pipeline continues normally even if the audit log is unavailable.
SQLite tables
The audit database contains two tables:
CREATE TABLE retrieval_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
timestamp TEXT NOT NULL, -- ISO 8601 UTC
caller_roles TEXT NOT NULL, -- JSON array of strings
chunks TEXT NOT NULL, -- JSON array of {documentId, chunkIndex, score}
query TEXT -- NULL unless LogQueryText = true
);
CREATE TABLE answer_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
timestamp TEXT NOT NULL, -- ISO 8601 UTC
answer TEXT -- NULL unless LogAnswerText = true
);
Query all retrieval events for a user role in the last 24 hours:
SELECT request_id, timestamp, caller_roles, chunks
FROM retrieval_events
WHERE json_each.value = 'hr'
AND timestamp >= datetime('now', '-1 day')
FROM retrieval_events, json_each(caller_roles);
Join retrieval and answer events by request_id:
SELECT r.timestamp, r.caller_roles, r.query, a.answer
FROM retrieval_events r
JOIN answer_events a ON a.request_id = r.request_id
ORDER BY r.timestamp DESC
LIMIT 100;
Composing Features
All three features are independent and compose freely. Register them in a single AddRagNet block:
// ASP.NET Core — RBAC + PII redaction + audit log
services.AddRagNet(b => b
.UseRbac()
.UsePiiDetection(o =>
{
o.Patterns.Remove(PiiPatterns.Ssn); // not applicable for this corpus
})
.UseLlmPiiDetection()
.UseSqliteAuditLog(o =>
{
o.LogQueryText = true;
o.DatabasePath = "/var/data/audit.db";
}));
services.AddRagNetAspNetCoreSecurity(); // wires ClaimsPrincipalCallerContext for UseRbac
The registration order within the builder determines sanitiser chain order (regex before LLM). RBAC filtering and audit logging are independent of registration order relative to PII.
Migrating from 0.1.0 —
UseAuditLogis gone. SQLite-backed audit logging moved to its own package so thatRag.NET.Securityno longer carriesMicrosoft.Data.Sqliteand a nativeSQLitePCLRawbinary for everyone usingUseChunkSanitiser,UseRbacorUsePiiDetection(#339). Two steps, and nousingchanges — the namespace is deliberately unchanged:
- Add a package reference to
Rag.NET.Security.Audit.Sqlite.- Rename
UseAuditLog(…)toUseSqliteAuditLog(…).Forgetting step 2 is a compile error, not a silent gap. The wiring that registers the audit behaviour and the answer decorator is internal to
Rag.NET.Securityand reachable only from a package that also supplies anIAuditLog, so "auditing configured, nothing recorded" cannot be expressed. An audit log that silently records nothing is worse than a build error.
Note:
UseSqliteAuditLogmust be called afterAddRagNetso thatRetrievalPipelineBuilderis already registered in DI. Calling it beforeAddRagNetthrowsInvalidOperationException.
Answer auditing is independent of registration order relative to the answer engines. UseSqliteAuditLog adds its decorator to the answer-engine decorations RagPipeline applies when it composes its engine, so rag.UseSqliteAuditLog().UseMapReduceAnswerEngine() and the reverse both audit every answer. Both used to register IAnswerEngine directly, so last-wins dropped whichever ran first — while retrieval auditing kept working, leaving an audit log that read as complete and recorded no answers at all (#195). Resolving IAnswerEngine yields the registered engine, undecorated; ComposedAnswerEngine is the audited one.