An AI That Can Say “I Don’t Know”
Writing the refusal and citation contract in .NET 10 code, not in the prompt.
In my previous article I wrote one sentence: "Behind every answer the model gives, there is either a verifiable source or an explicit refusal. There is no third option."
Then some private messages came in. Some said "I agree". Others looked like this: "Fine, but where do we actually write that? In the system prompt?"
That question is exactly where this series starts: a contract is not something you write in the system prompt. For the model, a system prompt is usually not a binding mechanism; it's an instruction it is expected to follow. What we call a contract is a rule that can stop the system when it's violated. If we don't enforce it in code, we haven't written a contract, just a statement of good intentions.
In this article we go inside the real code I mentioned last time. Which contract lives where, why it lives there, and what the system does when it's broken: I'll walk through all of it in code.
Azure is the subject of the next article, and I'm deliberately staying away from it here. A good contract should make sense without being tied to Azure, AWS or any other cloud provider.
Part 1The Domain defines the contract, the Application enforces it
The first question: which layer does this contract belong to?
The usual first answer is the Application layer. Most .NET developers put the refusal logic inside the handler and manage the flow from there. That isn't entirely wrong, but it misses something important.
The Application layer doesn't own the contract. Its job is to apply the contract and orchestrate the outcome. The contract itself should live in the Domain, because questions like "when do we answer, when do we refuse, when does the system stop?" are business rules before they are technical flow.
In practice it looks like this:
public sealed record AssistantAnswer
{
public required string AnswerText { get; init; }
public required IReadOnlyList<Citation> Citations { get; init; }
public required ConfidenceLevel ConfidenceLevel { get; init; }
public required RiskClass RiskClass { get; init; }
public required bool EscalationRequired { get; init; }
public required bool Refused { get; init; }
public string? RefusalReason { get; init; }
public static AssistantAnswer Grounded(
string answerText,
IReadOnlyList<Citation> citations,
ConfidenceLevel confidenceLevel,
RiskAssessment riskAssessment)
{
ArgumentNullException.ThrowIfNull(citations);
if (citations.Count is 0)
{
throw new UngroundedAnswerException();
}
return new AssistantAnswer { /* ... */ };
}
public static AssistantAnswer RefusedAnswer(string reason, RiskAssessment riskAssessment) =>
new()
{
AnswerText = reason,
Citations = [],
ConfidenceLevel = ConfidenceLevel.Low,
RiskClass = riskAssessment.RiskClass,
EscalationRequired = riskAssessment.EscalationRequired,
Refused = true,
RefusalReason = reason
};
public void EnsureCitationInvariant()
{
if (!Refused && Citations.Count is 0)
{
throw new UngroundedAnswerException();
}
}
}There are three things worth noticing in this code.
First, an AssistantAnswer is created in exactly two places: Grounded(...) and RefusedAnswer(...). What I don't want is someone, somewhere in the codebase, writing new AssistantAnswer { ... }, leaving the citations empty and letting that answer out through the API. At that point the contract is broken.
So the normal way to create the object is through factory methods. If you use Grounded(...), you have to provide citations; try to pass an empty list and an exception is thrown. On the RefusedAnswer(...) side I don't expect citations, because a refusal isn't a sourced answer, it's a safe redirection.
That leaves two valid states in the system: either you produce an answer backed by sources and citations, or you deliberately return a refusal. The third path I want to close off is this: an answer with no citations. In live systems, I think this is one of the most dangerous situations. The system looks like it's answering, but there's no verifiable source behind the answer.
Second, the EnsureCitationInvariant() method. There's no need to make "domain invariant" sound bigger than it is here; we're talking about a one-sentence rule: an answer that isn't a refusal must have at least one citation.
I don't want to leave that check to the handler's good intentions. Handlers change, mappings change, response models change. During a refactor, the citations could end up empty by accident. That's why the Application layer calls this method at the end of the orchestrator. If the answer isn't a refusal and has no citations, the system blows up before it returns a response.
To me, that's exactly what a contract is. Not "let's please add citations", but "no citations, no answer out the door". Absolute certainty.
Third: I didn't model refusal as Result<T>.Failure. It looks like a small decision, but it has a big effect on how the system behaves. A refusal is not an error.
If a user asks "what's the dosage for this medication?" and the system says "I can't answer that, I'm routing you to the clinical team" because it couldn't find enough sources, the system hasn't broken. On the contrary, it's doing exactly what it should.
If I modeled that as a Failure, the API would start treating it like a technical error: it returns a 500, the frontend shows a red error, and the user thinks the system crashed. But nothing crashed; the system is behaving safely. So a refusal is also a successful AssistantAnswer. The API returns 200. The frontend shows it as a controlled redirection rather than an error, and the user is guided to the right next step instead of seeing a technical failure.
Is there a cost? Yes. Writing tests means setting up a few more objects. Sometimes, to build an AssistantAnswer, you also need related models like RiskAssessment. It puts a small burden on the developer (thankfully, we can have LLMs write those now).
We should be willing to pay that price, because in return it becomes almost impossible to create an AssistantAnswer that has no citations but looks like a real answer (nothing is 100% certain; 99.9% at best). That's precisely the boundary the Domain protects.
Part 2The orchestrator: what a short flow looks like in real life
In the first article I deliberately kept the orchestrator simple: classify risk → search → generate → audit. On paper it looks like an eight-line flow. In the real code it grows to around 120 lines.
Is that bloat? I don't think so. Every extra line corresponds to a contract somewhere in the system, a place where we've said "this is how it must work". The code looks longer, but every block draws a boundary: where identity comes from, when risk is calculated, when the model is called, when an answer is refused, which decision gets written to the audit log.
public async ValueTask<Result<AssistantAnswer>> HandleAsync(
AssistantQuery request,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
// Identity comes from IUserContextProvider, not from the request body.
// Even if the body carries userId/roles, the retrieval filter is fed from here.
request = request with
{
UserId = userContextProvider.UserId,
Roles = userContextProvider.Roles,
Location = userContextProvider.Location
};
var validation = await queryValidator.ValidateAsync(request, ct).ConfigureAwait(false);
if (!validation.IsValid)
{
throw new InvalidAssistantQueryException(validation.ToString("; "));
}
var startTimestamp = Stopwatch.GetTimestamp();
var mode = agentOptions.Value.Mode;
metrics.RecordProviderMode(mode);
var risk = await riskClassifier.ClassifyAsync(request, ct).ConfigureAwait(false);
metrics.RecordRiskClass(risk.RiskClass, mode);
var chunks = await knowledgeSearchService.SearchAsync(request, risk, ct).ConfigureAwait(false);
metrics.RecordRetrievalCount(chunks.Count, mode);
// REFUSAL #1 — No sources? Don't go to the model at all.
if (chunks.Count is 0)
{
var refused = AssistantAnswer.RefusedAnswer(NoSourceRefusalReason, risk);
await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(refused);
}
var template = await promptProvider.GetAsync(AnswerTemplateId, ct);
var messages = BuildMessages(template, request, chunks);
var chatResponse = await chatClient.GetResponseAsync(messages, cancellationToken: ct);
// REFUSAL #2 — The model broke the JSON schema: reject the answer.
if (!ChatResponseParser.TryParse(chatResponse.Text, out var envelope) || envelope is null)
{
var refused = AssistantAnswer.RefusedAnswer(MalformedResponseReason, risk);
await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(refused);
}
// REFUSAL #3 — The model itself says "I can't": pass that on as structured data.
if (envelope.Refused)
{
var refused = AssistantAnswer.RefusedAnswer(envelope.RefusalReason ?? envelope.AnswerText, risk);
await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(refused);
}
// REFUSAL #4 — The model returned a made-up citation ID: reject the answer.
var citationValidation = CitationValidator.Validate(envelope.Citations, chunks);
if (citationValidation.Outcome is not CitationValidationOutcome.Valid)
{
var refused = AssistantAnswer.RefusedAnswer(InvalidCitationReason, risk);
await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(refused);
}
var chunkLookup = chunks.ToDictionary(chunk => chunk.ChunkId, StringComparer.Ordinal);
var citations = envelope.Citations
.Select(id => chunkLookup[id].ToCitation())
.ToArray();
var answer = AssistantAnswer.Grounded(
envelope.AnswerText,
citations,
MapConfidenceLevel(envelope.Confidence),
risk);
answer.EnsureCitationInvariant();
await WriteAuditAsync(request, answer, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(answer);
}Let's look at this code a little more closely.
The order isn't accidental. IRiskClassifier runs before retrieval, because IKnowledgeSearchService.SearchAsync(query, risk, ct) takes the risk as a parameter. That means: if a question is low-risk, you can fetch fewer chunks. If it's high-risk, you need to search more sources, filter more strictly and produce the answer in a more controlled way.
So the risk → retrieval relationship isn't a technical detail; it's a business rule. That's why I want it to be visible inside the orchestrator. Hide it somewhere else and one of the system's most important decisions becomes unreadable.
Another important line:
request = request with
{
UserId = userContextProvider.UserId,
Roles = userContextProvider.Roles,
Location = userContextProvider.Location
};It looks small, but it matters a lot for security. The handler doesn't trust any userId, roles or location values that might have arrived in the request body; it overwrites them from IUserContextProvider. So even if a user sends the API a body like this:
{
"question": "...",
"roles": ["supervisor"]
}retrieval doesn't use that role. The system only considers the role that comes from the authentication context.
You could say, "we don't even allow those fields in the body." True: the DTO uses JsonUnmappedMemberHandling.Disallow, and a request like that comes back with a 400. But we added a second shield here anyway. JsonUnmappedMemberHandling.Disallow is not a full JSON schema validator; it only rejects unexpected fields. Required fields, nullability, enum mapping and response parsing behavior still need their own tests.
I see this as defense in depth. The first layer is in the request model, the second in the application flow. Data derived from identity should come from the system's trusted context, not from the body.
There are four separate refusal points in the code:
- If there are no sources, we don't call the model at all. Without sources, the model's answer will be either general knowledge or a guess, and in this system we want neither. So when no chunks are found, we return
no_source_refusaldirectly. - If the model breaks the JSON format, we reject the answer. Producing text isn't enough; we expect a structured response that matches a specific schema. If the schema is broken, the response can't be trusted.
- If the model itself says "I can't answer this", we carry that as a structured refusal rather than an error. Instead of losing the model's decision somewhere in free text, we turn it into a
RefusedAnswerobject the system understands. - If the model invents a citation, we reject the answer. If the citation IDs the model returns aren't among the chunks that retrieval actually produced, that answer must not go out.
Each of these is written to the audit log with its own RefusalReason. I think that's very important, because in production, saying "our hallucination rate is X" doesn't tell you much. Being able to see this is far more valuable:
Four refusals, four different fixes
| Refusal | Reason code | What happened | Model called? | If it’s rising, look at… |
|---|---|---|---|---|
| #1 | no_source_refusal | There’s no source in the knowledge base. | No | Your knowledge base may have gaps. |
| #2 | malformed_response | The model broke the expected JSON schema. | Yes | The prompt template or the response schema. |
| #3 | model_self_refusal | The model chose not to answer. | Yes | Revisit the risk policy or the prompt’s behavior. |
| #4 | invalid_citation | The model returned an invalid citation. | Yes | The model may not be using source IDs correctly. |
That distinction changes what you do next. The same "no answer came back" can have four completely different causes. Throw them into one error bucket and you won't know what to fix. (This drifts into system design territory, a much deeper subject, so I won't dive into it here.)
Another deliberate choice is on the audit side: the audit record is always written. Not only for successful answers, but for refusals too. Refusing is also a decision the system makes, and in some industries it may be the decision that most needs to be explained.
Six months from now, when someone asks "why didn't you answer this user?", the system has to be able to say: the request with this correlation ID was classified with this risk level, retrieval returned this many chunks, the model produced this output, and the system refused the answer for this reason. If you can't say that, a refusal is just a safe-looking message on a screen. It isn't an auditable decision.
You may have noticed the audit write is repeated in several places. Is that a small DRY violation? Yes. Could it be moved into a helper? Yes, I tried. But in this example, pulling it into a helper made the orchestrator harder to read. Being able to see exactly what happens on each refusal path matters more to me, so I accept the small repetition on purpose. Call it my own style.
Then there's ConfigureAwait(false). I use it after every await in the Application layer, because in a sense that layer should behave like a library and not assume which host is calling it. I deliberately leave it out of the API layer; ASP.NET Core's default behavior largely covers that need. But the Application layer should stay independent of the host.
Without drowning you in more code and .NET, let me summarize the whole thing in one diagram:
Orchestrator: from question to answer, through four refusal gates
- 01Question arrives
- 02Identity stamped from UserContextuserId, roles, location. Never from the request body.
- 03Validation passed?400 · InvalidAssistantQueryException
- 04Risk classifyThe risk is passed into the search
- 05Knowledge search
- 06Any chunks?#1 · no_source_refusal
- 07IChatClient call
- 08JSON parsed?#2 · malformed_response
- 09Did the model refuse?#3 · model_self_refusal
- 10Citations in the chunks?#4 · invalid_citation
- 11Grounded answerEnsureCitationInvariant()
- 12AuditWritten on every path
- 13200 OK · AssistantAnswer
{
"refused": false,
"citations": ["CHK-001", "CHK-004"],
"confidenceLevel": "High",
"riskClass": "Low"
}Every check passed, and EnsureCitationInvariant() checked the final boundary one more time.
{
"title": "Invalid assistant query",
"status": 400
}Not an assistant answer at all. The flow stops before risk, search or the model.
no_source_refusal{
"refused": true,
"refusalReason": "no_source_refusal",
"citations": [],
"riskClass": "Medium"
}No chunks, no model call. Refusal #1 fires before a single token is spent.
malformed_response{
"refused": true,
"refusalReason": "malformed_response",
"citations": [],
"riskClass": "Medium"
}The model added a bonus_info field. Unmapped members are disallowed, so the response doesn’t count as parsed.
model_self_refusal{
"refused": true,
"refusalReason": "model_self_refusal",
"citations": [],
"riskClass": "Medium"
}The model said it couldn’t answer. That decision is carried as structured data, not lost in free text.
invalid_citation{
"refused": true,
"refusalReason": "invalid_citation",
"citations": [],
"riskClass": "Medium"
}The model returned CHK-009. Retrieval only returned CHK-001…CHK-004.
Each decision point represents a different security boundary:
- If validation fails, this is no longer an assistant answer; it's a bad request that returns 400.
- If there are no sources, we don't go to the model; we return
no_source_refusal. - If the model doesn't match the expected JSON schema, we return
malformed_response. - If the model says it can't answer, we carry that as a structured
model_self_refusal. - If the model invents a citation, we stop the answer with
invalid_citation.
Only when all of these checks pass do we produce a grounded answer, and at the very end we check the final boundary again with EnsureCitationInvariant(). Whichever path we came through, the audit record is written and the API returns 200 OK with an AssistantAnswer.
I want to stress one thing here: a refusal doesn't mean the system failed. A refusal is a successful assistant outcome, because the system showed the safety behavior we expected. The real errors are producing an answer without sources, inventing citations, or showing the user a model output that couldn't be parsed.
Part 3The model's word isn't enough: citations must be verified by the system
In the previous article I said you shouldn't leave citations to the model's language skills. A citation should count as valid not because the model says "I got this from that source", but because the system verifies it.
In .NET, that sentence turns into a small class: CitationValidator. But before we get to it, I need to explain why I made this decision.
In my first attempt, the mock chat client worked like this: the model received the list of retrieved chunks and put markers like [1] and [2] into the answer text. I parsed the answer and matched those markers to indexes in the list. I wrote a test, it passed. At first glance it all looked perfectly reasonable.
Then I tried a nastier scenario. I cut the retrieved chunk list down to two items but told the mock to act as if there were three. The model wrote [3] in its answer. My parsing code naturally tried to read chunks[2], and an IndexOutOfRangeException blew up.
When I saw the error, I understood the real problem: trusting a citation that the model writes inside its text is as fragile as blindly trusting a list index. The model doesn't have to know which source it actually relied on; it only has to look as if it's speaking in the right format. You send three chunks and it cites a fourth. Or, because it saw something similar in its training data, it invents a source ID that doesn't exist, like [Document_47].
At that point I dropped the text-marker approach and switched to a structured citation field. I no longer expect the model to squeeze markers like [1] or [2] into the answer text. I ask for a separate citations list, and then the system checks whether the IDs in that list are actually among the chunks that came from retrieval. Instead of an uncontrolled IndexOutOfRangeException, I get a controlled outcome:
public static CitationValidationResult Validate(
IReadOnlyList<string> citations,
IReadOnlyList<RetrievedChunk> retrievedChunks)
{
if (citations.Count is 0)
{
return new CitationValidationResult(CitationValidationOutcome.Empty);
}
var whitelist = retrievedChunks
.Select(chunk => chunk.ChunkId)
.ToHashSet(StringComparer.Ordinal);
var unknown = citations
.Where(id => !whitelist.Contains(id))
.ToArray();
return unknown.Length > 0
? new CitationValidationResult(CitationValidationOutcome.Unknown, unknown)
: new CitationValidationResult(CitationValidationOutcome.Valid);
}When the orchestrator sees this, it doesn't let the answer out; it turns it into a refusal.
This happened to me in the proof-of-concept code I wrote for this article, but I think anyone building a RAG system will hit the same problem at some point. A model producing a citation is not the same as that citation actually supporting the answer. The study by Wallat and colleagues, Correctness is not Faithfulness in RAG Attributions, shows that a correct-looking citation doesn't mean the model really relied on that document, and that in some settings citation faithfulness gaps reach up to 57%. Buchmann and Gurevych separately define the citation failure problem and propose ways to reduce it. The citation is there; whether it's really the source of the answer is another matter.
By the way, what I expect from the model isn't plain text but a specific JSON envelope:
internal sealed record AssistantAnswerEnvelope
{
public required string AnswerText { get; init; }
public required IReadOnlyList<string> Citations { get; init; }
public string? Confidence { get; init; }
public bool Refused { get; init; }
public string? RefusalReason { get; init; }
}If an extra property shows up in this envelope, the parser rejects it. Say the model adds a field called bonus_info. It may look harmless, but to me it signals one of two things: either the prompt didn't work the way I expected, or someone tried to steer the model into a different format. Both are a problem if the goal is a safe answer. That's why I use JsonSerializerOptions.UnmappedMemberHandling = Disallow. The model can only return the fields we expect; anything extra and the response doesn't count as parsed, so the orchestrator sends it down the malformed_response refusal path.
What CitationValidator does is really simple: the envelope.Citations list returned by the model has to be contained in retrievedChunks.Select(c => c.ChunkId), the IDs that came from retrieval. A single foreign ID and the outcome is Unknown. When the orchestrator sees that, it doesn't show the answer to the user. The answer may look right, read well, even sound confident. But if its citation ID isn't among the chunks the system actually retrieved, it isn't valid as far as I'm concerned.
There's a small but important detail here: StringComparer.Ordinal. I don't want a culture-sensitive comparison for chunk IDs. CHK-001 and chk-001 are not the same thing. These IDs aren't produced by users, they're produced by the system, so case sensitivity isn't a preference here, it's a requirement.
Text markers vs. structured citations
| Scenario | Text-marker approach | Structured citation |
|---|---|---|
| 1The model writes [5], but there are only 3 chunks | IndexOutOfRangeException, or an empty citationchunks[4] → IndexOutOfRangeException | Unknown outcome → refusal[5] isn’t on the whitelist |
| 2The model writes [1], but chunk 1 doesn’t actually support the answer | Looks like evidence | Not caught here either. Semantic checking is a separate problem. |
| 3The model invents an ID like [DOC_NEW] | The marker pattern may not match; the answer can slip out with no citation | Not on the whitelist → refusal |
| 4The model returns an empty citations field | A plain-text answer can be taken as trustworthy | Empty outcome → refusalcitations: [] |
| 5The model returns plain text instead of JSON | The parsing logic can breakJsonSerializer.Deserialize → exception | TryParse is false → malformed_response refusalTryParse(…) → false |
I'm deliberately not hiding the second row of this table: CitationValidator does not solve semantic correctness. It doesn't answer "does this chunk really support this claim?" What it does is more basic, and critical: it checks whether the citation IDs the model returned are actually among the chunks the system retrieved.
These are two problems that shouldn't be confused. The first is structural validation: the model must not cite a source that doesn't exist. The second is semantic validation: does an existing source actually support the answer? This article is about the first.
In the previous article I focused on setting up the system's boundaries first: which answers may go out, which must be stopped, and when to return a refusal. Semantic quality, faithfulness scores and a separate grounding evaluation are the second layer that gets built on top of that. (That part goes a bit deeper into AI; if I don't lose my motivation, we'll get to it.)
In short, the order for me is: first the structure of the system has to be right, then the quality of the answers gets measured with deeper metrics. Because if the structure is wrong, no quality metric will save you. If the model can cite a source that doesn't exist, how fluent the answer is becomes a secondary concern.
Part 4Audit records what the system decided, not who asked what
We're drifting into general architecture again, but this matters.
When people hear "audit log", they sometimes think: "Let's record everything and look at it later if we need to." I think that's a very dangerous approach. An audit log is not an unlimited data dump. Especially in systems that may contain personal data, keeping raw user questions for years "just in case" is not the right decision. Six months from now, when someone asks "why are you still storing this user's raw question?", you need an answer that holds up not only technically, but legally and operationally.
Our answer to that question: we don't store it. In code, that comes down to two small methods:
public static string ComputeQuestionHash(string question)
{
ArgumentNullException.ThrowIfNull(question);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(question));
return Convert.ToHexString(bytes);
}
public static string BuildQuestionPreview(string question)
{
ArgumentNullException.ThrowIfNull(question);
var redacted = SensitiveNumberRedactor.Redact(question);
return redacted.Length <= 80
? redacted
: string.Concat(redacted.AsSpan(0, 80), "…");
}The first method produces the SHA-256 hash of the question: a 64-character value that is deterministic and can't be reversed. Being deterministic matters: when the same question comes in again, it produces the same hash. That lets the audit side answer questions like "how many times was this same question asked?", but you can't go back from the hash to read the user's actual question.
The second method produces a short preview of the question. Before it does, SensitiveNumberRedactor kicks in: national ID numbers, card numbers and similar sensitive patterns are masked. Then the text is cut down to 80 characters. So the audit record holds two things:
QuestionHash→ to track the same question over time,QuestionPreview→ to get a rough idea of what the question was about.
The raw question is not written to the audit record. That separation is critical to me, because an audit record can be kept for a long time, say 7 years. Request logs that contain raw user questions should be kept for much shorter, say 24 hours or 30 days.
Log vs. audit
A long-lived record of decisions
- QuestionHash · SHA-256
- QuestionPreview · masked, 80 chars
- RiskClass · RefusalReason · RetrievalCount
- Never the raw question
Short-lived debugging support
- Raw question, inside the request scope
- e.g. Application Insights
- Deleted when retention expires
Example retention periods from the article, drawn to the same scale
Mix the two up, and over time your audit system turns into an archive of personal data. At that point the security value of the audit starts turning into data retention risk.
Another important decision is how the audit write itself is handled. In the code, the call to IAuditEventSink.WriteAsync runs inside a try/catch:
try
{
await auditEventSink.WriteAsync(auditEvent, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Audit write failed for correlation {CorrelationId}; continuing.",
auditEvent.CorrelationId);
metrics.RecordAuditWriteFailed(mode);
}The question here: if the audit record can't be written, should we still return an answer to the user? Say SQL or Elastic is temporarily down. The system has produced its answer and made its refusal-or-grounded decision, but the audit event couldn't be written.
There are two approaches. The first is audit-or-fail: if the audit can't be written, don't return the answer either; if the system can't record its decision, return a 503. That can make sense in heavily regulated systems, because you don't want an unauditable decision going out. The second is best-effort: return the answer even if the audit fails, but don't let it pass silently. Write a warning to the log, increment a metric, raise an alert in your observability stack.
Keeping the audit this way has a cost: debugging gets harder, because the audit record has no raw question. When a user says "the bot gave me a nonsense answer", you can't open the audit table and read their exact question. You see the hash, the masked preview, the risk class, the refusal reason, the chunk count and the correlation ID. That's a deliberate choice.
The fix isn't to pollute the audit; it's to design short-lived logging properly. If you need the raw question, it can be written to a short-retention log within the request scope, for example kept in Application Insights for 24 hours or 30 days and deleted when that expires. The audit stays for a long time but never carries the raw question. The line I'm trying to draw is this:
An audit record doesn't exist to store exactly what the user typed. It exists to prove which decision the system made, and why.
What an audit record tells you
Nine fields that make up the full decision trail for one question. Hover or tap a step.
Where each field comes from
Audit record · example
- CorrelationId0HN4Q7K2B1M9RTies every log and service record for this request together.
- QuestionHash9F2C51…A7E1SHA-256 of the question. Same question, same hash, so you can count repeats.
- QuestionPreview“My ID is ***********, when is my…”Masked, cut to 80 characters. A rough idea of the topic.
- RiskClassMediumWhich risk policy the system acted under.
- RetrievalCount0How many chunks retrieval found.
- RefusalReasonno_source_refusalWhy the answer was refused, if it was.
- ProviderModeDevCloudWhich provider was behind the call.
- Latency412 msTotal response time, for performance and SLA tracking.
- Timestamp2026-05-18T09:41:07ZWhen the record was written (UTC).
- Was the model called?RefusalReason = no_source means it never was.
- Why was it refused?RefusalReason says it explicitly.
- Did the audit write succeed?If not: metrics.RecordAuditWriteFailed() and a warning log.
I think that's exactly what audit should do in safe AI systems: not watch the user, but make the system's decisions auditable.
Part 5How do you know a contract is really a contract?
I think the answer is simple: if its violation can be tested, it's a contract. If it can't, it's just a statement of intent.
Sentences like "citations must be mandatory", "no sources, no answer" or "if the model invents a citation, reject it" aren't architectural decisions on their own. You can only prove they've become real system behavior through tests.
There are more than 70 tests in the repository right now. I won't list them all here (I'll make the project public on GitHub as the series goes on), but I want to show the seven tests that directly verify this article's main argument:
Seven tests, seven sentences from the article
HandleAsync_NoRetrievedChunks_ReturnsRefusedAnswer→ No source, no answer.Handler_ModelReturnsUnknownCitation_ReturnsRefusal→ Even if the model invents a chunk ID, the system refuses.Handler_DoesNotUseTextMarkerOnly_AsGroundingProof→ A [1] in the answer text isn’t proof of grounding. Only structured citations count.Handler_PromptInjectionAttemptInUserMessage_DoesNotOverrideSystemPrompt→ “Forget the previous rules” doesn’t override the system prompt.AssistantAnswer_GroundedFactory_WithEmptyCitations_ThrowsUngroundedAnswerException→ The domain invariant is guaranteed at runtime, not at compile time.Audit_Question_StoredAsHashAndPreview_NotRaw→ The raw question never reaches the audit. A hash and a masked preview do.Audit_SqlOutage_IncrementsAuditWriteFailedMetric→ Best-effort audit really works: on a SQL outage the counter goes up and the response still returns.
Each of these tests actually verifies a sentence from the article. When I talked about the "refusal contract" and the "citation contract" in the first article, this is exactly what I meant. They're not rules written into a prompt; they have counterparts in code. What the system does when they're violated is defined. And most importantly, that behavior can be tested. If the test doesn't pass, there is no contract.
Part 6Next phase: moving to Azure
I deliberately stayed away from Azure in this article, because I think good architecture shows itself in one place: when the context changes, do the contracts still hold?
In the previous article I built the system on mocks. Behind IKnowledgeSearchService sat a MockKnowledgeSearchService doing simple keyword matching over in-memory chunks. It can look like a toy at first, but that's the point: being able to test the contracts in the Application and Domain layers without connecting to Azure, OpenAI or any other external service.
In the next step, an AzureSearchKnowledgeService goes behind that interface, which means search gets wired to Azure AI Search, and retrieval mechanisms closer to production, like keyword search, vector search and the semantic ranker, come into play. MockChatClient gets replaced by a registered Azure OpenAI chat client. The goal stays the same: the vendor changes, the handler doesn't.
Microsoft.Extensions.AI.IChatClient already offers a meaningful abstraction for exactly that separation. The Application layer shouldn't care which provider is behind it. Whether it's OpenAI, Azure OpenAI or some other model down the road, the contracts in the Domain and Application layers should not be affected.
Closing
In the first article I introduced an idea: the system contract. In this one I tried to show where that contract actually gets written. We wrote it into the 120 lines of the orchestrator in the Application layer. Into a single invariant method in the Domain. Into the 15-line check inside CitationValidator, and finally into four separate refusal points.
But they all have one thing in common:
A contract has to be able to stop the system when it's violated. If it can't, it's no longer a contract; at best, it's a well-meaning wish.
The compensation Air Canada paid because of its chatbot, the sanctions in Mata v. Avianca, the apology in court on Anthropic's side… In every one of these cases the underlying problem was very similar: the model couldn't stop where it wasn't sure, couldn't say it didn't know, or the system didn't question the model's output enough.
Safe answer flow: the model talks, the system decides
- The user’s question arrivesidentity, context, location
- Validationtechnically and rule-wise valid?
- Risk classificationlow / medium / high
- Retrievalsearch the right sources
no_source_refusal- Model callsystem prompt + question + chunks → JSON envelope
- Does the JSON parse?
- Refusal #2
malformed_response - Did the model say “I can’t”?
- Refusal #3
model_self_refusal - Are all citations in the retrieved chunks?
- Refusal #4
invalid_citation
- Grounded
- With valid citations
- With a confidence level
- Audit record written
If a contract fails, a refusal goes out instead, with the reason explained to the user.
- Set boundaries Send the right question to the model.
- Check Audit the model’s answer against the contracts.
- Answer Let it out only if the contracts pass.
Evidence and sources
Legal cases and incidents
- Moffatt v. Air Canada, 2024 BCCRT 149
- Mata v. Avianca, Inc., 678 F. Supp. 3d 443 (S.D.N.Y. 2023). Sanctions order: 22 June 2023.
- Concord Music Group v. Anthropic PBC (N.D. Cal., May 2025). TechCrunch
Academic work
- Buchmann, Gurevych. Citation Failure: Definition, Analysis and Efficient Mitigation (arXiv 2510.20303, October 2025)
- Wallat et al. Correctness is not Faithfulness in RAG Attributions (citation post-rationalization finding, up to 57%)
- Stanford RegLab, Magesh et al. Hallucination-Free? Assessing the Reliability of Leading AI Legal Research Tools (May 2024)
.NET and Microsoft.Extensions.AI
- .NET 10 announcement (11 November 2025)
- Microsoft.Extensions.AI IChatClient API
- Stephen Toub — GitHub Discussion #5498
- JsonUnmappedMemberHandling reference
The ideas, architectural approach and technical assessments in this article are my own. AI-assisted tools were used for visuals, editing, coding and formatting. This English version is a translation of the original Turkish article.