Onboarding guide
27. Common AI Integration
What this chapter covers. This group is the framework's language-model boundary: the optional
MMCA.Common.AI package that turns a call to an LLM provider into an ordinary, governed external
dependency. Four questions shape every type in it. What is this call allowed to do? (a pinned
model, a clamped output ceiling, a wall-clock budget, a policy-gated tool list, an optional input
ceiling). What may it say, and what may it answer? (redactors that rewrite a request before
anything reads it, and guardrails that can refuse the request, the response or a single streamed
fragment). What did it cost, how long did it take, and which prompt spent it? (two token counters
plus a latency histogram on one framework-wide meter, all tagged by prompt name, version and
provider, with the same prompt identity stamped on the trace). Which exact prompt produced this
answer? (a versioned contract whose SHA-256 hash is what an evaluation gate records against). The
33 types answering those questions ship in four packages. The core MMCA.Common.AI holds the
settings, the composition entry point, four delegating clients (bounds, guardrails, metering, prompt
tagging), the guardrail, redactor and tool-policy contracts with the two content policies the
framework does ship (and the internal helper they share), the provider-factory contract and its startup validator, a meter and the prompt
contract. Two adapter packages, MMCA.Common.AI.Anthropic and MMCA.Common.AI.OpenAI, each add one
provider factory and one registration method. MMCA.Common.AI.Testing is the offline evaluation
harness a consumer's test project subclasses. The decision record is
ADR-120, which extends
the feature-level record
ADR-111.
If you have not yet met the extension(T) DI convention or the fail-fast options pattern, skim
primer section 4 first: this chapter composes both
and does not re-teach them.
[Rubric section 16, AI-Native Application Architecture] is the category this whole group exists to
answer. Section 16 asks whether a model dependency is isolated, versioned, evaluated, observed and
bounded in what it may do, rather than being a free-form HTTP call buried in a feature. The package's
shape is a literal reading of that list: isolation is the single IChatClient registration over an
IAiProviderFactory, versioning is PromptContract,
evaluation is GoldenReplayTestsBase and
PromptContractPinTestsBase, observation is
AiUsageMeter plus PromptTaggingChatClient, the
configured bounds are BoundedChatClient with
IChatToolPolicy deciding which tools survive them, and the content rules on top
are IChatRequestRedactor and IChatGuardrail run by
GuardrailChatClient. [Rubric section 2, Design Patterns] also applies
throughout, because the mechanism is the decorator: every layer is a DelegatingChatClient wrapping
the next, and the provider is a strategy selected by name.
The configuration surface is the contract
AiSettings (MMCA.Common/Source/Core/MMCA.Common.AI/AiSettings.cs:22) binds from the
Ai section (AiSettings.cs:25) and holds every knob the package has: Enabled (:39), Provider
(:48), Model (:58), ApiKey (:69), the optional Endpoint (:76), MaxOutputTokens
(:83, defaulting to 1024 at :28 and range-checked 1 to 1,000,000 at :82), Timeout (:89,
defaulting to 30 seconds at :31), AllowTools (:96), RequireGuardrail (:110, defaulting to
true), EnableCache (:116) and the optional PerCallInputTokenBudget (:124, range 1 to
int.MaxValue at :123). Every one of those is a bound rather than a suggestion: the settings doc
comment says so outright (AiSettings.cs:7-14), and the runtime enforcement lives one layer out in
BoundedChatClient, so a caller cannot opt out by passing different
ChatOptions. Nothing in the section names a vendor: Provider is a plain string matched against the
names of whatever provider factories the host registered (AiSettings.cs:15-20, :41-47), and
Endpoint is what routes any adapter through an AI gateway, a regional endpoint or an
OpenAI-compatible server (:71-75).
Validation is conditional, which is what lets the Ai section ship in every appsettings file.
AiSettings implements IValidatableObject (AiSettings.cs:22) and its Validate yields nothing
at all when Enabled is false (:135-138); once the dependency is switched on it requires
Provider (:140-146), Model (:148-153) and ApiKey (:155-161), rejects a relative
Endpoint (:163-168) and rejects a non-positive Timeout (:170-175). The registration chains
that onto ValidateDataAnnotations().ValidateOnStart()
(MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:123-126), so a misconfigured host
fails at startup rather than on the first user request: the same fail-fast configuration contract the
rest of the framework follows
(ADR-070).
[Rubric section 11, Security] is in play at ApiKey: the property is documented as binding from Key
Vault in production and from user secrets locally (AiSettings.cs:69-74), so the package itself never
decides where the secret comes from.
The provider is a registered factory, not a type the package names
IAiProviderFactory
(MMCA.Common/Source/Core/MMCA.Common.AI/Providers/IAiProviderFactory.cs:20) is the whole provider
boundary: a Name a configuration file selects it by (:26) and a Create(AiSettings, IServiceProvider) that returns the innermost, ungoverned client (:34). The governance pipeline never
sees a vendor type, so swapping the provider is a package reference, a registration call and a
configuration value with nothing else in the host changing (IAiProviderFactory.cs:8-13). Each
adapter package ships exactly one implementation.
AnthropicAiProviderFactory
(MMCA.Common/Source/Core/MMCA.Common.AI.Anthropic/AnthropicAiProviderFactory.cs:14, name
Anthropic at :17) passes the key, the timeout and an optional base URL from Endpoint to the
official SDK's ClientOptions (:37-46) and adapts the AnthropicClient with the SDK's own
AsIChatClient, handing it the pinned model and output ceiling as defaults (:48), so nothing
hand-rolls the Messages API over HttpClient (:24-27).
OpenAiProviderFactory
(MMCA.Common/Source/Core/MMCA.Common.AI.OpenAI/OpenAiProviderFactory.cs:18, name OpenAI at
:21) builds an OpenAIClient from an ApiKeyCredential with NetworkTimeout and the optional
Endpoint, then binds the model through GetChatClient(model).AsIChatClient() (:42-51); because
that client fixes its model at construction, the per-call model pin one layer out is what keeps a
prompt contract's model meaningful on both adapters (:28-31).
Each adapter registers itself through its own extension(IServiceCollection) block:
AnthropicAiServiceCollectionExtensions
(MMCA.Common/Source/Core/MMCA.Common.AI.Anthropic/DependencyInjection.cs:17) exposes
AddAnthropicAiProvider() (:26) and
OpenAiServiceCollectionExtensions
(MMCA.Common/Source/Core/MMCA.Common.AI.OpenAI/DependencyInjection.cs:17) exposes
AddOpenAiProvider() (:26). Both use TryAddEnumerable (:30 in each file), so registration is
additive and idempotent and a host can register several providers and pick one per environment in
configuration (:10-11). AiProviderValidator
(MMCA.Common/Source/Core/MMCA.Common.AI/Providers/AiProviderValidator.cs:16) closes the loop at
startup. It is an internal IValidateOptions<AiSettings> that matches Provider against the
registered names case-insensitively (:41-42) and fails with a message listing the registered
providers, or naming both adapter packages and their registration methods when none is registered
(:48-64). It stays silent when the section is disabled or the name is blank (:27-30), because the
data annotations already report a blank name and one failure should be reported once.
One entry point, one pipeline, outermost first
AiServiceCollectionExtensions
(MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:77) is the only composition surface.
It exposes two AddMmcaChatClient overloads inside an extension(IServiceCollection services) block
(DependencyInjection.cs:79), the framework's standard public DI shape
(ADR-106): the
configuration form (:88) registers AiProviderValidator (:94) and then
delegates to the factory form (:113-115) with CreateProviderChatClient as the innermost client
(:96). That method resolves every registered IAiProviderFactory, picks the
one whose name matches Ai:Provider and calls its Create (:239-248); its own throw is only a belt
for a host that bypassed the options pipeline (:235-238). The provider path is therefore nothing more
than the general path with its innermost client supplied. On the factory form Ai:Provider is still
required, but it is only the metrics fallback and is matched against nothing (:109-111).
The registration reads top to bottom as a policy. Bind and validate (:121-126). Then, if the section
is disabled, return having registered no client at all (:128-132). That absence is the API: a
consumer gates on GetService<IChatClient>() returning null rather than on reading a flag
(DependencyInjection.cs:66-70), so a feature whose key is missing in a given environment is off by
construction. When enabled, the registration first scans the descriptors for an IChatGuardrail and
an IChatRequestRedactor (:139-140) and hands the answers to RefuseAnUngovernedHost (:142, body
at :202-227), which makes two refusals at registration time rather than on the tenth user's request.
With RequireGuardrail true (its default) and neither extension point registered it throws, naming
AddPiiRedactionGuardrail() as the one-line fix and the RequireGuardrail switch as the reviewable
opt-out (:208-217); with AllowTools true and no IChatToolPolicy registered it throws too, because
every tool would otherwise be silently stripped (:219-226). Then the meter is registered
(AddMetrics plus TryAddSingleton<AiUsageMeter>(), :144-145) and the client is built with
AddChatClient followed by Use calls, first-registered being outermost because ChatClientBuilder
applies its factories in reverse (:147-184). The resulting order, outermost first, is
BoundedChatClient, then the optional
GuardrailChatClient, then
UsageRecordingChatClient, then optional distributed caching, then
OpenTelemetry, then PromptTaggingChatClient, then logging, then the
provider client (DependencyInjection.cs:22-31). The ordering is deliberate: bounds sit outside
everything, so a rejected call is rejected before anything logs or caches it; guardrails sit inside
the bounds and outside metering, because a blocked request never reaches the provider and so has no
cost to record; a cache hit still records "what this call would have cost"; and prompt tagging sits
just inside the OpenTelemetry layer so the activity it decorates is that layer's own span
(:33-40).
The guardrail layer is the one conditional wrapper, and how it is decided matters. The registration
adds it only when the collection already carries an IChatGuardrail or an IChatRequestRedactor
descriptor (:156-162), and the check is a descriptor scan rather than a resolve, because building
the layer unconditionally would change the concrete type the container hands back for every
application that adopts neither (:134-138, restated in the type's own remarks at :43-48). An app
that registers none, and has deliberately set RequireGuardrail false, therefore keeps exactly the
chain it had.
Two details in that block are worth reading closely. Caching needs both halves, the EnableCache
switch and an actually-registered IDistributedCache, because a cache the host never registered would
turn every call into a resolve-time throw (:170-175). And EnableSensitiveData on the OpenTelemetry
layer is driven by IsDevelopmentHost (:177-182, implementation at :262-266), which fails closed:
an environment it cannot positively identify as Development reads as not-Development, so prompt and
completion text never reach telemetry by accident (:256-260). That is the same dev-only-relaxation
rule recorded in
ADR-122, and it is
[Rubric section 11, Security] and [Rubric section 13, Observability and Operability] pulling in
opposite directions with the safe default winning.
The outer layer: what a call is allowed to do
BoundedChatClient
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/BoundedChatClient.cs:65) is a DelegatingChatClient
that applies five bounds (output tokens, wall clock, tool use, input size, model) to both the
buffered and the streaming path (the list at BoundedChatClient.cs:16-50). Bound works on a
clone of the caller's options (:183), clamps MaxOutputTokens down with Math.Min
(:185-187) so a caller asking for less keeps its smaller number, and nulls out Tools and
ToolMode while AllowTools is false (:189-193). The model is pinned rather than negotiated: a
request naming a model other than Ai:Model throws, and every request goes out naming the pinned
model explicitly whether or not the adapter honors a per-request override (:205-220), which is what
makes PromptContract's model mean the same thing on every provider (:43-49).
CreateLinkedTimeout links the caller's token to one cancelled after Timeout (:270-275), so
neither cancellation source masks the other, and on the streaming path the timeout deliberately
covers the whole stream rather than just its first update (:140-142).
[Rubric section 12, Performance and Scalability] and [Rubric section 31, Cost and FinOps] both
live here: the output ceiling is a spend bound and the timeout is a thread-occupancy bound.
While AllowTools is true, tools are not all-or-nothing: FilterTools (:231-268) offers a tool
only when every registered IChatToolPolicy returns
ToolAuthorization.Allowed (:251), so policies compose by intersection and a
new concern can only ever remove tools
(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/IChatToolPolicy.cs:17-21). With no policy
registered every tool is stripped (BoundedChatClient.cs:251-254), and ToolAuthorization.Denied is
the enum's zero value so an unassigned answer refuses (ToolAuthorization.cs:7-8): the layer fails
closed, and a missing policy costs a capability rather than granting one. On top of the policies, a
tool marked consequential must also be confirmed by the caller for this request (:259-262), and the
confirmation never overrides a policy that denied it (:256-258). Both markers are plain string keys
on the property bags Microsoft.Extensions.AI already carries, published by the static
ChatToolPolicy
(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ChatToolPolicy.cs:24): mmca.tool.consequential
on the tool (:30) and mmca.tool.confirmed on the options (:43), so any tool factory and any
caller can participate without referencing this package (:11-14). The distinction they encode is
reading versus writing: a lookup can be offered on a policy decided once, while a tool that sends,
moves money or deletes is a decision a human makes per request (:18-22). The marker reader fails
closed too: only a definite false (a bool, a parseable string or a JSON false) reads as harmless,
and a marker in any shape it does not recognise reads as consequential (IsConsequential,
:51-70). The confirmation reader accepts a single string, a string sequence, or the JsonElement
string or array that options bound from JSON carry (ReadConfirmedTools, :78-108), so a tool
confirmed over the wire is not silently stripped as unconfirmed (:91-93).
[Rubric section 11, Security] and [Rubric section 16].
The input-size bound is the one with a caveat baked into its own doc comment.
EnforceInputBudget (BoundedChatClient.cs:287-302) runs only when PerCallInputTokenBudget is set,
estimates the request, and throws an InvalidOperationException naming the setting when the estimate
exceeds it (:290-291), which fails the call locally instead of paying for it remotely. The estimate
comes from the public static EstimateInputTokens (:155-179), which concatenates the options'
instructions and every message's text and then either defers to an
IAiTokenEstimator or falls back to one token per four characters (:178).
IAiTokenEstimator
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/IAiTokenEstimator.cs:14) is optional and is resolved
through the client pipeline rather than from DI, via this.GetService<IAiTokenEstimator>()
(BoundedChatClient.cs:294), so any inner client that knows its provider's tokenizer can offer one.
The package declines to take a tokenizer dependency for this on purpose (IAiTokenEstimator.cs:8-12),
and the source is blunt that the number is a guardrail and not a billing figure: images, tool schemas,
provider-side system additions and non-Latin scripts are all under-counted
(BoundedChatClient.cs:52-58). The under-count direction is chosen too: it lets a call through, it
never blocks one that would have fit (:175-177). Both entry points bound and budget-check eagerly,
the streaming one outside the iterator (:124-130), so a forbidden request is refused when it is made,
not when somebody starts reading the stream.
The middle layer: what the call may say, and what it may answer
Bounds are configuration, so the framework can decide them. Content is not, so it does not.
IChatGuardrail
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/IChatGuardrail.cs:21) is the extension point an
application implements to inspect an outgoing request (InspectRequestAsync, :27-30), a
completed response (InspectResponseAsync, :37-40) and, through a default-interface member that
allows unless overridden, each streamed update (InspectStreamedUpdateAsync, :56-59), and the
interface's own doc comment states the split outright: the framework ships the extension point and
no policy (ADR-120), because what counts as a prompt injection, a leaked secret, an off-topic answer
or a disallowed topic depends on the data the application holds and the jurisdiction it runs in, so
a content rule baked into a shared package would be wrong somewhere by construction
(IChatGuardrail.cs:9-13). Register one implementation per concern; all of them run and the first
block stops the call (:12-13). One operational caveat is recorded on the interface: a guardrail
runs on the hot path of every chat call and inherits the caller's cancellation token rather than
getting a budget of its own, so an implementation that calls a remote classifier has to carry its own
timeout (:16-19). A streamed update is a fragment rather than an answer, so a rule that needs the
whole text accumulates it itself, and a block ends the stream after the caller has already seen
everything yielded before it (:50-55). [Rubric section 11, Security] and [Rubric section 16]
both read this extension point.
IChatRequestRedactor
(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/IChatRequestRedactor.cs:25) is the other half of
that extension point: where a guardrail answers yes or no, a redactor lets the call through with less
in it, which for contact details is almost always the answer an application wants (:8-13). Its one
member, Redact (:36), must return new message instances and never mutate the caller's, so a
conversation history or an audit record the caller keeps still holds exactly what it built
(:30-35). Every registered redactor runs in registration order on the materialized messages before
any guardrail inspects them, on both paths, and the redacted list is what the provider is sent
(:14-19); being on the hot path, an implementation must stay allocation-light and must not reach the
network (:21-24).
GuardrailVerdict
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/GuardrailVerdict.cs:13) is the answer type: a
readonly record struct with IsAllowed (:28) and an optional Reason (:34), built either from
the static Allow (:25) or from Block(reason), which rejects a null-or-whitespace reason outright
(:39-44). It is a struct because a guardrail answers on every call and the common answer carries no
payload (:7-9), and the default value is deliberately a block carrying the
UnspecifiedReason constant (:16), so a half-built verdict fails closed rather than silently
admitting the request (:9-12). That is the same fail-closed default the OpenTelemetry sensitive-data
gate uses one layer out, applied to a different question.
GuardrailChatClient
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/GuardrailChatClient.cs:31) is the DelegatingChatClient
that runs both, holding the registered guardrails and redactors as arrays materialized once in the
constructor (:33-34, :56-57). On the buffered path it materializes and redacts the messages
(:68), inspects the redacted request (:70), calls through with that same redacted list (:72),
then inspects the response (:74-84); a block anywhere throws
ChatGuardrailException carrying the verdict's reason, falling back to
GuardrailVerdict.UnspecifiedReason when a block supplied none (:82, :111, :150). The caller's
messages are materialized once before anything reads them (helper at :119-124), so the redactors,
the guardrails and the inner client cannot see two different sequences when a caller hands in a
lazily generated one (:66-67), and the redactors are folded over that list in registration order
(:126-135). The streaming path redacts and inspects the request the same way (:95-97) and then
runs every guardrail over each update before yielding it (:99-115), because once the caller
holds a fragment, refusing it is a statement rather than a control (:101-102). What it does not do
is inspect the assembled answer: accumulating a whole streamed answer before releasing any of it
defeats the reason a caller chose streaming, so that stays the caller's choice (:24-29).
ChatGuardrailException
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/ChatGuardrailException.cs:12) is a sealed exception
with the three conventional constructors (:15, :22, :30), the parameterless one defaulting its
message to UnspecifiedReason (:16). A refusal is an exception rather than an empty response so it
cannot be mistaken for the model having nothing to say, and so a caller wanting a graceful fallback
catches this one type (ChatGuardrailException.cs:7-11).
The two content policies the framework does ship
PiiRedactionGuardrail
(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/PiiRedactionGuardrail.cs:40) is the stated
exception to "extension points and no policy": an email address or a phone number is never evidence
for anything a model is asked, so sending one is pure exposure and the judgement does not vary between
applications or jurisdictions (:11-17). It implements both contracts. As a redactor it rewrites the
text every message carries, replacing matches of a source-generated email pattern and a deliberately
narrow North American phone pattern (each with a one-second match timeout, :71-81) with
[redacted-email] and [redacted-phone] (:42-43). That text is message text, reasoning text, a
tool result and a tool call's arguments, whether they arrive as CLR strings or as JsonElement
values (RedactContent, :123-157, rationale at :103-107), because a tool's output is exactly
where contact details turn up. JSON is rebuilt value by value so property names and numbers are
copied verbatim and a ten-digit timestamp never becomes a phone placeholder (:195-244). An item with
nothing to redact passes through by reference; a rebuilt one keeps its metadata through
ContentMetadata but deliberately drops RawRepresentation, which still holds the
unredacted text and which the OpenAI adapters would send verbatim (:108-116). A reasoning block
carrying ProtectedData is never rebuilt, because an edited signed block is rejected when the
conversation is sent back (:117-121, :125), and images and provider-specific content pass
through untouched (:152-156). As a guardrail it allows everything (:59-69), which is exactly
enough to satisfy RequireGuardrail without pretending to be a content policy (:19-24). Names are
not redacted and ChatOptions.Instructions is left alone, because a speaker's name is the published
record and the instructions are the application's own text (:29-30, :34-38).
ContentMetadata
(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ContentMetadata.cs:9) is the one internal helper
both shipped policies share so the two cannot drift (:5-8). Its single method, WithMetadataOf
(:22), copies AdditionalProperties and Annotations from the item being replaced onto its rebuilt
copy (:25-26) and pointedly does not copy RawRepresentation (:13-16): carrying the provider's
original object across would let the adapter undo the redaction it was just given.
ContentPolicyGuardrail
(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ContentPolicyGuardrail.cs:64) is the configurable
one, and it too implements both halves. On the request side it looks for prompt-injection markers in
user-role content only, since system and assistant messages are the application's own text and
the model's own turns (:34-38). The eight built-in markers (:77-87) are each anchored on an
imperative verb aimed at the model rather than a noun a person might use, which makes the list a floor
rather than a detector (:54-61). What happens on a match is the
ContentPolicyInjectionMode
(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ContentPolicyInjectionMode.cs:16): Redact, the
default (:23), Block (:29) or Off (:35). The two active modes are one behavior split across
the pipeline's fixed order (ContentPolicyGuardrail.cs:25-32): Redact rewrites the marker away
before any guardrail runs, so the request inspection allows; Block makes the redactor a pass-through
(:122-127) so the inspection sees what the caller actually supplied and refuses naming the marker's
label (:153-163). A rewritten user message is a new instance, a content item with no marker is the
same instance it came in as, and a rebuilt text item keeps its metadata through
ContentMetadata exactly as the PII policy does (:111-116, :339-341). On the
response side a configured pattern blocks the buffered answer (:167-175) and each streamed fragment
(:184-192), with the reason naming the pattern's index and never the text it matched (:281-286);
a pattern spanning two updates is missed on the streaming path, so a host that needs whole-answer
certainty uses the buffered one (:180-182).
ContentPolicySettings
(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ContentPolicySettings.cs:23) binds from its own
Ai:ContentPolicy section (:26) so a host that never adopts the policy has nothing to configure
(:11-14): InjectionMode (:35), AdditionalRequestPatterns (:45), BlockedResponsePatterns
(:57) and RedactionPlaceholder (:69, defaulting to [redacted-instruction] at :29). The
built-in markers stay code, not configuration (:17-21).
GuardrailServiceCollectionExtensions
(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/GuardrailServiceCollectionExtensions.cs:17)
registers both: AddPiiRedactionGuardrail() (:33) and AddContentPolicyGuardrail(configuration)
(:62), which also binds ContentPolicySettings with ValidateDataAnnotations().ValidateOnStart()
(:67-70) so a pattern that does not compile fails the deployment rather than a user's request
(:56-57). Each registers one singleton resolved under both IChatGuardrail and
IChatRequestRedactor rather than two instances, because the redactor and the guardrail are two
faces of one decision (:37-41, :72-76, rationale at :28-31), and each must run before
AddMmcaChatClient, which reads the descriptors (:30-31).
The inner layer: what the call actually cost
UsageRecordingChatClient
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/UsageRecordingChatClient.cs:38) sits inside the
guardrails and reports the provider's own numbers. On the buffered path it records after awaiting the
response (:109-114), passing response.Usage, the model (response.ModelId falling back to the
options' ModelId, :111), the prompt identity read back off the options (:112-113) and the
provider (:114). On the streaming path it accumulates from the update stream, recording whenever a
UsageContent item appears (:163, helper at :181), which is how providers typically deliver usage
on a final update. The split of responsibility with the outer layer is stated in its remarks
(:13-15): a bound has to be decided before the call, a cost has to be measured after it. The
provider tag comes from the inner client itself: ResolveProviderName (:68) reads the
ChatClientMetadata.ProviderName every Microsoft.Extensions.AI adapter publishes (:72) and falls
back to the configured Ai:Provider, lower-cased, only when the client reports none (:78-80), so a
foreign client supplied through the factory overload is metered as what it is (:18-22). The
registration hands it that configured fallback (DependencyInjection.cs:165-168).
It also measures the call. A Stopwatch timestamp is taken on entry to both paths
(UsageRecordingChatClient.cs:89, :125) and the elapsed time is reported to the meter's latency
histogram with an outcome of success, error or canceled (RecordDuration, :195-201). The
buffered path classifies through two catch clauses that re-throw (:96-105) and records success
only after the call returned (:107); the streaming path drives the inner enumerator by hand instead
of using await foreach, because the outcome has to be attributed and C# forbids a yield return
inside a try that has a catch, so the duration is written in a finally (:129-133,
:168-172). The stream ending is what stops the clock, which is the number a caller of a streaming
API actually feels (:131). Recording on the failure paths too is the point: a failure rate and a
latency distribution then come off one series (:30-35).
AiUsageMeter
(MMCA.Common/Source/Core/MMCA.Common.AI/Observability/AiUsageMeter.cs:20) owns all three
instruments: the counters mmca.ai.input_tokens (:29) and mmca.ai.output_tokens (:32), and the
histogram mmca.ai.call.duration in seconds (:45, created at :85-88). They are published under
the meter name MMCA.Common.AI (:26), which is the same name the pipeline's OpenTelemetry layer
uses as its activity source (DependencyInjection.cs:181), so one name enables both traces and
metrics. Every adopting app reports to that same meter, which is what makes one dashboard query cover
every service (AiUsageMeter.cs:12-17). Record (:101-124) and RecordDuration (:137-149) share
one tag builder, so the cost series and the latency series join on the same dimensions: model,
prompt_name, prompt_version and provider (:155-164), with outcome added for the histogram
only (:146). Record is careful in two places: a null usage records nothing (:108-111) and a
count the provider did not report is skipped rather than written as zero (:115-123), because an
absent number must not read as a zero on a spend dashboard (:91-95). The duration histogram does not
replace gen_ai.client.operation.duration, which the Microsoft.Extensions.AI OpenTelemetry layer
already publishes on this same meter: the standard instrument carries the GenAI semantic-convention
dimensions, this one carries the prompt identity and the outcome, so a latency regression can be
attributed to the prompt that caused it (:34-44). The meter is created through IMeterFactory and
deliberately neither retained nor disposed (:72-88, rationale at :62-67), since disposing a
factory-owned meter would kill the instrument for every other holder of the name.
PromptTaggingChatClient
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/PromptTaggingChatClient.cs:21) gives traces the same
attribution the counters have. It copies the three prompt-identity keys a
PromptContract stamped on the options onto Activity.Current (:63-69), on both
paths before calling through (:36, :46). It is composed just inside the OpenTelemetry layer
(DependencyInjection.cs:183), so when that layer has a listener the current activity is its own
gen_ai span, and it tags only an activity whose source is the MMCA.Common.AI name
(PromptTaggingChatClient.cs:56-57), so a host with no listener for that source gets no stray tags on
its HTTP request span and a request with no contract writes nothing (:14-18).
[Rubric section 13, Observability and Operability].
The prompt is a versioned artifact
PromptContract (MMCA.Common/Source/Core/MMCA.Common.AI/PromptContract.cs:23) is a
sealed record of the four things that decide what a model will answer: Name, Version, Model and
SystemPrompt. Its Hash (:46) is the lowercase hex SHA-256 of those four joined by a pipe
character (:90-100), with every component's line endings normalized to LF first (:102-103) so a
Windows checkout and a Linux checkout of the same prompt hash identically and a CI gate cannot be
tripped by core.autocrlf. The hash is computed on each read rather than cached, and the reason is
recorded in the remarks (:40-45): a record's generated copy constructor copies fields verbatim, so a
cached hash would survive a with expression and describe the prompt the copy was made from.
That hash is the evaluation hook. ToChatOptions (:53-54) builds fresh options carrying the model
and the system prompt as instructions, Apply (:62-72) stamps mmca.prompt.name,
mmca.prompt.version and mmca.prompt.hash (:26,29,32) onto options a caller already built, and the
static ReadName / ReadVersion (:77,82) are what
UsageRecordingChatClient calls to turn a spend number into a per-prompt
signal. Change any of the four components and the hash moves, so a golden-replay gate that recorded
answers for the old hash demands a re-evaluation instead of silently approving a prompt nobody scored
(PromptContract.cs:11-17). ADC's evaluation suite does exactly that through the framework's pin
gate, SessionScoringPromptContractPinTests : PromptContractPinTestsBase
(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Scoring.Evaluation.Tests/PromptContractTests.cs:180).
[Rubric section 16] again, and this is the part of it most systems skip.
Where it runs today, and how it is tested
Exactly one feature in the workspace calls a model: ADC's organizer-facing session scoring. The
Conference service host reads one secret, Ai:ApiKey, and derives Ai:Enabled from its presence
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:137-142); the infrastructure
template writes the Key Vault secret into the container app under that same key and local
development keeps it in user secrets (Program.cs:131-133), so a host with no key starts with
scoring unavailable rather than failing validation. It then registers the Anthropic adapter with
AddAnthropicAiProvider() (Program.cs:147), its guardrails with AddConferenceAiGuardrails
(Program.cs:158) and the governed client with AddMmcaChatClient(builder.Configuration)
(Program.cs:160), in that order because the last call reads the guardrail descriptors, and
subscribes the framework meter and activity source by literal name (Program.cs:179-180).
AddConferenceAiGuardrails
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:85)
composes both framework policies, AddPiiRedactionGuardrail() and AddContentPolicyGuardrail
(:95-96), with the module's own response guardrail (:99), so in the deployed workspace the
guardrail layer is present on every call. On the module side,
SessionScoringService
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringService.cs:47)
consumes the injected IChatClient and PromptContract, and Conference's Infrastructure registration
resolves the client with GetService rather than GetRequiredService precisely because the disabled
host registers none
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:42-48).
[Rubric section 3, Clean Architecture]: an ADC architecture fitness test asserts that Infrastructure
is the only layer naming a language-model SDK type or the package
(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Layering/AiDependencyIsolationTests.cs:14).
[Rubric section 14, Testability] is served first by the factory overload. Because the innermost
client is just a Func<IServiceProvider, IChatClient> (DependencyInjection.cs:113-115), the whole
governance pipeline can be exercised with no network at all, which is what the package's own suite
under MMCA.Common/Tests/Core/MMCA.Common.AI.Tests does, from AiServiceCollectionExtensionsTests and
BoundedChatClientTests through Providers/AdapterFactoryTests, Guardrails/ContentPolicyGuardrailTests,
Guardrails/ToolPolicyTests and PromptTaggingChatClientTests. A guardrail is equally cheap to test,
being a small interface over in-memory types.
The evaluation half of [Rubric section 16] ships as a package of its own, MMCA.Common.AI.Testing,
which a consumer's test project subclasses. ReplayChatClient
(MMCA.Common/Source/Hosting/MMCA.Common.AI.Testing/ReplayChatClient.cs:17) is an offline
IChatClient that answers with recorded responses and records what went out, so a test asserts the
request as well as the answer (:7-15). RecordedResponses
(RecordedResponses.cs:20) reads and writes those recordings in the abstraction's ChatResponse
shape through AIJsonUtilities.DefaultOptions, never a vendor wire format, so a provider swap does
not invalidate a corpus or lose its regression history (:10-18). A
GoldenReplayCase (GoldenReplayCase.cs:13) is an id, a one-line description and
a response path resolved relative to the test assembly (:9-12), and
GoldenReplayTestsBase (GoldenReplayTestsBase.cs:24) replays every case
through the real code under test on a fresh client, collects every failure rather than stopping at the
first, and fails an empty corpus because an evaluation that evaluates nothing passes forever
(:11-21). PromptContractPinTestsBase
(PromptContractPinTestsBase.cs:25) is the prompt-change protocol as a gate: a JSON pin file maps
<Name>@<Version> to the contract hash (:20-22), and a contract with no pin or a pin that no longer
matches fails (:12-18). ADC subclasses both, in
MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Scoring.Evaluation.Tests/GoldenReplayTests.cs:35
and PromptContractTests.cs:180.
Read the per-type sections next in level order: the Level 0 contracts and values (AiSettings, PromptContract, GuardrailVerdict, IAiTokenEstimator, IChatRequestRedactor, ToolAuthorization, ChatToolPolicy, ContentMetadata, ContentPolicyInjectionMode, AiUsageMeter and the three replay types) first, then the Level 1 types (IChatGuardrail, IChatToolPolicy, IAiProviderFactory, ChatGuardrailException, UsageRecordingChatClient and the two test bases), then the Level 2 clients, policies, validator and provider factories, then the Level 3 registrations, and finally PromptTaggingChatClient and the AiServiceCollectionExtensions entry point that assembles them.
AiSettings
MMCA.Common.AI ·
MMCA.Common.AI·MMCA.Common/Source/Core/MMCA.Common.AI/AiSettings.cs:22· Level 0 · class
- What it is: the bound, validated
Aiconfiguration section (SectionName = "Ai",AiSettings.cs:25) that gates and configures the entire AI integration: whether it is on at all (Enabled,AiSettings.cs:46), which provider and model to call (Provider/Model,AiSettings.cs:55,65), the API key, an optional endpoint override, the output-token ceiling, the per-call timeout (capped byMaxTimeout), and opt-ins (AllowTools,RequireGuardrail,EnableCache) plus an optional input-token pre-flight budget. - Depends on:
System.ComponentModel.DataAnnotations(IValidatableObject,[Range]); no first-party dependency of its own, though its doc comments nameAddCommonKeyVaultConfigurationas the sibling convention elsewhere in Common that suppliesApiKeyin production (AiSettings.cs:67-76). - Concept introduced, an off-by-default dependency with conditional validation.
[Rubric §16, AI-Native Application Architecture](assesses whether an LLM dependency is safe to ship in every environment):Enableddefaults tofalse, and when it is falseAddMmcaChatClientregisters noIChatClientat all, so resolving one yieldsnulland a consumer gates on the service being present rather than reading a flag itself (AiSettings.cs:40-46).Provideris a string, not a closed enum: it is matched case-insensitively against the registeredIAiProviderFactory.Nameof whatever provider packages the host referenced (MMCA.Common.AI.AnthropicandMMCA.Common.AI.OpenAIeach ship one), so the provider switch lives in the host's package references and its configuration value, not in this framework's source (AiSettings.cs:15-19,AiSettings.cs:48-55).[Rubric §11, Security](assesses secret handling):ApiKeyis required only whenEnabled, and its doc comment records that production binds it from Key Vault viaAddCommonKeyVaultConfiguration, never from a checked-in settings file (AiSettings.cs:67-76). - Walkthrough: two defaults,
DefaultMaxOutputTokens = 1024(AiSettings.cs:28) andDefaultTimeout = TimeSpan.FromSeconds(30)(AiSettings.cs:31), back the two settings that ship with a value even when the section is silent; a third static,MaxTimeout = TimeSpan.FromHours(1)(AiSettings.cs:33-38), is the largest per-call timeout validation accepts.Provider(AiSettings.cs:55) is required whenEnabledand names a registered provider by string, e.g.AnthropicorOpenAI; an unknown name fails at startup naming the registered ones.Model(AiSettings.cs:57-65) is required whenEnabledbecause it is part of the prompt contract, hashed intoPromptContract.Hash, so an implicit provider default would silently change evaluated behavior on the provider's own schedule rather than on a reviewed version bump, and it is also a bound:BoundedChatClientrefuses a request that names a different model.Endpoint(AiSettings.cs:78-83) is an optional absolute URI to route through an AI gateway, a regional endpoint or an OpenAI-compatible server; each adapter passes it to its SDK's base-address option.MaxOutputTokens([Range(1, 1_000_000)],AiSettings.cs:89-90) is the hard ceilingBoundedChatClient.Boundclamps every call down to.Timeout(AiSettings.cs:92-96) is enforced with a token linked to the caller's own, so a caller cancelling early still wins.AllowTools(AiSettings.cs:98-103) isfalseby default, and while it is false any tools on a request are stripped before the call.RequireGuardrail(AiSettings.cs:105-117) defaults totrue: an enabled host that registers no guardrail fails at startup rather than shipping an uninspected model call, and the message names the one-line fix (AddPiiRedactionGuardrail()); a host that deliberately wants none sets itfalse, which is a reviewable line rather than an absence nobody can see.EnableCache(AiSettings.cs:119-123) is off by default and stays off when noIDistributedCacheis registered.PerCallInputTokenBudget([Range(1, int.MaxValue)],AiSettings.cs:130-131) isnullby default, leaving input unbounded, and is an ESTIMATE never a billing figure.Validate(AiSettings.cs:140-190) is the conditional half: ityield breaks immediately when!Enabled(AiSettings.cs:142-145), so a host that leaves AI off is valid with nothing else set, which is what lets the section ship in everyappsettingsfile; when enabled it requiresProvider,Model,ApiKey, an absoluteEndpointwhen one is set, a positiveTimeout, and aTimeoutno greater thanMaxTimeout(AiSettings.cs:147-189). That last check exists because a bare number in configuration binds as DAYS, and a value above roughly 49.7 days makes every call throw where the timeout is armed; the one-hour cap rejects every bare-number value while leaving any plausible per-call budget alone, and its message points at the00:00:30form (AiSettings.cs:184-189). - Why it's built this way:
IValidatableObject.Validateruns through.ValidateDataAnnotations().ValidateOnStart()inAiServiceCollectionExtensions.AddMmcaChatClient(MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:123-126), so a misconfiguredAisection, including aTimeoutmistyped as a bare number, fails at host startup, not on the first call.Provideris a string, not an enum, so a new vendor is a new adapter package registering a factory, never a change to this framework's source. SeeADR-120andADR-070. - Where it's used: bound and consumed by
AiServiceCollectionExtensions, enforced byBoundedChatClient, and itsProvidervalue is threaded intoUsageRecordingChatClientas the fallback tag when the resolved client reports none of its own.
GuardrailVerdict
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/GuardrailVerdict.cs:13· Level 0 · record struct
- What it is: the allow/block outcome a chat guardrail returns for one inspected request or
response:
IsAllowedplus an optionalReason(GuardrailVerdict.cs:39,46), built only through the two factory membersAllowandBlock(reason)(GuardrailVerdict.cs:37,51-56). - Depends on: nothing first-party beyond
IChatGuardrail, which returns it. - Concept introduced, a closed allow/block gate.
[Rubric §11, Security](assesses whether content passing through an AI dependency is inspected and the inspection outcome cannot be misconstructed): the constructor isprivate, so the only way to produce a blocking verdict isBlock(string reason), which throws on a null or whitespace reason (GuardrailVerdict.cs:39-44); a caller cannot construct a blocked verdict that silently carries no explanation. - Walkthrough:
Allow(GuardrailVerdict.cs:37) is a static property returningnew(isAllowed: true, reason: null).Block(reason)(GuardrailVerdict.cs:39-44) validates the reason withArgumentException.ThrowIfNullOrWhiteSpacebefore constructingisAllowed: false. The constantUnspecifiedReason(GuardrailVerdict.cs:28) exists for the struct's owndefaultvalue, whoseReasonisnullbecause it went through neither factory. - Why it's built this way: a record struct keeps every inspection call allocation-free, which matters
because a guardrail runs on every request and every response, not just the ones it blocks. See
ADR-120for the guardrail pipeline this verdict gates. - Where it's used: returned by
IChatGuardrail's inspection methods; consumed byGuardrailChatClient, which throwsChatGuardrailExceptionwithverdict.Reason ?? GuardrailVerdict.UnspecifiedReasonwhenIsAllowedisfalse.
IAiTokenEstimator
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/IAiTokenEstimator.cs:14· Level 0 · interface
- What it is: a one-method extension point for estimating how many input tokens a piece of prompt
text will cost:
int EstimateTokenCount(string text)(IAiTokenEstimator.cs:19). - Depends on: nothing first-party beyond the caller that resolves it.
- Concept introduced, a pluggable estimator behind a cheap built-in default.
[Rubric §1, SOLID](assesses whether a dependency is inverted behind an abstraction rather than hard-coded):BoundedChatClientresolves this interface from DI (this.GetService<IAiTokenEstimator>(),MMCA.Common/Source/Core/MMCA.Common.AI/Chat/BoundedChatClient.cs:298) and falls back to a built-in heuristic when nothing is registered (BoundedChatClient.EstimateInputTokens,BoundedChatClient.cs:179), so a host that wants provider-accurate tokenization can register a real tokenizer without changing the bounding logic. - Walkthrough: the interface has no other members; the contract is the single method plus its two
doc-comment lines describing the parameter and return value (
IAiTokenEstimator.cs:17-18). - Why it's built this way:
BoundedChatClient.EstimateInputTokensispublic staticprecisely so a caller (or the estimator implementation itself) can reproduce the same estimate the budget check uses (BoundedChatClient.cs:155-179), and it deliberately rounds up with a cheap four-characters-per-token heuristic when no estimator is registered, an under-count that only ever lets a call through, never blocks one that would have fit (BoundedChatClient.cs:176-179). - Where it's used:
BoundedChatClient.EnforceInputBudget(BoundedChatClient.cs:287-302) is the only production call site; the test fixtureMMCA.Common/Tests/Core/MMCA.Common.AI.Tests/Fixtures/StubChatClient.cssupplies a stub implementation for unit tests. - Caveats / not-in-source: no built-in implementation ships in
MMCA.Common.AI; every consumer today relies on the character-based fallback rather than registering one.
PromptContract
MMCA.Common.AI ·
MMCA.Common.AI·MMCA.Common/Source/Core/MMCA.Common.AI/PromptContract.cs:23· Level 0 · record
- What it is: an immutable, hashable identity for one prompt:
Name,Version,Model,SystemPrompt(PromptContract.cs:78). It both builds theChatOptionsa call needs (ToChatOptions,PromptContract.cs:53-54) and stamps its own identity onto telemetry so every call can be traced back to the exact prompt that produced it. - Depends on:
ChatOptions(Microsoft.Extensions.AI),System.Security.Cryptography.SHA256, andSystem.Text.Encoding. - Concept introduced, prompt-as-versioned-contract.
[Rubric §16, AI-Native Application Architecture](assesses whether prompts are governed, versioned artifacts rather than inline strings): the record'sHashproperty (PromptContract.cs:101) is a lowercase hex SHA-256 ofName|Version|Model|SystemPrompt, with every component's line endings normalized to LF first (NormalizeLineEndings,PromptContract.cs:102-103) so the same prompt checked out on Windows and on Linux hashes identically and a CI gate cannot be tripped bycore.autocrlf(PromptContract.cs:90-93). The three identity values are stamped ontoChatOptions.AdditionalPropertiesunder fixed keys (NamePropertyKey/VersionPropertyKey/HashPropertyKey,PromptContract.cs:81-87), so a request carries its own provenance through the pipeline into telemetry. - Walkthrough:
Hash(PromptContract.cs:101) is computed on every read, deliberately not cached in a field, because a record's generated copy constructor copies fields verbatim and a cached hash would survive awithexpression describing the prompt the copy was made FROM (PromptContract.cs:96-100, exactly the drift the type exists to prevent).ToChatOptions(PromptContract.cs:53-54) builds a freshChatOptionswithModelIdandInstructionsset, then delegates toApply.Apply(PromptContract.cs:62-72) stampsName,Version, andHashonto an existingChatOptionsin place and returns it for chaining, for a caller that already built options and needs its own temperature or response format alongside the prompt identity.ReadName/ReadVersion(PromptContract.cs:77,82) and the privateReadProperty(PromptContract.cs:84-88) reverse the stamp, reading back whateverApplywrote (ornullwhen nothing was stamped). - Why it's built this way: hashing four short strings on every read is cheap enough that correctness
wins outright over caching (
PromptContract.cs:96-100). SeeADR-120for how this identity feeds the governed pipeline, andADR-111for a concrete scoring consumer. - Where it's used:
UsageRecordingChatClientreads the stamped name and version back offChatOptionsto tag every usage measurement; ADC'sAnthropicScoringService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AnthropicScoringService.cs) is the production caller that builds aPromptContractfor session scoring, and ADC's architecture fitness testAiDependencyIsolationTests(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Layering/AiDependencyIsolationTests.cs) asserts the isolation boundary this contract type crosses.
ChatGuardrailException
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/ChatGuardrailException.cs:12· Level 1 · class
- What it is: the exception
GuardrailChatClientthrows when a guardrail blocks a call: three constructors following the standard .NET exception pattern (parameterless, message, message plus inner exception,ChatGuardrailException.cs:14,20,27), with the parameterless form defaulting its message toGuardrailVerdict.UnspecifiedReason(ChatGuardrailException.cs:15). - Depends on:
GuardrailVerdict(for its default message only) andSystem.Exception. - Walkthrough: no members beyond the three constructors; it carries no state of its own, the blocked
reason travels as the base
Exception.Message. - Why it's built this way: a dedicated exception type, rather than a generic
InvalidOperationException, lets a caller catch guardrail blocks specifically without also catching every other failure the pipeline can throw. SeeADR-120. - Where it's used: thrown by
GuardrailChatClientfrom bothInspectRequestAsyncand the response-side check, always withverdict.Reason ?? GuardrailVerdict.UnspecifiedReasonas the message.
IChatGuardrail
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/IChatGuardrail.cs:21· Level 1 · interface
- What it is: the extension point a host implements to inspect chat traffic:
InspectRequestAsyncbefore the model is called,InspectResponseAsyncafter a completed response, andInspectStreamedUpdateAsyncfor one streamed update, each returning aGuardrailVerdict(IChatGuardrail.cs:28-31,38-41,57-60). The streamed member is a default interface member that allows by default, so a guardrail written before it existed keeps compiling and keeps its streaming behavior. - Depends on:
GuardrailVerdict,Microsoft.Extensions.AI.ChatMessage/ChatOptions/ChatResponse/ChatResponseUpdate. - Concept introduced, guardrail as a resolved multi-instance dependency.
[Rubric §11, Security](assesses whether content passing through an AI dependency is inspected before and after the model): every registered implementation runs, in registration order, on the outgoing request, the completed response, and each streamed update (GuardrailChatClient.cs:65-68,88-102,146-154); a host that registers neither a guardrail nor a redactor pays no cost, seeAiServiceCollectionExtensions's descriptor check, and an enabled host withRequireGuardrailtrue (the default) fails at startup instead. - Walkthrough:
InspectRequestAsync(IChatGuardrail.cs:28-31) takes the materialized (and, if any redactor ran, already-redacted) message list and the call's options, returning a verdict; a block here stops the call before the provider is reached.InspectResponseAsync(IChatGuardrail.cs:38-41) takes the completedChatResponseand the same options; a block here stops the response from reaching the caller after the provider call already ran.InspectStreamedUpdateAsync(IChatGuardrail.cs:57-60) takes oneChatResponseUpdatefragment; a rule that needs the whole answer has to accumulate it itself, and a block ends the stream from that update on, though the caller has already seen every update yielded before it. - Why it's built this way: separate inspection points let an implementation block on
prompt-injection patterns in the request, unsafe content in a completed answer, and unsafe content in a
streamed fragment, three different failure modes with three different signals, without forcing a
streaming caller to buffer the whole answer just to be inspected. See
ADR-120. - Where it's used: resolved from DI by
GuardrailChatClient; no built-in implementation ships inMMCA.Common.AI. - Caveats / not-in-source: no production implementation is present in this codebase; the test fixture
MMCA.Common/Tests/Core/MMCA.Common.AI.Tests/Fixtures/StubGuardrail.cssupplies one for unit tests.
UsageRecordingChatClient
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/UsageRecordingChatClient.cs:38· Level 1 · class
- What it is: a
DelegatingChatClientthat records every call's token usage AND end-to-end latency toAiUsageMeter, tagged with the model, the prompt name/version read offPromptContract, and a resolved provider name. - Depends on:
Microsoft.Extensions.AI.DelegatingChatClient/IChatClient/UsageContent/ChatClientMetadata,System.Diagnostics.Stopwatch,AiUsageMeter,PromptContract(ReadName/ReadVersion). - Concept introduced, cross-referenced.
[Rubric §13, Observability & Operability]: same category asAiUsageMeter; this type is the call site that turns a response into a measurement, closing the loop betweenPromptContract's stamped identity and the meter's counters and duration histogram. - Walkthrough: the constructor takes the inner client, the meter, and an optional configured provider
fallback, then resolves the
Providerproperty once via the staticResolveProviderName(UsageRecordingChatClient.cs:42-52): it prefers whatever the inner client itself reports throughChatClientMetadata.ProviderName, falls back to the constructor'sconfiguredProviderlower-cased, and falls back further to the literalunknown(UsageRecordingChatClient.cs:57-81); this is what lets one client work correctly whicheverIAiProviderFactorybuilt the inner client, or when none did.GetResponseAsyncstarts aStopwatchtimestamp, awaits the base call inside atry, and records the duration withAiUsageMeter.CanceledOutcomeorErrorOutcomefrom the matchingcatchbefore rethrowing; on success it records the duration withSuccessOutcome, then recordsresponse.Usagetagged withresponse.ModelId ?? options?.ModelIdand the prompt name/version read back offoptionsviaPromptContract.ReadName/ReadVersion, returning the response unchanged.GetStreamingResponseAsyncis hand-driven with an explicitGetAsyncEnumerator/MoveNextAsyncloop rather thanawait foreach, because C# forbids ayield returninside atrythat has acatchclause and the outcome has to be known before thefinallyrecords the duration; it tracks the first non-nullmodelIdseen across updates, records anyUsageContenteach update carries through the privateRecordUsageIn, yields the update, and in afinallyrecords the duration under whichever outcome the loop settled on, stopping the clock only once the stream itself ends, which is the number a caller of a streaming API actually feels. The outcome starts asCanceledOutcome(UsageRecordingChatClient.cs:196) and flips toSuccessOutcomeonly whenMoveNextAsyncreturns false (UsageRecordingChatClient.cs:161-165), so a consumer that stops enumerating early (a guardrail blocking an update, a caller breaking out of its loop) reaches thefinallywith neithercatchhaving run and is recorded as canceled, not as a success; usage already delivered update by update stays recorded. The privateRecordDurationis the shared helper both paths call, and both paths tag every measurement with the resolvedProviderproperty rather than a constructor field. - Why it's built this way: usage recording happens after the base call returns (or per streamed
UsageContent), so this client never alters the response, only observes it; that keeps it safely composable withBoundedChatClient, which does alter requests. Resolving the provider tag from the client's own metadata, with the configured value only as a fallback, means the tag stays accurate even when the provider-agnosticAddMmcaChatClientoverload is used with a hand-built client. Duration is recorded on every outcome, including a thrown exception or a cancellation, so a failure rate and a latency distribution come off the same series. SeeADR-111andADR-120. - Where it's used: registered last (innermost of the two governance wrappers, so it sees the actual
provider response) by
AiServiceCollectionExtensions.AddMmcaChatClient(DependencyInjection.cs:164-168); ADC'sAnthropicScoringService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AnthropicScoringService.cs) andMMCA.ADC.Conference.Service/Program.csare downstream of the registered pipeline.
BoundedChatClient
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/BoundedChatClient.cs:65· Level 2 · class
- What it is: a
DelegatingChatClient(Microsoft.Extensions.AI) that enforces the governance bounds fromAiSettingson every call: output-token clamping, model pinning, per-request tool policy filtering, a linked per-call timeout, and an optional input-token pre-flight budget. - Depends on:
Microsoft.Extensions.AI.DelegatingChatClient/IChatClient/ChatOptions/AITool,AiSettings,IChatToolPolicy(a group of every registered one, injected through the constructor),IAiTokenEstimator(resolved, not injected),System.Globalization.CultureInfo,System.Text.StringBuilder. - Concept introduced, a governance layer as a chat-client decorator.
[Rubric §12, Performance & Scalability](assesses whether a resource-bounded dependency is protected against runaway cost or latency): every bound applied here (output tokens, timeout, input budget) exists to keep one call from exceeding what the host budgeted for it.[Rubric §29, Resilience & Business Continuity](assesses whether an external dependency is time-bounded): the linkedCancellationTokenSource(CreateLinkedTimeout,BoundedChatClient.cs:280-285) guarantees the provider call cannot hang pastAiSettings.Timeoutregardless of what the caller's own token does. This is the first client wrapped inAiServiceCollectionExtensions's pipeline, matching the outermost-first ordering the type's own remarks describe. - Walkthrough: the two-argument constructor (
BoundedChatClient.cs:78-84) delegates to the three-argument one with an empty tool-policy list, for a host that composes the client by hand and does not offer tools; the real constructor (BoundedChatClient.cs:87-97) takes the inner client, the settings, and everyIChatToolPolicyto consult.GetResponseAsync(BoundedChatClient.cs:100-115) materializes the message enumerable once, callsBoundto clamp options, callsEnforceInputBudget, then races the base call against a linked timeout.GetStreamingResponseAsync(BoundedChatClient.cs:117-131) bounds and budget-checks eagerly, OUTSIDE the iterator methodStreamBoundedAsync, so a request the configuration forbids is refused when it is made, not when somebody starts reading the stream;StreamBoundedAsync(BoundedChatClient.cs:133-153) then wraps the whole stream, not just its first update, in the linked timeout, because a provider that opens a response and then stalls is exactly the failure mode a per-call budget exists to bound. The staticEstimateInputTokens(BoundedChatClient.cs:155-179) concatenatesoptions.Instructionsplus every message's text and either runs the resolvedIAiTokenEstimatoror falls back to a four-characters- per-token heuristic.Bound(BoundedChatClient.cs:193-233) clones the incomingChatOptions, clampsMaxOutputTokensto the smaller of the request's own value and_settings.MaxOutputTokens, and whenAllowToolsis false nulls outTools/ToolModeentirely: a model that cannot be handed a tool cannot be talked into using one. WhenAllowToolsis true it instead narrows the offered tools throughFilterTools(BoundedChatClient.cs:241-278), which requires every registeredIChatToolPolicyto authorize a tool (no policy registered means no tool survives, since an unanswered capability question is answered by withholding it) and, for a toolChatToolPolicymarks consequential, additionally requires the request to carry a matchingmmca.tool.confirmedstamp read byChatToolPolicy.ReadConfirmedTools. AToolModethe provider cannot honor is then cleared back to auto byCanHonorToolMode(BoundedChatClient.cs:181-191): that covers a mode with no surviving tools (RequireAnywould otherwise be an error rather than a plain answer) and aRequireSpecificmode naming a tool the filter stripped, since the provider rejects atool_choicefor a tool the request does not offer.Boundalso enforcesAiSettings.Modelas a pin: a request naming a differentModelIdthrowsInvalidOperationExceptionnaming the conflict, and when the request names none the pinned model is stamped explicitly so every adapter is asked for it by name and a failed call's model tag is never blank.EnforceInputBudget(BoundedChatClient.cs:287-302) is a no-op whenPerCallInputTokenBudgetis unset, otherwise it estimates and throwsInvalidOperationExceptionwith the estimated count, the configured budget, and a reminder that the estimate is approximate when the estimate exceeds the budget. - Why it's built this way: bounding happens BEFORE the linked timeout is created and BEFORE the base
call, so a call this client is going to refuse never reaches the network. Clamping the smaller of the
request's and the settings'
MaxOutputTokens, rather than always overriding, lets a caller ask for less without losing that choice. Tool authorization is checked on top of the caller's confirmation stamp, never instead of it, because a human saying yes to a prompt is not a grant of an authority the application never had; registration refusesAllowTools: truewith noIChatToolPolicyregistered (seeAiServiceCollectionExtensions), so reachingFilterToolswith no policies means the client was composed by hand outside that guard. SeeADR-120. - Where it's used: registered first (outermost is applied last by
ChatClientBuilder, so it ends up wrapping everything butUsageRecordingChatClient) byAiServiceCollectionExtensions.AddMmcaChatClient(DependencyInjection.cs:149-154).
GuardrailChatClient
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/GuardrailChatClient.cs:31· Level 2 · class
- What it is: a
DelegatingChatClientthat redacts a request with every registeredIChatRequestRedactor, then runs every registeredIChatGuardrailover the redacted request, the completed response, and each streamed update, throwingChatGuardrailExceptionon the first block. - Depends on:
Microsoft.Extensions.AI.DelegatingChatClient/IChatClient/ChatMessage,IChatGuardrail,IChatRequestRedactor,GuardrailVerdict,ChatGuardrailException. - Concept introduced, cross-referenced.
[Rubric §11, Security]: same category asIChatGuardrail; this type is the pipeline stage that redacts, then actually calls every registered guardrail and turns a block into a thrown exception. - Walkthrough: the two-argument constructor (
GuardrailChatClient.cs:39-42) delegates to the three-argument one with an empty redactor list; the real constructor (GuardrailChatClient.cs:48-58) materializes the injectedIEnumerable<IChatGuardrail>andIEnumerable<IChatRequestRedactor>into arrays once.GetResponseAsync(GuardrailChatClient.cs:61-87) materializes the message enumerable, redacts it withRedact(GuardrailChatClient.cs:126-135, every registered redactor applied in order, its output feeding the next), inspects the redacted request through every guardrail (InspectRequestAsyncprivate helper,GuardrailChatClient.cs:137-154), calls the base client, then inspects the response through every guardrail before returning it, throwing on the firstIsAllowed: falseverdict either side.GetStreamingResponseAsync(GuardrailChatClient.cs:90-117) redacts and inspects the request the same way, then, unlike the non-streaming path, also inspects EACH streamed update through every guardrail'sInspectStreamedUpdateAsyncbefore yielding it, so a block ends the stream at that update; it does NOT inspect an assembled whole answer, because accumulating one before releasing any of it defeats the reason a caller chose streaming, and that accumulation, if a rule needs it, stays the caller's own choice. The privateMaterialize(GuardrailChatClient.cs:119-124) avoids re-enumerating a caller's lazily generated sequence, since the same message list must reach the redactors, the guardrails, and the inner client. - Why it's built this way: redaction runs FIRST, on both paths, and its output is what the guardrails
inspect and what the provider is sent, so a guardrail never has to reason about text a redactor was
going to remove anyway and nothing downstream of this layer can see the original. Request and response
inspection both run BEFORE the caller sees anything, for the response side, before
GetResponseAsyncreturns, so an application that adopts a guardrail never observes a blocked answer even transiently. SeeADR-120. - Where it's used: registered by
AiServiceCollectionExtensionsonly when the host has registered at least oneIChatGuardrailor oneIChatRequestRedactor, checked by descriptor rather than by resolving one, so a host that adopts neither pays no pipeline cost.
PromptTaggingChatClient
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/PromptTaggingChatClient.cs:21· Level 9 · class
- What it is: a
DelegatingChatClientthat copies a call'sPromptContractidentity (name, version, hash) onto the currentActivityas tags, but only when that activity belongs toAiUsageMeter's own trace source (PromptTaggingChatClient.cs:52-63). - Depends on:
Microsoft.Extensions.AI.DelegatingChatClient/IChatClient,System.Diagnostics.Activity,AiUsageMeter(forMeterName, matched againstActivity.Current.Source.Name),PromptContract(for the three property-key constants it reads offChatOptions.AdditionalProperties). - Concept introduced, telemetry tagging as its own decorator layer.
[Rubric §13, Observability & Operability](assesses whether a traced call carries the prompt identity that produced it, not just a raw request/response pair): rather than folding this intoUsageRecordingChatClient, it is a separate, single-purpose client registered near the end of the pipeline, after the OpenTelemetry instrumentation it tags has already started the activity it writes into. - Walkthrough:
GetResponseAsyncandGetStreamingResponseAsync(PromptTaggingChatClient.cs:29-46) both call the privateTagbefore delegating to the base client.Tag(PromptTaggingChatClient.cs:48-63) returns immediately unlessActivity.Currentis non-null, itsSource.NameequalsAiUsageMeter.MeterNameexactly (ordinal comparison), and the call'sChatOptions.AdditionalPropertiesis present; when all three hold, it walksPromptContract.NamePropertyKey/VersionPropertyKey/HashPropertyKey, and for each one present as astringvalue callsactivity.SetTag(key, text). - Why it's built this way: matching the activity source name, rather than tagging whatever activity happens to be current, keeps this client inert when nothing downstream is listening on the AI meter's own trace source, so it never writes tags onto an unrelated caller's span.
- Where it's used: registered last among the governance decorators, immediately before
.UseLogging(), byAiServiceCollectionExtensions.AddMmcaChatClient(DependencyInjection.cs:183).
AiServiceCollectionExtensions
MMCA.Common.AI ·
MMCA.Common.AI·MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:77· Level 10 · class
- What it is: the DI entry point for the whole AI integration: two
extension(IServiceCollection)members,AddMmcaChatClient(IConfiguration)and the factory overloadAddMmcaChatClient(IConfiguration, Func<IServiceProvider, IChatClient>), that bindAiSettings, and when enabled, build the full governed pipeline. - Depends on:
AiSettings,AiUsageMeter,BoundedChatClient,GuardrailChatClient,PromptTaggingChatClient,IChatGuardrail,IChatRequestRedactor,IChatToolPolicy,UsageRecordingChatClient,IAiProviderFactory(resolved by name, one per adapter package),AiProviderValidator(both as anIValidateOptions<AiSettings>and for its staticMatch/DescribeMismatchhelpers),Microsoft.Extensions.AI.ChatClientBuilder(AddChatClient,.Use,.UseDistributedCache,.UseOpenTelemetry,.UseLogging),Microsoft.Extensions.Caching.Distributed.IDistributedCache,Microsoft.Extensions.Hosting.IHostEnvironment. - Concept introduced, cross-referenced to the primer's
extension(T)note (00-primer.md#c-extensiont-types--read-this-once).[Rubric §2, Design Patterns](assesses whether a composition pipeline is idiomatic): the method builds aChatClientBuilderdecorator chain where "first registered is outermost":BoundedChatClientwraps first (outermost), then, only when the host registered a guardrail or a redactor,GuardrailChatClient, thenUsageRecordingChatClient, then optionally a distributed cache layer, then OpenTelemetry, thenPromptTaggingChatClient, then logging.[Rubric §16, AI-Native Application Architecture]: this is the single composition point that turns raw provider access into the framework's governed AI dependency, and the point that now refuses to build an ungoverned one: an enabled host withRequireGuardrailtrue and no guardrail or redactor, or withAllowToolstrue and noIChatToolPolicy, throws at registration rather than at the first call. - Walkthrough:
AddMmcaChatClient(IConfiguration)(DependencyInjection.cs:88-97) registersAiProviderValidatoras anIValidateOptions<AiSettings>viaTryAddEnumerableBEFORE delegating to the factory overload withCreateProviderChatClient, so an unknown or unregistered provider name is a boot failure validated alongside the data annotations, not a first-call one. The factory overload (DependencyInjection.cs:99-186) is the extension point a test uses to exercise the pipeline without a network, and the one a host with its own credential story plugs into: it binds and validatesAiSettings(.Bind(section).ValidateDataAnnotations().ValidateOnStart(),DependencyInjection.cs:123-126), reads the settings once withsection.Get<AiSettings>() ?? new AiSettings()(DependencyInjection.cs:128), and returns early, registering nothing, when!settings.Enabled(DependencyInjection.cs:129-132). When enabled it checks by service descriptor (not resolve) whether the host registered anyIChatGuardrailorIChatRequestRedactor(DependencyInjection.cs:139-140) and calls the privateRefuseAnUngovernedHost(DependencyInjection.cs:142,202-227), which throws whenRequireGuardrailis true and neither is registered, and separately whenAllowToolsis true and noIChatToolPolicyis registered; both messages name the one-line fix. It then registersAddMetrics()and a singletonAiUsageMeter(DependencyInjection.cs:144-145), then builds the chain viaAddChatClient+.Use(...)forBoundedChatClient, now also handing it every resolvedIChatToolPolicy(DependencyInjection.cs:149-154). Using the descriptor check from above, it addsGuardrailChatClientto the chain only when a guardrail or a redactor was found, resolving both groups at build time (DependencyInjection.cs:156-162). It then addsUsageRecordingChatClient, passing the boundAiSettings.Provideras its fallback provider name (DependencyInjection.cs:164-168). It adds a distributed-cache layer only whenEnableCacheis set AND anIDistributedCacheis actually registered (DependencyInjection.cs:170-175), because a cache the host never registered would make every call throw at resolve time, so the opt-in needs both halves: the switch AND a store to write to. It then applies OpenTelemetry withEnableSensitiveDatagated byIsDevelopmentHost, thenPromptTaggingChatClient, then logging last (DependencyInjection.cs:177-184). The privateCreateProviderChatClient(DependencyInjection.cs:239-248) resolves every registeredIAiProviderFactory, matches one by name againstAiSettings.ProviderthroughAiProviderValidator.Match, and callsfactory.Create(settings, serviceProvider); a name with no matching factory throwsInvalidOperationExceptionbuilt fromAiProviderValidator.DescribeMismatch, which is a belt for the case where the options pipeline was bypassed, sinceAiProviderValidatorhas already refused an unmatched name at startup. The privateIsDevelopmentHost(DependencyInjection.cs:262-266) fails CLOSED: an environment it cannot positively identify asDevelopment(by scanning already-registeredIHostEnvironmentinstances) reads as not-Development, so prompt and completion text never reach telemetry by accident, mirroring the same gate onPersistence:EnableSensitiveDataLoggingelsewhere in the framework. - Why it's built this way: an early return for
!Enabledmeans a host that ships theAisection disabled pays zero DI registration cost, not just a runtime no-op. Reading settings once viasection.Get<AiSettings>()(rather than resolvingIOptions<AiSettings>before the container is built) lets the method branch onEnabledsynchronously during registration.RefuseAnUngovernedHostruns at registration, with the fix named in the message both times, so a bound nobody reaches is not a bound: the deployment fails instead of the tenth user. Resolving the provider by name against registeredIAiProviderFactoryinstances, rather than switching on a closed enum, means a new vendor is a new adapter package, never a change to this class. SeeADR-120. - Where it's used: the entry point every AI-enabled host calls; no other type in
MMCA.Common.AIcalls it, so its consumers are downstream application hosts such as ADC'sMMCA.ADC.Conference.Service.
AiUsageMeter
MMCA.Common.AI.Observability ·
MMCA.Common.AI.Observability·MMCA.Common/Source/Core/MMCA.Common.AI/Observability/AiUsageMeter.cs:20· Level 0 · class
- What it is: the
System.Diagnostics.Metricswrapper that records token usage and call latency for the AI dependency: twoCounter<long>instruments (input and output tokens) plus aHistogram<double>for end-to-end call duration in seconds (AiUsageMeter.cs:59-61). - Depends on:
System.Diagnostics.Metrics(Meter,Counter<long>,Histogram<double>,TagList),IMeterFactory(Microsoft.Extensions.Diagnostics.Metrics), andMicrosoft.Extensions.AI.UsageDetails. - Concept introduced, factory-owned meter lifetime.
[Rubric §13, Observability & Operability](assesses whether a dependency's cost/usage is measurable in production): the constructor takesIMeterFactoryand creates itsMeterthrough it, but deliberately does NOT retain or dispose that meter itself (AiUsageMeter.cs:75-92); the factory owns the meter's lifetime, and disposing a factory-owned meter from a consumer would silently kill the instrument for every other holder of the same name, so this type is notIDisposable, matching how the rest of the framework's meters work (AiUsageMeter.cs:63-69,[SuppressMessage("CA2000", ...)]atAiUsageMeter.cs:71-74documents the deliberate suppression). - Walkthrough:
MeterName = "MMCA.Common.AI"(AiUsageMeter.cs:26) is shared with theActivitySourcename the pipeline's OpenTelemetry layer publishes under (AiUsageMeter.MeterNamereused atMMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:181), so a host enables traces and metrics for the AI dependency with one name.InputTokensCounterName/OutputTokensCounterName(AiUsageMeter.cs:29,32,mmca.ai.input_tokens/mmca.ai.output_tokens) name the two counters, andCallDurationHistogramName(AiUsageMeter.cs:45,mmca.ai.call.duration) names the histogram; its doc comment records that Microsoft.Extensions.AI's ownUseOpenTelemetrylayer already publishesgen_ai.client.operation.durationon the same meter, and this instrument does not replace it, it adds theprompt_name/prompt_version/outcomedimensions that let a latency regression be attributed to the prompt that caused it (AiUsageMeter.cs:34-44).Record(AiUsageMeter.cs:104-127) takes a possibly-nullUsageDetails, model, prompt name/version, and provider; anullusage, or one whose counts the provider did not report, records nothing (an absent number must not read as a zero on a spend dashboard,AiUsageMeter.cs:111-114,118-125).RecordDuration(AiUsageMeter.cs:140-152) records the elapsed seconds on every call, including one that threw, tagged with the same attribution dimensions plus anoutcomeofSuccessOutcome,ErrorOutcome, orCanceledOutcome(AiUsageMeter.cs:48,51,57; the canceled value also covers a streamed call whose consumer stopped enumerating before the stream ended,AiUsageMeter.cs:53-56), so a failure rate and a latency distribution come off one series. Both methods share their tagging through the privateAttributionTags(AiUsageMeter.cs:158-168,model/prompt_name/prompt_version/providereach falling back to"unknown"when absent or blank), so a dashboard can join the cost series and the latency series on the same tags.provideris a plainstring?here, not theAiProviderenum: the caller (the resolved chat client'sChatClientMetadata) may name a provider the framework does not enumerate, so the meter records whatever string it is given rather than forcing it through a closed set. - Why it's built this way: recording input and output as two separate counters, tagged by model and
prompt identity, lets a spend dashboard break down cost by exactly the dimensions a prompt-versioning
workflow cares about; the duration histogram carries the same dimensions plus outcome so the same join
works for latency and failure rate. See
ADR-041for the framework's general telemetry conventions andADR-120for this meter's place in the governed pipeline. - Where it's used: registered as a singleton and resolved by
AiServiceCollectionExtensions(MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:145,167);RecordandRecordDurationare called exclusively fromUsageRecordingChatClient, which stops the clock in atry/finallyso every outcome, including a cancellation or an exception, is recorded.
ChatToolPolicy
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ChatToolPolicy.cs:24· Level 0 · class
- What it is: a static helper reading two
AdditionalPropertiesconventions the tool-calling boundary shares: whether a tool declares itself consequential, and which tool names the caller confirmed for the current request. - Depends on:
Microsoft.Extensions.AI(AITool,ChatOptions),System.Text.Json(JsonElement). - Concept introduced, per-request confirmation via
AdditionalProperties.[Rubric §16, AI-Native Application Architecture](assesses tool-calling safety patterns): a confirmation is carried as a key inChatOptions.AdditionalProperties, never as a typed property, so it composes with anyChatOptionswithout a wrapper type.[Rubric §11, Security]: the confirmation is scoped to ONE request by design (ChatToolPolicy.cs:39-42remark), because a confirmation that outlived the request it was given for would be a standing grant, exactly what a confirmation step exists to avoid. The consequential marker, by contrast, fails closed: it is the whole write-safety gate, so only a definite false reads as harmless (ChatToolPolicy.cs:58-61). - Walkthrough:
ConsequentialPropertyKey = "mmca.tool.consequential"(ChatToolPolicy.cs:30) marks anAIToolas doing something that cannot be undone by not reading the answer.ConfirmedToolsPropertyKey = "mmca.tool.confirmed"(ChatToolPolicy.cs:43) holds the confirmed names for the current request.IsConsequential(ChatToolPolicy.cs:51-70) reads the tool'sAdditionalProperties: a missing key isfalse; aboolvalue is used as-is; astringvalue goes through the privateParsedOrConsequential(ChatToolPolicy.cs:72-73), which returnsfalseonly for text that parses asfalseandtruefor anything else, unparseable text included (configuration hands a boolean over as text, and a tool declared consequential in a settings file must not read as harmless because of that); aJsonElementof kindFalseisfalse, aJsonElementof kindStringgoes through the sameParsedOrConsequential(a JSON-bound bag hands values over asJsonElement); and a marker present in any other shape reads astrue(ChatToolPolicy.cs:62-69).ReadConfirmedTools(ChatToolPolicy.cs:78-109) reads the confirmed names offChatOptions.AdditionalProperties, accepting a singlestring, anIEnumerable<string>, aJsonElementof kindString(one name) or of kindArray(its string items, non-string items and empty names dropped) (ChatToolPolicy.cs:94-99), or a non-genericIEnumerable(a JSON array bound asobject[]), converting each element withConvert.ToStringand dropping empties; anything else returns none. The twoJsonElementarms exist because options carried over the wire bind their bag values asJsonElement, a struct that is neither a string nor a sequence, so without them a confirmed tool would read as unconfirmed and be stripped (ChatToolPolicy.cs:91-93). - Why it's built this way: see
ADR-120for the guardrail boundary this policy plugs into. - Where it's used:
BoundedChatClientcalls both methods when filtering tools through the registeredIChatToolPolicyinstances.
ContentMetadata
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ContentMetadata.cs:9· Level 0 · class
- What it is: an
internal statichelper holding the one rule both rewriting guardrails follow when they rebuild a content item to change its text: which metadata the rebuilt copy keeps (ContentMetadata.cs:5-8). It is shared byPiiRedactionGuardrailandContentPolicyGuardrail"so the two cannot drift". - Depends on:
Microsoft.Extensions.AI(AIContent). - Concept introduced, metadata fidelity without the raw representation.
[Rubric §11, Security](data exposure: whether a control can be undone downstream): a redacting rebuild keeps what describes the item but drops what still carries the original text, because a copiedRawRepresentationwould hand the unredacted provider object back to the adapter. - Walkthrough:
WithMetadataOf<TContent>(AIContent source, TContent rebuilt)(ContentMetadata.cs:22-28, constrainedwhere TContent : AIContent) copiesAdditionalPropertiesandAnnotationsfromsourceontorebuiltand returnsrebuilt, so a caller can wrap the constructor call inline. It deliberately does NOT copyRawRepresentation(ContentMetadata.cs:11-17): the provider's original object still holds the unredacted text, and the OpenAI adapters send a raw representation verbatim in place of the item, which would undo the redaction. Type-specific members (a tool call'sInformationalOnly, a tool result'sException) are not its concern; the caller sets those on the rebuilt item. - Why it's built this way: one helper instead of two private copies keeps the redaction
guarantee identical across both guardrails. See
ADR-120. - Where it's used:
PiiRedactionGuardrail.RedactContent(PiiRedactionGuardrail.cs:127-150, text, reasoning, tool-result and tool-call arms) andContentPolicyGuardrail.RedactMessage(ContentPolicyGuardrail.cs:341, rebuilt text items).
ContentPolicyInjectionMode
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ContentPolicyInjectionMode.cs:16· Level 0 · enum
- What it is: the three-way mode
ContentPolicyGuardrailreads to decide what to do with a prompt-injection marker found in user-role content. - Depends on: read by
ContentPolicySettingsandContentPolicyGuardrail. - Walkthrough:
Redact = 0(ContentPolicyInjectionMode.cs:22-26, the default) replaces every marker match withContentPolicySettings.RedactionPlaceholderand lets the call proceed.Block = 1(ContentPolicyInjectionMode.cs:28-32) leaves the content alone and refuses the call, naming the marker that matched.Off = 2(ContentPolicyInjectionMode.cs:34-35) does neither; configured response patterns still apply, because they are a separate axis. - Why it's built this way: keeping redact/block/off as an explicit, mutually exclusive mode (rather
than two independent booleans) means a host cannot accidentally combine "rewrite the content" with
"refuse the call on the same content", which would be a contradiction. See
ADR-120. - Where it's used:
ContentPolicySettings.InjectionMode;ContentPolicyGuardrailbranches on it inRedactandInspectRequestAsync.
GoldenReplayCase
MMCA.Common.AI.Testing ·
MMCA.Common.AI.Testing·MMCA.Common/Source/Hosting/MMCA.Common.AI.Testing/GoldenReplayCase.cs:13· Level 0 · record
- What it is: one entry in a golden-replay corpus: an
Id, aDescription, and aResponsePathnaming the recorded response file to replay for that case (GoldenReplayCase.cs:13-17). - Depends on: none first-party; consumed by
GoldenReplayTestsBase. - Walkthrough: a
sealed recordwith three positional properties;ToString()returnsId(GoldenReplayCase.cs:15-16), which is what an xUnit theory display name shows. - Where it's used:
GoldenReplayTestsBase.Cases; ADC'sMMCA.ADC.Conference.Scoring.Evaluation.Testsdeclares its own corpus of these.
IChatRequestRedactor
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/IChatRequestRedactor.cs:25· Level 0 · interface
- What it is: the contract for rewriting a request's messages before they reach the model.
- Depends on:
Microsoft.Extensions.AI.ChatMessage. - Concept introduced, new instances, never a mutation.
[Rubric §1, SOLID](single responsibility: redaction is its own boundary, separate from allow/block decisions):Redactreturns NEWChatMessageinstances carrying the redacted content (IChatRequestRedactor.cs:29-33remark); a caller holding its own conversation history, retry buffer, or audit record must still hold exactly what it built, so an implementation that edited the incoming messages in place would silently rewrite the caller's own data as a side effect of sending it. - Walkthrough: one method,
Redact(IReadOnlyList<ChatMessage> messages)(IChatRequestRedactor.cs:34), returning the messages to send in place of the ones supplied. - Why it's built this way: separating redaction (
IChatRequestRedactor, changes the payload) from a guardrail's allow/block decision (IChatGuardrail) lets a type implement one, the other, or both, asContentPolicyGuardrailandPiiRedactionGuardraildo. SeeADR-120. - Where it's used: implemented by
ContentPolicyGuardrailandPiiRedactionGuardrail; consumed byGuardrailChatClientand byDependencyInjection'sAiSettings.RequireGuardrailcheck.
RecordedResponses
MMCA.Common.AI.Testing ·
MMCA.Common.AI.Testing·MMCA.Common/Source/Hosting/MMCA.Common.AI.Testing/RecordedResponses.cs:20· Level 0 · class
- What it is: the static read/write/serialize/deserialize helpers for a recorded
ChatResponsefixture on disk. - Depends on:
Microsoft.Extensions.AI.AIJsonUtilities,System.Text.Json. - Walkthrough:
Options(RecordedResponses.cs:26-27) is the abstraction's own serializer options withWriteIndented = true, so a recorded answer stays readable in review and a re-recording produces a reviewable diff instead of one long line.Read(path)(RecordedResponses.cs:31-40) throwsFileNotFoundExceptionnaming the path and hinting that the corpus may not be copied to the output directory.Write(path, response)(RecordedResponses.cs:44-53) creates the target folder if needed before writing.Serialize/Deserialize(RecordedResponses.cs:57-63,67-73) wrapJsonSerializerwithOptions;Deserializethrows if the JSON does not round-trip to aChatResponse. - Where it's used:
GoldenReplayTestsBasereads a recorded response by path for each case; ADC'sGoldenCase/GoldenReplayTests(Conference.Scoring.Evaluation.Tests) and this repo's own evaluation tests.
ReplayChatClient
MMCA.Common.AI.Testing ·
MMCA.Common.AI.Testing·MMCA.Common/Source/Hosting/MMCA.Common.AI.Testing/ReplayChatClient.cs:17· Level 0 · class
- What it is: an
IChatClienttest double that answers every call with one or more recordedChatResponses, in order. - Depends on:
Microsoft.Extensions.AI.IChatClient(implements it),ChatResponse. - Concept introduced, deterministic replay over a live model call.
[Rubric §14, Testability](assesses whether behavior that depends on an external, non-deterministic dependency can be exercised deterministically): rather than calling a live model, a test replays a recorded transcript, so the assertion exercises the consumer's parsing and handling code without network variance. - Walkthrough: two constructors (
ReplayChatClient.cs:26-29,37-47), one for a single response, one for an ordered list (rejecting an empty list, since a replay client needs at least one recorded answer).LastOptions,LastMessages,CallCount(ReplayChatClient.cs:50-56) record what the client was last called with, for assertions.GetResponseAsyncandGetStreamingResponseAsync(ReplayChatClient.cs:65-94) both funnel through the privateRecord, with the streaming path projecting the SAME recorded response onto the streaming shape via the abstraction's ownToChatResponseUpdates()conversion, so a test asserting the streamed answer exercises the same corpus as the buffered path rather than a second, hand-built approximation of it.Record(ReplayChatClient.cs:104-122) advances through the recorded list and repeats the LAST response once exhausted, so a test calling once more than it recorded gets a stable answer rather than an exception from the harness pretending to be a failure in the code under test. It snapshots the messages outside the lock, then takes oneLock(_sync,ReplayChatClient.cs:20) over the index read, theCallCountincrement and theLastMessages/LastOptionswrites (ReplayChatClient.cs:112-121), so concurrent calls neither lose a count nor pair one call's messages with another call's options.Disposeis a no-op: the replay client holds no transport.GetServicereturns itself only when asked for its own type. - Where it's used:
GoldenReplayTestsBase.RunCaseAsyncconstructs one per case;ReplayChatClientTests, ADC'sGoldenReplayTests, andReferenceGoldenReplayTests.
ToolAuthorization
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ToolAuthorization.cs:10· Level 0 · enum
- What it is: the two-value outcome an
IChatToolPolicyreturns for one tool on one request:Denied(ToolAuthorization.cs:12, not offered to the model) orAllowed(ToolAuthorization.cs:14, may be offered unless another policy or the confirmation rule refuses it). - Where it's used: return type of
IChatToolPolicy.Authorize; read byBoundedChatClientwhen filtering tools.
GoldenReplayTestsBase
MMCA.Common.AI.Testing ·
MMCA.Common.AI.Testing·MMCA.Common/Source/Hosting/MMCA.Common.AI.Testing/GoldenReplayTestsBase.cs:24· Level 1 · class
- What it is: the abstract xUnit base that drives a golden-replay corpus: for every
GoldenReplayCaseit reads the recorded response, wraps it in aReplayChatClient, and hands both to the derived class's assertion. - Depends on:
GoldenReplayCase,RecordedResponses,ReplayChatClient, xUnit[Fact]. - Concept introduced, aggregated-failure regression gate.
[Rubric §14, Testability]: rather than stopping at the first failing case, the single[Fact]runs every case and reports the complete failure set in one message, so a full corpus run always shows every regression from one CI attempt. - Walkthrough:
CasesandAssertCaseAsync(GoldenReplayTestsBase.cs:27,34-38) are the two hooks a derived test class implements: the corpus, and how one case's outcome is asserted (throwing, an assertion failure or anything else, fails that case and is reported against its id).EveryGoldenCase_ReplaysAsRecorded(GoldenReplayTestsBase.cs:41-59) copiesCasesdefensively, throwsInvalidOperationExceptionif the corpus is empty (an empty gate would pass without evaluating anything), runs every case throughRunCaseAsync, and throws one exception listing every failure whenfailures.Count > 0.RunCaseAsync(GoldenReplayTestsBase.cs:61-86) resolves the response path underAppContext.BaseDirectory, reads it viaRecordedResponses.Read, builds aReplayChatClient, and callsAssertCaseAsync; every exception is caught (CA1031/S2221suppressed atGoldenReplayTestsBase.cs:70with a comment that a failing case must be reported, not end the run) and turned into one line naming the case's id and description. - Why it's built this way: see
ADR-120andADR-111for the scoring-governance context this harness was built for. - Where it's used: ADC's
GoldenReplayTests(Conference.Scoring.Evaluation.Tests) and this repo's ownReferenceGoldenReplayTests/ReplayChatClientTestssubclass it, exercising the gate itself in this repo's CI.
IChatToolPolicy
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/IChatToolPolicy.cs:23· Level 1 · interface
- What it is: the contract letting a host decide whether ONE tool may be offered on ONE request.
- Depends on:
Microsoft.Extensions.AI(AITool,ChatOptions),ToolAuthorization. - Concept introduced, pluggable per-tool authorization.
[Rubric §11, Security](assesses whether a tool-calling boundary can restrict what a model can act on): tool authorization is a registered, per-request extension point rather than a static allow-list, so a host can vary which tools are offered by caller identity, role, or the request's content. - Walkthrough: one method,
Authorize(AITool tool, ChatOptions options)(IChatToolPolicy.cs:29), returningToolAuthorization.Allowedto offer the tool, otherwise it is stripped from the request. - Where it's used: every registered
IChatToolPolicyis consulted byBoundedChatClient(a consequential tool, perChatToolPolicy.IsConsequential, additionally needs the request's confirmed-tools stamp);DependencyInjectionregisters implementations;StubToolPolicyin tests.
PromptContractPinTestsBase
MMCA.Common.AI.Testing ·
MMCA.Common.AI.Testing·MMCA.Common/Source/Hosting/MMCA.Common.AI.Testing/PromptContractPinTestsBase.cs:25· Level 1 · class
- What it is: the abstract xUnit base pinning every declared
PromptContract's hash to a recorded JSON file, so a prompt, model, or system-text change without a version bump fails CI. - Depends on:
PromptContract(Name,Version,Hash),System.Text.Json. - Concept introduced, prompt-version pinning.
[Rubric §16, AI-Native Application Architecture](assesses whether prompt changes are governed the way schema or contract changes are): this is the companion gate toGoldenReplayTestsBase; the golden-replay base tests the CODE's handling of a recorded answer, this base tests that the PROMPT itself did not drift silently. - Walkthrough:
ContractsandPinFilePath(PromptContractPinTestsBase.cs:29,35) are the two hooks a derived class implements: the contracts this repo pins, and the pin file's path relative toAppContext.BaseDirectory.EveryContract_HasARecordedHash(PromptContractPinTestsBase.cs:37-52) fails naming the exact"<Name>@<Version>": "<hash>"line to add for any contract with no pinned entry, because an unrecorded version is a prompt nobody evaluated.EveryRecordedHash_MatchesTheCurrentContract(PromptContractPinTestsBase.cs:54-70) fails naming the recorded and the current hash for any contract whoseHashno longer matches what is pinned.KeyOf(PromptContractPinTestsBase.cs:72) builds the"<Name>@<Version>"key both methods share.LoadContracts(PromptContractPinTestsBase.cs:74-86) throws ifContractsis empty, the same empty-gate guardGoldenReplayTestsBaseapplies.ReadPinFile(PromptContractPinTestsBase.cs:90-107) treats a MISSING pin file as zero recorded entries rather than a failure: that is exactly the "nothing is recorded yet" caseEveryContract_HasARecordedHashalready reports, with the lines to add and the path to add them to. - Why it's built this way: see
ADR-120andADR-111. - Where it's used: ADC's
PromptContractTests, and this repo'sReferencePromptContractTests/RecordedResponsesTestssubclass it.
ContentPolicyGuardrail
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ContentPolicyGuardrail.cs:64· Level 2 · class
- What it is: the sealed partial class implementing both
IChatGuardrailandIChatRequestRedactorfor prompt-injection and answer-content policy: it redacts or blocks user content matching a marker (eight built-in patterns plus any configuredAdditionalRequestPatterns), and refuses an answer matching a configuredBlockedResponsePatternsentry. - Depends on:
ContentPolicySettings(bound options),ContentPolicyInjectionMode,IChatGuardrail/IChatRequestRedactor/GuardrailVerdict,ContentMetadata,System.Text.RegularExpressions([GeneratedRegex]source generator). - Concept introduced, one type composing redaction AND blocking.
[Rubric §11, Security](prompt-injection defense: explains what it protects and where it stops): onlyChatRole.Usercontent is inspected or rewritten, and the streaming inspection path (InspectStreamedUpdateAsync) checks each fragment rather than the whole answer, so a pattern that spans two streamed updates is missed by design; a host that needs whole-answer certainty uses the buffered path.[Rubric §16, AI-Native Application Architecture]. - Walkthrough:
ConfiguredPatternOptions(ContentPolicyGuardrail.cs:67,IgnoreCase | CultureInvariant) is what every CONFIGURED pattern compiles with;MatchTimeout(ContentPolicyGuardrail.cs:70, 1 second) bounds a single match so a pathological pattern cannot hang a request;BuiltInPatternOptionsaddsExplicitCaptureon the BUILT-INS ONLY (ContentPolicyGuardrail.cs:72-75), because turning that off under a host would silently change what a configured pattern's own backreferences mean.BuiltInMarkers(ContentPolicyGuardrail.cs:77-87) pairs eight labeled[GeneratedRegex]patterns (ignore-previous-instructions, disregard-system-prompt, role-reassignment, new-instructions, reveal-system-prompt, act-as-unrestricted, developer-mode, do-anything-now), each with a 1-second generated-regex timeout. The constructor (ContentPolicyGuardrail.cs:100-107) compiles every configured pattern ONCE, at resolve time; a pattern that does not compile has already failedContentPolicySettings.Validateat startup, so this constructor is not where a typo is discovered.Redact(ContentPolicyGuardrail.cs:118-138) rewrites only whenInjectionModeisRedact: Block mode must inspect what the caller supplied, so rewriting there would hide the marker from the check that is supposed to refuse it; Off mode does neither. Non-user messages pass through as the same instance, and a rewritten user message is a NEW instance, so a caller holding its own conversation history still holds exactly what it built (ContentPolicyGuardrail.cs:110-117remark).InspectRequestAsync(ContentPolicyGuardrail.cs:146-164) allows in every mode exceptBlock, because inRedactmode the redactor has already run and there is nothing left to refuse.InspectResponseAsync/InspectStreamedUpdateAsync(ContentPolicyGuardrail.cs:167-192) both funnel throughInspectAnswerText, which matches againstBlockedResponsePatternsand returns a block reason naming the pattern's INDEX, never the matched text: the reason reaches the caller, and the point of the rule was that the text should not.RedactMessage/RedactText(ContentPolicyGuardrail.cs:331-373) rewrite onlyTextContent, passing images, function calls, and provider-specific content through untouched: a guardrail that silently dropped content it does not understand would be a far worse failure than leaving it alone. Inside a rewritten message, a text item with no marker is kept as the same instance, and a rebuilt text item keeps itsAdditionalPropertiesandAnnotationsthrough the sharedContentMetadata.WithMetadataOf(ContentPolicyGuardrail.cs:338-341), the same helperPiiRedactionGuardrailuses; the rebuilt message keepsAuthorName,MessageId,CreatedAtandAdditionalProperties(ContentPolicyGuardrail.cs:352-358).RedactText(ContentPolicyGuardrail.cs:361) replaces each marker with aMatchEvaluatorthat returns the placeholder, not the replacement-string overload: the placeholder is literal text, and as a replacement pattern$&or$0would put the matched marker straight back. - Why it's built this way: matches the ADR-120 governed pipeline's split between redaction (silent,
changes the request shape) and blocking (visible, a refusal). See
ADR-120. - Where it's used: registered by
GuardrailServiceCollectionExtensions.AddContentPolicyGuardrailas bothIChatGuardrailandIChatRequestRedactorfrom one singleton; ADC'sSessionScoreResponseGuardrailcomposes it.
ContentPolicySettings
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ContentPolicySettings.cs:23· Level 2 · class
- What it is: the
IValidatableObject-boundAi:ContentPolicyoptions:InjectionMode,AdditionalRequestPatterns,BlockedResponsePatterns,RedactionPlaceholder. - Depends on:
ContentPolicyInjectionMode,ContentPolicyGuardrail(reuses its pattern-compile options and timeout inValidate),System.ComponentModel.DataAnnotations. - Concept introduced, options validation as a startup gate.
[Rubric §17, DevOps](fail-fast configuration: a bad setting fails the deployment, not the first request it affects):Validatecompiles every configured pattern and reports every failure at once, so the person deploying a typo'd pattern, not the first user who trips it, is the one who sees it. - Walkthrough:
SectionName = "Ai:ContentPolicy"(ContentPolicySettings.cs:26);DefaultRedactionPlaceholder = "[redacted-instruction]"(ContentPolicySettings.cs:29), a VISIBLE placeholder on purpose (ContentPolicySettings.cs:64-68remark) so the model can tell something was removed and a trace reviewer can tell a redaction from a sentence the user never wrote.InjectionModedefaultsRedact(ContentPolicySettings.cs:35).AdditionalRequestPatterns/BlockedResponsePatterns(ContentPolicySettings.cs:45,57) default to empty; an emptyBlockedResponsePatternsswitches the response half of the policy off entirely, because a model that has already said the thing cannot unsay it, so the only available response-side answer is a refusal.RedactionPlaceholderis[Required](ContentPolicySettings.cs:69-70) and is inserted as literal text, so a$sequence in it is not a regex substitution (ContentPolicySettings.cs:59-62).Validate(ContentPolicySettings.cs:83-94) runsValidatePatternsover both lists, which callsDescribeFailure(ContentPolicySettings.cs:117-133) per pattern: an empty-or-whitespace pattern is rejected outright (it would match everything, "never what a policy meant"), otherwise the pattern is compiled withContentPolicyGuardrail.ConfiguredPatternOptions/MatchTimeoutand anyArgumentExceptionmessage is surfaced againstSectionName:memberName[index]. - Why it's built this way: see
ADR-120. - Where it's used: bound and validated by
GuardrailServiceCollectionExtensions.AddContentPolicyGuardrail; read byContentPolicyGuardrail.
PiiRedactionGuardrail
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/PiiRedactionGuardrail.cs:40· Level 3 · class
- What it is: the sealed partial class implementing
IChatRequestRedactorandIChatGuardrailthat strips emails and phone numbers from the text carried by outgoing messages, tool calls and tool results included. The guardrail half is a pass-through (alwaysAllow): this type only redacts, it never blocks. - Depends on:
IChatRequestRedactor/IChatGuardrail/GuardrailVerdict,ContentMetadata,System.Text.RegularExpressions,System.Text.Json. - Concept introduced, configuration-free, unconditional redaction. Contrasts with
ContentPolicyGuardrail:PiiRedactionGuardrailhas no configurable options and applies to every outgoing message unconditionally. - Walkthrough:
EmailPattern/PhonePattern(PiiRedactionGuardrail.cs:71-81) are[GeneratedRegex]source-generated matchers with a 1-second timeout (email: a standard local-part@domain shape; phone: an optional leading country code plus a 10-digit US-shaped number, guarded by negative lookaround so it does not clip a longer digit run).Redact(PiiRedactionGuardrail.cs:46-57) rewrites EVERY message, not onlyChatRole.User(unlikeContentPolicyGuardrail), via the privateRedactMessage(PiiRedactionGuardrail.cs:83-100), which rebuilds each message (keeping role,AuthorName,MessageId,CreatedAtandAdditionalProperties,PiiRedactionGuardrail.cs:93-99) and hands every content item toRedactContent(PiiRedactionGuardrail.cs:123-157). That switch rebuilds an item only when it actually holds contact details: non-emptyTextContentandTextReasoningContent, aFunctionResultContentwhose result is aJsonElementor astring, and aFunctionCallContentwith an argument value that is astringor aJsonElement(routed throughRedactArgument,PiiRedactionGuardrail.cs:159-164; other argument values kept as-is). The "holds contact details" test isHasContactDetails/ArgumentHasContactDetails/JsonHasContactDetails(PiiRedactionGuardrail.cs:166-190), which comparesRedactTextoutput with the input; an item with nothing to redact passes through by reference. A rebuilt item keeps its metadata through the sharedContentMetadata.WithMetadataOf(AdditionalPropertiesandAnnotations), plus a tool call'sInformationalOnlyandExceptionand a tool result'sException, set in the rebuilding arms themselves (PiiRedactionGuardrail.cs:133-150), but NOTRawRepresentation: the provider's original object still holds the unredacted text, and the OpenAI adapters send a raw representation verbatim in place of the item, which would undo the redaction (PiiRedactionGuardrail.cs:102-122). ATextReasoningContentcarryingProtectedDatais never rebuilt (PiiRedactionGuardrail.cs:125): that data is the provider's signature over the text (Anthropic's thinking signature), and an edited signed block is rejected when the conversation is sent back. Both shapes matter because Microsoft.Extensions.AI's function-invoking client marshals a tool's return value and the model's arguments intoJsonElement, and a tool's output is exactly where contact details turn up (a customer lookup, a directory search). Plain text goes throughRedactText(PiiRedactionGuardrail.cs:192-193), which replaces email matches then phone matches. AJsonElementgoes throughRedactJson(PiiRedactionGuardrail.cs:200-210) and the recursiveWriteRedacted(PiiRedactionGuardrail.cs:212-244), which rebuilds the document with aUtf8JsonWriter, redacting only string VALUES and copying property names, numbers, booleans and nulls verbatim: running the patterns over the raw JSON text would turn a bare 10-digit number (a Unix timestamp) into a phone placeholder and break the document. Binary and other non-text content (images, results and arguments that are neither a string nor aJsonElement, provider-specific items) passes through untouched for the same reasonContentPolicyGuardrailgives (a guardrail that silently dropped content it does not understand would be worse than leaving it alone).InspectRequestAsync/InspectResponseAsync(PiiRedactionGuardrail.cs:60-69) both unconditionally returnGuardrailVerdict.Allow. - Why it's built this way: unconditional redaction with zero configuration lets the guardrail
satisfy
AiSettings.RequireGuardrailwith no setup. SeeADR-120andADR-111. - Where it's used: registered by
GuardrailServiceCollectionExtensions.AddPiiRedactionGuardrail(both contracts from one singleton); ADC'sSessionScoreResponseGuardrailandSessionScoringServicecompose it.
GuardrailServiceCollectionExtensions
MMCA.Common.AI.Guardrails ·
MMCA.Common.AI.Guardrails·MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/GuardrailServiceCollectionExtensions.cs:17· Level 3 · class
- What it is: the extension block (C#
extension(IServiceCollection services)) registering the two shipped guardrails:AddPiiRedactionGuardrail()andAddContentPolicyGuardrail(configuration). - Depends on:
PiiRedactionGuardrail,ContentPolicyGuardrail,ContentPolicySettings,IChatGuardrail/IChatRequestRedactor,Microsoft.Extensions.DependencyInjection. - Concept introduced, one singleton under two contracts.
[Rubric §1, SOLID](interface segregation without instance duplication): each guardrail implements bothIChatGuardrailandIChatRequestRedactor, and both are registered from the SAME singleton viaTryAddEnumerable(... => serviceProvider.GetRequiredService<T>()), never two independent instances; a host that later gives the type state would otherwise get two copies of it. Registration uses C# extension members as the idiom, not static extension methods. - Walkthrough:
AddPiiRedactionGuardrail(GuardrailServiceCollectionExtensions.cs:33-44) registersPiiRedactionGuardrailonce viaTryAddSingleton, thenTryAddEnumerable-registers it under bothIChatGuardrailandIChatRequestRedactor.AddContentPolicyGuardrail(GuardrailServiceCollectionExtensions.cs:62-79) additionally bindsContentPolicySettingsfromAi:ContentPolicywith.ValidateDataAnnotations().ValidateOnStart()(so a bad pattern fails the deployment, not a user's request) before registeringContentPolicyGuardrailthe same two-contract way. Both methods' doc remarks state they must be called BEFOREAddMmcaChatClient, which reads the registered descriptors to decide whether to compose the guardrail layer at all. - Why it's built this way: see
ADR-120. - Where it's used: called by a consuming host before
AddMmcaChatClient; ADC'sConference.InfrastructureDependencyInjectionis the one usage site outside this file's own registration tests.
IAiProviderFactory
MMCA.Common.AI ·
MMCA.Common.AI.Providers·MMCA.Common/Source/Core/MMCA.Common.AI/Providers/IAiProviderFactory.cs:20· Level 1 · interface
- What it is: the boundary a vendor SDK adapter implements to plug into
AddMmcaChatClient's provider selection.Name(IAiProviderFactory.cs:31) is the case-insensitive string a config file'sAi:Providerselects it by;Create(IAiProviderFactory.cs:34) builds the ungovernedIChatClientfor already-validatedAiSettings. - Depends on:
AiSettings(the boundAisection it receives),Microsoft.Extensions.AI.IChatClientandIServiceProvider(both external), both parameters ofCreate(IAiProviderFactory.cs:34). - Concept introduced, the provider-factory boundary.
[Rubric §1, SOLID](assesses dependency inversion and substitutability):AddMmcaChatClientandAiProviderValidatordepend only on this interface, never on a concrete vendor SDK type, so a new provider (a third LLM vendor) is one new adapter package plus one registration call, with zero changes to the pipeline or the validator.[Rubric §16, AI-Native Application Architecture](assesses whether provider selection is a first-class, swappable concern rather than hard-wired code): this interface is the entire swap point, recorded in ADR-120. - Walkthrough: two members only,
Name(IAiProviderFactory.cs:31, astringgetter) andCreate(AiSettings settings, IServiceProvider serviceProvider)(IAiProviderFactory.cs:34, returnsIChatClient). The doc comment onCreate(IAiProviderFactory.cs:33) records that ownership of the returned client passes to the governed pipeline that wraps it, i.e. the factory itself never disposes what it builds. - Why it's built this way:
serviceProvideris passed alongsidesettingsso an adapter that needs a logger or a namedHttpClientfactory can resolve one without the interface growing a parameter per future need (IAiProviderFactory.cs:32). The instances that implement it,AnthropicAiProviderFactoryandOpenAiProviderFactory, are registered asIEnumerable<IAiProviderFactory>soAddMmcaChatClientandAiProviderValidatorcan enumerate every adapter package the host actually referenced, never a fixed list. - Where it's used:
DependencyInjection.csinMMCA.Common.AIresolves the matching factory to build the governed client;AiProviderValidatorenumerates all registered factories to validateAi:Providerat startup;MMCA.ADC.Conference.Service/Program.csregisters an adapter package that implements it. - Caveats / not-in-source: the interface itself carries no lifetime attribute; the registration
call sites (
AnthropicAiServiceCollectionExtensions.AddAnthropicAiProvider,OpenAiServiceCollectionExtensions.AddOpenAiProvider) are what fix it to singleton, not this type.
AiProviderValidator
MMCA.Common.AI ·
MMCA.Common.AI.Providers·MMCA.Common/Source/Core/MMCA.Common.AI/Providers/AiProviderValidator.cs:16· Level 2 · class
- What it is: an
IValidateOptions<AiSettings>that runs at options-bind time and fails startup whenAi:Providernames no registeredIAiProviderFactory, rather than surfacing that mismatch on the first chat call. - Depends on:
IEnumerable<IAiProviderFactory>(constructor-injected,AiProviderValidator.cs:16),AiSettings(the options type it validates),Microsoft.Extensions.Options.IValidateOptions<TOptions>andValidateOptionsResult(external). - Concept introduced, options validation as a startup gate.
[Rubric §11, Security]and[Rubric §13, Observability & Operability](assesses whether misconfiguration fails fast, loudly, and early rather than at request time): a blank or misspelled provider name is caught once here, during host startup, instead of surfacing as a confusing runtime failure inside the first request that needs the chat client. - Walkthrough: the constructor captures the injected factories into a fixed array
(
AiProviderValidator.cs:18,_factories = [.. factories]).Validate(AiProviderValidator.cs:65) first short-circuits whenEnabledis false orProvideris blank (AiProviderValidator.cs:27, deferring to the data-annotation validator so the blank case is reported exactly once, not twice). Otherwise it calls the static helperMatch(AiProviderValidator.cs:41), which does a case-insensitiveFirstOrDefaultover the factories'Name; a hit returnsSuccess, a miss callsDescribeMismatch(AiProviderValidator.cs:48) to build the failure text.DescribeMismatchbranches on whether any factory is registered at all (AiProviderValidator.cs:52): zero factories means no adapter package is referenced, so the message names both adapter packages and their registration calls (AddAnthropicAiProvider(),AddOpenAiProvider()) plus the manualAddMmcaChatClientoverload (AiProviderValidator.cs:54-57); one or more factories means the name is simply wrong, so the message lists the registered provider names, sorted case-insensitively (AiProviderValidator.cs:60,62). - Why it's built this way:
MatchandDescribeMismatchareinternal static(AiProviderValidator.cs:41,48) so the unit tests can exercise the matching and message-building logic directly without standing up the full options pipeline. The class itself isinternal sealed(AiProviderValidator.cs:16): it is wiring, registered byMMCA.Common.AI's ownDependencyInjection.cs, not a public extension point. - Where it's used: registered against
AiSettingsbyDependencyInjection.csinMMCA.Common.AIso it runs automatically whenever the options are bound; exercised directly byMMCA.Common/Tests/Core/MMCA.Common.AI.Tests/AiProviderSelectionTests.cs.
AnthropicAiProviderFactory
MMCA.Common.AI.Anthropic ·
MMCA.Common.AI.Anthropic·MMCA.Common/Source/Core/MMCA.Common.AI.Anthropic/AnthropicAiProviderFactory.cs:14· Level 2 · class
- What it is: the
IAiProviderFactoryimplementation for Anthropic, selected whenAi:ProviderequalsAnthropic(ProviderName,AnthropicAiProviderFactory.cs:16). - Depends on:
IAiProviderFactory,AiSettings; externally, the Anthropic SDK'sAnthropicClientandClientOptions, andMicrosoft.Extensions.AI'sAsIChatClientadapter extension (AnthropicAiProviderFactory.cs:48). - Concept: first concrete example of the factory boundary
IAiProviderFactoryintroduces; see that section for the swap-point rationale. Introduces one new idea of its own:AsIChatClientis the SDK's ownMicrosoft.Extensions.AIadapter, so this factory never hand-rolls the Messages API overHttpClient(AnthropicAiProviderFactory.cs:24-25). - Walkthrough:
Namereturns theconst string ProviderName = "Anthropic"(AnthropicAiProviderFactory.cs:17,20).Create(AnthropicAiProviderFactory.cs:33) null-checkssettings, builds an SDKClientOptionsfromApiKeyandTimeout(AnthropicAiProviderFactory.cs:37-41), and, whensettings.Endpointis set, overridesBaseUrl(AnthropicAiProviderFactory.cs:43-46). It then constructsnew AnthropicClient(options).AsIChatClient(settings.Model, settings.MaxOutputTokens)(AnthropicAiProviderFactory.cs:48), passing the model and output ceiling as the client's defaults. - Why it's built this way: a
[SuppressMessage("Reliability", "CA2000", ...)]onCreate(AnthropicAiProviderFactory.cs:29-32) documents why the built client is not disposed here: ownership passes to theIChatClientthe pipeline wraps (seeBoundedChatClient), and the DI container disposes it with the singleton; disposing in the factory would close the transport before the first call. The model and output ceiling passed as SDK defaults are not the last word:BoundedChatClientstill pins and clamps per call (AnthropicAiProviderFactory.cs:26-27), because a default is a suggestion and a bound is not. Recorded in ADR-120. - Where it's used: registered by
AnthropicAiServiceCollectionExtensions.AddAnthropicAiProvider(); exercised byMMCA.Common/Tests/Core/MMCA.Common.AI.Tests/Providers/AdapterFactoryTests.cs.
OpenAiProviderFactory
MMCA.Common.AI.OpenAI ·
MMCA.Common.AI.OpenAI·MMCA.Common/Source/Core/MMCA.Common.AI.OpenAI/OpenAiProviderFactory.cs:18· Level 2 · class
- What it is: the
IAiProviderFactoryimplementation for OpenAI, selected whenAi:ProviderequalsOpenAI(ProviderName,OpenAiProviderFactory.cs:20). - Depends on:
IAiProviderFactory,AiSettings; externally,OpenAI'sOpenAIClient,OpenAIClientOptions,System.ClientModel.ApiKeyCredential, andMicrosoft.Extensions.AI'sAsIChatClient(OpenAiProviderFactory.cs:49-51). - Concept: same factory-boundary role as
AnthropicAiProviderFactory(seeIAiProviderFactoryfor the shared rationale). Differs in one behavior worth flagging on its own: the OpenAI client binds its model at construction time, not per call (OpenAiProviderFactory.cs:28-31). - Walkthrough:
Namereturns theconst string ProviderName = "OpenAI"(OpenAiProviderFactory.cs:21,24).Create(OpenAiProviderFactory.cs:33) null-checkssettings, then requires bothModel(OpenAiProviderFactory.cs:37-38) andApiKey(OpenAiProviderFactory.cs:39-40) with anInvalidOperationExceptionnaming the missingAi:setting when either is absent, unlike Anthropic's factory, which tolerates a null model. It buildsOpenAIClientOptionsfromTimeout(OpenAiProviderFactory.cs:42), appliesEndpointwhen set (OpenAiProviderFactory.cs:44-47), and returnsnew OpenAIClient(new ApiKeyCredential(apiKey), options).GetChatClient(model).AsIChatClient()(OpenAiProviderFactory.cs:49-51). - Why it's built this way: because the SDK binds the model at construction, the pinned model is the
only one every request can go to;
BoundedChatClientrefuses a request naming any other model (OpenAiProviderFactory.cs:28-31), which keeps aPromptContract's model assertion meaningful here the same way it is on adapters (Anthropic's) that honor a per-request override. The eagerInvalidOperationExceptionon a missing model or key fails at firstCreatecall rather than deeper inside the SDK. Recorded in ADR-120. - Where it's used: registered by
OpenAiServiceCollectionExtensions.AddOpenAiProvider(); exercised byMMCA.Common/Tests/Core/MMCA.Common.AI.Tests/Providers/AdapterFactoryTests.cs.
AnthropicAiServiceCollectionExtensions, OpenAiServiceCollectionExtensions
MMCA.Common.AI.Anthropic + MMCA.Common.AI.OpenAI ·
MMCA.Common.AI.Anthropic/MMCA.Common.AI.OpenAI·MMCA.Common/Source/Core/MMCA.Common.AI.Anthropic/DependencyInjection.cs:17· Level 3 · class (static) + class (static)
- What it is: the per-adapter-package DI registration extensions. Each adds its
package's provider factory to the set
AddMmcaChatClientchooses from.
| Type | File:Line | Notes (what differs) |
|---|---|---|
AnthropicAiServiceCollectionExtensions |
MMCA.Common/Source/Core/MMCA.Common.AI.Anthropic/DependencyInjection.cs:17 |
exposes AddAnthropicAiProvider() (DependencyInjection.cs:250), registers AnthropicAiProviderFactory |
OpenAiServiceCollectionExtensions |
MMCA.Common/Source/Core/MMCA.Common.AI.OpenAI/DependencyInjection.cs:17 |
exposes AddOpenAiProvider() (DependencyInjection.cs:288), registers OpenAiProviderFactory |
- Depends on:
Microsoft.Extensions.DependencyInjection.IServiceCollection(external) and each package's own factory (AnthropicAiProviderFactoryorOpenAiProviderFactory). - Concept: both use the
extension(IServiceCollection services)C# member-extension form (primer:extension(T)types) rather than a classic staticthis IServiceCollectionmethod.[Rubric §1, SOLID](open/closed): each adapter package ships the one line that opts it into provider selection, so adding a new vendor never touchesMMCA.Common.AI's own registration code. - Walkthrough: identical shape in both files: null-check
services(DependencyInjection.cs:252/:290), callservices.TryAddEnumerable(ServiceDescriptor.Singleton<IAiProviderFactory, {Factory}>())(DependencyInjection.cs:254/:292), and returnservicesfor chaining (DependencyInjection.cs:256/:294).TryAddEnumerableis what makes registering both packages in the same host additive rather than a last-one-wins replace: both factories end up in theIEnumerable<IAiProviderFactory>AiProviderValidatorandAddMmcaChatClientenumerate. - Why it's built this way: singleton lifetime matches the factories' own statelessness (both hold no
mutable fields);
TryAddEnumerableover plainAddSingletonis what lets a host reference both adapter packages without one factory silently dropping the other. - Where it's used:
MMCA.ADC.Conference.Service/Program.cscallsAddAnthropicAiProvider(). No call site forAddOpenAiProvider()is visible inMMCA.ADC/Sourcetoday; it is exercised only fromMMCA.Common.AI.OpenAI's own tests.
⬅ Device Capability Abstraction Layer (Native Contracts, MAUI, Browser & Fallback Adapters) • Index • Common Cloud Provider Adapters (AWS, Azure, GCP) ➡