Skip to main content

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​

FamilyDefends againstDocumented
RBAC on chunksa caller retrieving chunks they should not seebelow
PII detection and redactionpersonal data reaching the vector storebelow
Audit loghaving no record of what was retrieved or answeredbelow
Prompt-injection defences — chunk and query sanitisation, retrieval guards, prompt hardeningattacker-controlled content hijacking the model at query timebelow

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.AspNetCore attaches its API-key filter to the same convention builder that maps the endpoints, so "mapped but unauthenticated" is not expressible. MapRagNetMcp throws if the transport was never configured.
  • Rag.NET.Api cannot do that — registration and mapping happen on two builders that cannot see each other — so MapRagNetApi detects 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.

PackageSeverityWhere it livesPatch
image-size (×2)highDocusaurus, which builds this documentation sitenone available
nltkhighbenchmarks/library-comparison-python/, a comparison harnessnone available
qs (×2)mediumwebpack-dev-server, reached only by npm startpinned 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 IChatClient decorator 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 IChatClient is. Resolving the chat client alone succeeds and hands back the bare monitor, so a smoke test that only resolves IChatClient will report a composition that is in fact broken. Resolve IRagPipeline.
  • 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.NETA model-boundary monitor
Prompt injectionquery sanitisers, retrieval guards, prompt hardening — before the calldetectors on the assembled prompt — at the call
PIIredacted at ingest, before embeddingdetected in the response
Credentials in a responsenot coveredcovered
GroundednessIConfidenceScorer against retrieved contexthallucination 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:

LayerInterfaceRegistrationRuns
Chunk sanitisationIChunkSanitiserUseChunkSanitiser / UseLlmChunkSanitiserat ingest, before embedding
Query sanitisationIQuerySanitiserUseQuerySanitiser / UseLlmQuerySanitiserbefore retrieval
Retrieval guardsIRetrievalGuardUseRetrievalGuard / UseTrustLevelGuardon retrieved chunks
Prompt hardeninganswer-engine decoratorUsePromptHardeningat 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() resolves ICallerContext as a required service. If no implementation is registered, the application will throw InvalidOperationException at first retrieval. Always register ICallerContext before the pipeline is first used — either via AddRagNetAspNetCoreSecurity() 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​

PatternPlaceholderMatches
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:

FieldTypeNotes
RequestIdstringShared with the corresponding AuditAnswerEvent
TimestampDateTimeOffsetUTC timestamp of the retrieval
CallerRolesIReadOnlyList<string>Roles from ICallerContext, or empty list when RBAC is not active
ChunksIReadOnlyList<AuditChunkRef>DocumentId, ChunkIndex, and Score for each returned chunk
Querystring?Raw query text — only populated when LogQueryText = true

AuditAnswerEvent — written after every AskAsync call:

FieldTypeNotes
RequestIdstringMatches the RequestId of the preceding AuditRetrievalEvent
TimestampDateTimeOffsetUTC timestamp of answer generation
Answerstring?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 — UseAuditLog is gone. SQLite-backed audit logging moved to its own package so that Rag.NET.Security no longer carries Microsoft.Data.Sqlite and a native SQLitePCLRaw binary for everyone using UseChunkSanitiser, UseRbac or UsePiiDetection (#339). Two steps, and no using changes — the namespace is deliberately unchanged:

  1. Add a package reference to Rag.NET.Security.Audit.Sqlite.
  2. Rename UseAuditLog(…) to UseSqliteAuditLog(…).

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.Security and reachable only from a package that also supplies an IAuditLog, so "auditing configured, nothing recorded" cannot be expressed. An audit log that silently records nothing is worse than a build error.

Note: UseSqliteAuditLog must be called after AddRagNet so that RetrievalPipelineBuilder is already registered in DI. Calling it before AddRagNet throws InvalidOperationException.

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.