Architecture Decision Record
ADR-120: Governed Language-Model Boundary (MMCA.Common.AI)
Status
Accepted (2026-09-11). Extends ADR-111, which keeps every scoring-specific rule it states: this record moves the parts that are not about session scoring into the framework, as one optional package every future model call composes over.
Revised 2026-09-11: ADC's session scoring runs on the package. The Context, Trade-offs and
Consequences describe the adopted state (a constructor-injected IChatClient and PromptContract,
framework-owned bounds and one framework meter) instead of a pending migration.
Revised 2026-09-15 (MMCA.Common v1.203.0): the pipeline gains an optional guardrail layer and its
telemetry is exported by the framework host. IChatGuardrail (MMCA.Common.AI.Chat) is an
extension point only: a host registers one or more implementations and GuardrailChatClient runs
them, in registration order, on the request and on the buffered response, throwing
ChatGuardrailException on the first GuardrailVerdict.Block(reason). Content policy (delimiting,
redaction, injection handling) still lives in the feature per the trade-off below; the framework now
offers the place to plug it in, not the policy. AiUsageMeter also publishes a
mmca.ai.call.duration histogram (seconds, tagged outcome = success | error | canceled
beside the usage tags), and MMCA.Common.Aspire's ConfigureOpenTelemetry registers the
MMCA.Common.AI trace source and meter, so a consumer no longer has to add either by hand for the
spans and counters to leave the process.
Revised 2026-09-19: the section 16 re-scoring this record left open for ADC has happened. The Consequences state the outcome of the thirty-second scorecard cycle (2026-09-16, M4/I9, Implementation 8 to 9) instead of a pending cycle.
Revised 2026-09-21 (MMCA.Common v1.207.0): the provider leaves the governed package. Ai:Provider
is a string matched against the IAiProviderFactory instances a host registered from an adapter
package (MMCA.Common.AI.Anthropic, MMCA.Common.AI.OpenAI) and validated at startup; the
provider tag is read from the inner client's own metadata; BoundedChatClient pins Ai:Model and
filters tools through IChatToolPolicy; a redaction extension point, one shipped PII policy,
per-update inspection on the streamed path and two registration-time refusals (Ai:RequireGuardrail
on by default, Ai:AllowTools with no policy) turn the guardrail layer from an extension point into
a default; PromptTaggingChatClient joins the pipeline inside the OpenTelemetry layer; and the
evaluation harness ships as MMCA.Common.AI.Testing, which the framework runs on its own reference
contract. The consumer-visible half of that (the provider tag casing, the two new defaults, the
removed AiProvider enum) is mapped in MMCA.Common/UPGRADING.md under [1.207.0].
Revised 2026-09-21 (MMCA.Common v1.208.0): the framework ships a second content
policy, ContentPolicyGuardrail, bound from Ai:ContentPolicy: it redacts, blocks or ignores eight
built-in prompt-injection markers in user-role content according to InjectionMode (Redact by
default) plus any request pattern the host adds, and it refuses an answer matching a configured
blocked-response pattern, both on the buffered response and on each streamed update.
AddContentPolicyGuardrail(configuration) validates every configured pattern at startup and
registers one singleton under both IChatGuardrail and IChatRequestRedactor, so registering it
satisfies Ai:RequireGuardrail on its own (released in v1.208.0).
Revised 2026-10-01: the current-state sections match the code (ADC's full adoption, the v1.213.0 hardening, refreshed citations).
Revised 2026-10-06: the Anthropic SDK pin reads 12.53.0, and every live-section anchor is re-verified against current source.
Revised 2026-10-07: PiiRedactionGuardrail also redacts tool results and tool-call arguments that
arrive as JsonElement values (since v1.232.0), and its anchors and the CHANGELOG release anchors
are refreshed.
Revised 2026-10-21: PiiRedactionGuardrail passes signed reasoning and items with nothing to
redact through unchanged, and a rebuilt item keeps its metadata (still not RawRepresentation)
(since v1.247.0), and the package pins and anchors are refreshed.
Revised 2026-10-26: the Anthropic SDK pin is 13.3.0, ADC's section 16 score reads M4/I9 as the
scorecard does, ContentPolicyGuardrail keeps item metadata like the PII policy and both guardrails keep
CreatedAt (since v1.261.0), the type-level fitness rule's namespace root is stated, and anchors are refreshed.
Revised 2026-10-28: anchors refreshed after MMCA.Common v1.265.1.
Context
Rubric section 16, AI-Native Application Architecture, asks one question of a product feature that
calls a language model: is that dependency governed like any other external system, meaning
isolated, versioned, evaluated, observed and bounded in what it may do
(Website/docs-src/governance/ArchitectureEvaluationCriteria.md:469, intent at :471, the seven
criteria at :476-482). The category is N/A until a feature calls a model and is scored the moment
one does (:473).
Exactly one feature does. ADC's organizer-facing session scoring takes a constructor-injected
IChatClient and PromptContract
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringService.cs:47-50),
and ADR-111 records the governance it grew: a port in
Application, a pinned model, a dated prompt version, a golden-replay evaluation gate that hashes the
rendered prompt per version, delimited and redacted input, a schema-constrained response, and a
budgeted spend alert. What the module owns is what only session scoring knows: the prompt text, the
redaction rules and the response schema. What it does not own is the call. The model id (:64) is
folded into the prompt contract (:93-94), the 256-token output ceiling is one named constant
(:71) set on the request (:116) and clamped again by Ai:MaxOutputTokens at the framework
boundary, ModelId (:74) and PromptVersion (:85) read through the injected contract rather
than standing as independent literals, nothing in the module constructs an HttpClient for Anthropic
(MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:36-50), and Infrastructure is the only
layer that names the package (MMCA.ADC.Conference.Infrastructure.csproj:19, reason at :15-18).
Token counters come off the framework meter MMCA.Common.AI rather than a meter named for the
feature (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:160, meter registration
at :179-180). That division is the whole point of this record: ADR-111 names the alternative in
its own trade-offs, where one feature, one provider and one model leave no general model-calling
abstraction, so a second AI feature inherits the conventions by imitation rather than by
construction. ADC scored M2/I5 on section 16 in the 2026-09-04 cycle, before the package existed,
and scores M4/I9 on it now (Website/docs-src/governance/adc-ArchitectureScorecard.md:80); Store
and Helpdesk have no feature that calls a model, so the category is N/A for them.
Imitation is the wrong transport for this particular set of rules. Every one of them is a property of calling a model, not a property of scoring a session: an output-token ceiling, a wall-clock budget, a tool-use gate, a prompt whose identity is hashed, a token counter tagged by model and prompt version. Written per feature, each is a fresh chance to forget one, and the forgetting is invisible until a bill or an incident. Written once, they are what a second feature gets for free.
The external landscape had already converged on the shape. Microsoft's October 2024 eShopSupport
sample ("eShop infused with AI") demonstrated the Microsoft.Extensions.AI abstractions precisely so
that a provider swap does not touch business logic, and the October 2025 Microsoft Agent Framework
orchestration post made OpenTelemetry instrumentation the default expectation for a production agent
rather than an afterthought. Both point at the same conclusion this workspace reached from its own
rubric: the interesting artifact is not the provider client, it is the governed pipeline around it.
Decision
The framework owns the language-model boundary as one optional package, MMCA.Common.AI, that
names no vendor. A provider arrives as an adapter package contributing one factory, the governance
half is a pipeline of delegating clients, prompts are versioned contracts the framework hashes,
tools are off until a host turns them on and says which, a host that inspects nothing does not
start, and the evaluation harness ships beside the package.
One governed package, with no MMCA project reference and no vendor SDK.
MMCA.Common.AItakes aFrameworkReferenceonMicrosoft.AspNetCore.App(MMCA.Common/Source/Core/MMCA.Common.AI/MMCA.Common.AI.csproj:11) for the hosting environment, the distributed cache, the configuration binder and the options validation pipeline, plus exactly one runtime package:Microsoft.Extensions.AI(:30), pinned at 10.11.0 (MMCA.Common/Directory.Packages.props:105). The csproj states the reason in place (MMCA.Common.AI.csproj:15-29): everything the package governs is expressed inMicrosoft.Extensions.AIprimitives, so it needs nothing from Domain, Application or Infrastructure, must stay installable by a host that has taken none of them, and must not pin a vendor for a host that configured a different one.A provider is an adapter package contributing one factory, selected by name and validated at startup.
IAiProviderFactory(MMCA.Common.AI/Providers/IAiProviderFactory.cs:20) is the whole provider boundary: aNamea configuration file selects by (:26) and aCreatethat hands back an ungovernedIChatClient(:34). Two adapters ship, each one factory and one registration call:MMCA.Common.AI.Anthropic(AnthropicAiProviderFactory.cs:14, name at:17, the SDK's ownAsIChatClientat:48,AddAnthropicAiProvider()atDependencyInjection.cs:26) over the official Anthropic SDK (MMCA.Common.AI.Anthropic.csproj:13, pinned at 13.3.0,Directory.Packages.props:112), andMMCA.Common.AI.OpenAI(OpenAiProviderFactory.cs:18, name at:21,GetChatClient(model).AsIChatClient()at:49-51,AddOpenAiProvider()atDependencyInjection.cs:26) overMicrosoft.Extensions.AI.OpenAIand the official OpenAI SDK (MMCA.Common.AI.OpenAI.csproj:13-14, pinned at 10.11.0 and 2.14.0,Directory.Packages.props:117-118).Ai:Provideris a plain string (AiSettings.cs:55) matched case-insensitively against the registered factory names (Providers/AiProviderValidator.cs:41-42), andCreateProviderChatClient(DependencyInjection.cs:239) asks the match to build the client (:244-247). A name with no factory behind it is refused at boot, not on a user's first request:AiProviderValidator(AiProviderValidator.cs:16) is anIValidateOptions<AiSettings>the configuration overload registers (DependencyInjection.cs:94) andValidateOnStartruns (:126), with the registered names, or the adapter packages when there are none, in the message (AiProviderValidator.cs:48-63).Ai:Endpoint(AiSettings.cs:83) routes either adapter through an AI gateway, a regional endpoint or a compatible server (AnthropicAiProviderFactory.cs:43-46,OpenAiProviderFactory.cs:44-47).The dependency direction is build-gated in both directions.
EnforceAiLayerBoundary(MMCA.Common/Source/Build/MMCA.Common.LayerEnforcement.targets:86) runs forMMCA.Common.AIand for everyMMCA.Common.AI.*project (:88), so the adapters and the testing package inherit the rule, and fails the build if any of them takes aProjectReferenceon Domain, Application, Infrastructure, API, Grpc or UI (:90-99). The other direction is a runtime fitness function:AiDependencyIsolationTestsBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Layering/AiDependencyIsolationTestsBase.cs:19) asserts that no layer outside a*.Infrastructureassembly or the boundary itself namesAnthropic,Microsoft.Extensions.AI,OpenAIorAzure.AI(Rules/Layering/ArchitectureRules.Ai.cs:38-44, rule at:50), and that nothing outside Infrastructure references the governed package at all (:75, check at:83). The boundary assembly is classified by name with theMMCA.Common.AI.prefix (:19, classification at:99-107), which is what admits the adapter packages andMMCA.Common.AI.Testingto it; the type-level rule matches the undotted namespace rootMMCA.Common.AI(:25), because NetArchTest matches whole namespace segments and an entry ending in a dot would match nothing (:13-14). A prompt or a bound must not become a type Domain or Application depends on.Governance is a pipeline of delegating clients, ordered outermost first.
AddMmcaChatClient(IConfiguration)(DependencyInjection.cs:88) buildsBoundedChatClient-> optionalGuardrailChatClient(present only when the host registers at least oneIChatGuardrailor oneIChatRequestRedactor) ->UsageRecordingChatClient-> optionalDistributedCache->OpenTelemetry->PromptTagging->Logging-> the provider client (registration at:149-184, order documented at:22-41). Bounds are outermost so a call the configuration forbids is refused before it is inspected, logged, cached or counted, and a guardrail block is refused before it is counted.PromptTaggingChatClient(Chat/PromptTaggingChatClient.cs:21) is composed just inside the OpenTelemetry layer (DependencyInjection.cs:183) so the activity it decorates is that layer's owngen_aispan: it copiesmmca.prompt.name,mmca.prompt.versionandmmca.prompt.hashonto it (:63) and only ever onto an activity from the framework's own source (:56-57), so a host with no listener gets no stray tags. Telemetry rides the same name as the meter (DependencyInjection.cs:181), and prompt and completion text reach traces only on a host positively identified as Development (:177, gate at:262-266), which fails closed.What a call may do is configuration, and the outermost client enforces it.
BoundedChatClient(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/BoundedChatClient.cs:65) clampsMaxOutputTokensdown toAi:MaxOutputTokenswhile leaving a smaller caller number alone (:197-199), sends every request out namingAi:Modeland refuses one that names a different model (:215-230, the refusal at:217-224), runs every call under a token linked to the caller's own and cancelled afterAi:Timeout(:280-285, the streaming path covered for the whole stream at:138-145), and refuses a request whose ESTIMATED input exceedsAi:PerCallInputTokenBudgetbefore it reaches the provider (:287-302). The estimate asks the pipeline for anIAiTokenEstimator(Chat/IAiTokenEstimator.cs:14) and otherwise counts four characters to the token (BoundedChatClient.cs:178). Caller options are cloned, never mutated (:195). Pinning the model here is what makesPromptContract.Modelmean the same thing on an adapter that honors a per-request override and one that binds the model at construction (OpenAiProviderFactory.cs:27-32).Tools are off by default, and "on" means a policy said yes. While
Ai:AllowToolsis false, tools and the tool mode are stripped from the request (BoundedChatClient.cs:201-205). While it is true,FilterTools(:241-278) offers a tool only when EVERY registeredIChatToolPolicy(Guardrails/IChatToolPolicy.cs:23) returnsAllowed(BoundedChatClient.cs:261), so policies compose by intersection and a new concern can only ever remove tools;Deniedis the enum's zero value (Guardrails/ToolAuthorization.cs:13), so a policy that forgets to answer refuses. A tool that marks itself consequential (Guardrails/ChatToolPolicy.cs:30) clears one more bar: the request has to name it in the confirmed list (:43, read at:78-109, checked atBoundedChatClient.cs:269), per request and never per session. With no policy registered every tool is stripped (:251-254), so the layer fails closed, and that combination is refused at registration instead (DependencyInjection.cs:219-226).A host that inspects nothing does not start.
GuardrailChatClient(Chat/GuardrailChatClient.cs:31) runs every registeredIChatRequestRedactor(Guardrails/IChatRequestRedactor.cs:25) over the materialized messages first (:126-135), and the redacted list is what the guardrails inspect and what the provider is sent (:68,:95). Then everyIChatGuardrail(Chat/IChatGuardrail.cs:21) inspects the request (:28), the buffered response (:38) and, on the streamed path, each update BEFORE it is yielded (InspectStreamedUpdateAsync, a default interface member that allows so an implementation written earlier keeps compiling,:57-60; the per-update loop atGuardrailChatClient.cs:99-116), with the first block throwingChatGuardrailException.Ai:RequireGuardraildefaults to true (AiSettings.cs:117), and an enabled host that registered neither a guardrail nor a redactor is refused at registration with the one-line fix in the message (DependencyInjection.cs:208-217, both refusals inRefuseAnUngovernedHostat:202). The framework ships two content policies that answer that refusal on their own. The first,PiiRedactionGuardrail(Guardrails/PiiRedactionGuardrail.cs:40), removes email addresses and North American phone numbers from every outgoing message, including reasoning text, tool results and the arguments of tool calls (:71-81,:123-157,RedactTextat:192), whether those arrive as strings or asJsonElementvalues, where only string values are rewritten and property names, numbers and booleans are copied as they are (:159-164,RedactJsonat:200,WriteRedactedat:212-244). Two kinds of item pass through unchanged: a reasoning block that carriesProtectedData(a signed thinking block) is never rebuilt, because an edited signed block is rejected when the conversation is sent back (:118-120,:125), and an item with nothing to redact passes through by reference (:109,:156). A rebuilt item keepsAdditionalProperties,Annotationsand a tool item'sInformationalOnlyandException(:136,:148-149), copied by the internal helper both shipped policies share (Guardrails/ContentMetadata.cs:22), but notRawRepresentation, the provider's original object, which still holds the unredacted text (:112-115,ContentMetadata.cs:13-16). It leaves the application's ownInstructionsalone (:35-37), and is registered as one singleton under both contracts byAddPiiRedactionGuardrail()(Guardrails/GuardrailServiceCollectionExtensions.cs:33,:37-41). Contact details are one of the two judgements the framework is willing to make for every application: they are never evidence for anything a model is asked, so the judgement does not change between applications.The second is prompt injection.
ContentPolicyGuardrail(Guardrails/ContentPolicyGuardrail.cs:64) is one type under both contracts: as anIChatRequestRedactorit rewrites user-role content, as anIChatGuardrailit inspects the request, the buffered response and each streamed update. It bindsAi:ContentPolicy(Guardrails/ContentPolicySettings.cs:26).InjectionMode(ContentPolicySettings.cs:35) picks one of three answers to a marker in user-role content:Redact(the default,Guardrails/ContentPolicyInjectionMode.cs:23) replaces the match withRedactionPlaceholder([redacted-instruction]unless configured,ContentPolicySettings.cs:70) and lets the call proceed,Block(ContentPolicyInjectionMode.cs:29) leaves the text alone and refuses the call naming the marker,Off(:35) does neither. The eight built-in markers are code rather than configuration (ContentPolicyGuardrail.cs:77-87):ignore-previous-instructions,disregard-system-prompt,role-reassignment,new-instructions,reveal-system-prompt,act-as-unrestricted,developer-modeanddo-anything-now.AdditionalRequestPatterns(ContentPolicySettings.cs:45) merges an application's own regular expressions into that list, andBlockedResponsePatterns(:57) is the other half: inspected on the buffered response (ContentPolicyGuardrail.cs:167) and on each streamed update (:184), refusing by pattern index and never echoing the text that matched (:270,:283). Only user-role messages are ever rewritten, never the application's system prompt or a prior assistant turn (ContentPolicyGuardrail.cs:111-138, role filter at:134). Within a rewritten message an item with no marker passes through by reference and a rebuilt text item keeps itsAdditionalPropertiesandAnnotations(:337-341), and both policies keepChatMessage.CreatedAton the message they rebuild (:356,PiiRedactionGuardrail.cs:97), since v1.261.0 (MMCA.Common/CHANGELOG.md:65, L244). A configured pattern that does not compile fails the host at startup rather than on a user's request (ContentPolicySettings.cs:83,ValidateOnStartatGuardrails/GuardrailServiceCollectionExtensions.cs:67-70), andAddContentPolicyGuardrail(IConfiguration)(GuardrailServiceCollectionExtensions.cs:62) registers one singleton under both interfaces (:72-76), so registering it satisfiesAi:RequireGuardrailexactly as the PII policy does.PiiRedactionGuardrailships from MMCA.Common v1.207.0 andContentPolicyGuardrailfrom v1.208.0 (MMCA.Common/CHANGELOG.md:1375,:1329).The configuration surface is one section, validated, and the switch is the registration.
AiSettings(MMCA.Common/Source/Core/MMCA.Common.AI/AiSettings.cs:22) bindsAi(:25) and carriesEnabled(:46),Provider(:55),Model(:65),ApiKey(:76),Endpoint(:83),MaxOutputTokens(:90, default 1024 at:28),Timeout(:96, default 30 seconds at:31),AllowTools(:103),RequireGuardrail(:117),EnableCache(:123) andPerCallInputTokenBudget(:131).Provider,ModelandApiKeyare required only onceEnabledis true (:140-190), so the section ships in every appsettings file. WhenAi:Enabledis false nothing is registered (DependencyInjection.cs:129-132): a consumer gates onGetService<IChatClient>()being present, not on a flag it has to read, so a feature with no key in a given environment is off by construction.A prompt is a versioned contract, and the framework computes its identity.
PromptContract(MMCA.Common/Source/Core/MMCA.Common.AI/PromptContract.cs:23) is a record of name, version, model and system prompt whoseHash(:46) is the lowercase hex SHA-256 of those four joined, each with its line endings normalized to LF first, so the same prompt hashes identically on Windows and Linux andcore.autocrlfcannot trip a CI gate. The hash is computed on each read rather than cached, because a record's copy constructor would carry a cached hash through awithexpression and describe the prompt the copy came from (:41-46).ToChatOptions(:53) andApply(:62) stampmmca.prompt.name,mmca.prompt.versionandmmca.prompt.hashonto the request (:26,:29,:32), which is how the identity reaches the meter and, throughPromptTaggingChatClient, the trace.The evaluation harness is framework code, and the framework runs it on itself.
MMCA.Common.AI.TestingshipsReplayChatClient(MMCA.Common/Source/Hosting/MMCA.Common.AI.Testing/ReplayChatClient.cs:17), an offlineIChatClientthat answers from recordings and captures what went out (:50-56);RecordedResponses(RecordedResponses.cs:20), which reads and writes aChatResponsethroughAIJsonUtilities.DefaultOptions(:26-27), the abstraction's own JSON shape and never a vendor wire format, so a provider swap cannot invalidate a corpus (:9-19);GoldenReplayTestsBase(GoldenReplayTestsBase.cs:24), which replays every case through the subclass's real code path with a fresh client per case (:88), collects failures rather than stopping at the first (:64-70) and fails an EMPTY corpus, because an evaluation that evaluates nothing passes forever (:57-62); andPromptContractPinTestsBase(PromptContractPinTestsBase.cs:25), whose two facts fail a contract with no recorded hash (:39) and a recorded hash that no longer matches (:60), against a pin file the subclass names (:36). The package takes the governed package and no vendor SDK (MMCA.Common.AI.Testing.csproj:35, reason at:25-33). The framework then subclasses both bases over a reference contract nobody deploys (MMCA.Common/Tests/Core/MMCA.Common.AI.Tests/Evaluation/ReferencePrompts.cs:30), a three-case corpus (ReferenceGoldenReplayTests.cs:17, cases at:20) and a pin file (ReferencePromptContractTests.cs:11, path at:17, the recorded hash atEvaluation/Golden/prompt-versions.json:2), so the gate and the prompt-change protocol run on every MMCA.Common pull request rather than only in the repos that consume them.Spend is metered once, framework-wide.
AiUsageMeter(MMCA.Common/Source/Core/MMCA.Common.AI/Observability/AiUsageMeter.cs:20) publishesmmca.ai.input_tokens(:29),mmca.ai.output_tokens(:32) and themmca.ai.call.durationhistogram (:45, outcomes at:48-57) on the meterMMCA.Common.AI(:26), each taggedmodel,prompt_name,prompt_versionandprovider(:158-168).UsageRecordingChatClient(Chat/UsageRecordingChatClient.cs:38) records the provider's own reported numbers rather than an estimate, on the buffered path (:109-114) and on the streaming path from theUsageContentitem the provider delivers (RecordUsageInat:185-195). Theprovidertag is read from the inner client itself:ResolveProviderName(:68) takesChatClientMetadata.ProviderNamewhen the adapter reports one (:72-76), so a foreign client supplied through the factory overload is metered as what it is, and falls back to the configured name, lower-cased to match the convention the adapters use (:78-80). A usage the provider did not report records nothing: an absent number must not read as a zero on a spend dashboard.
Rationale
- These rules belong to the call, not to the feature. An output ceiling, a timeout, a tool gate and a token counter are true of every model call anybody will ever write here. Shipping them as a package makes the second feature cheaper than the first, which is the opposite of what imitation produces.
- A package that names a vendor is a package that has chosen for its consumers. The governed
half needs nothing from an SDK except an
IChatClient, so holding one would pin a vendor, a wire format and a transitive dependency on every host, including the ones that configured a different provider. One factory contract and one adapter package per provider make a swap a package reference, a registration call and a configuration value, with nothing else in the host changing (IAiProviderFactory.cs:8-13). - A default is a suggestion and a bound is not. The provider client is handed the model and the
ceiling as its defaults (
AnthropicAiProviderFactory.cs:24-27), andBoundedChatClientstill pins and clamps per call. A governance layer that can be talked out of its limits by a caller argument is documentation. - Off by absence beats off by flag. Registering nothing when
Ai:Enabledis false means a feature cannot half-run against a missing key: the dependency is either resolvable or the code path does not exist. A boolean everyone has to remember to check is the version of this that fails. - A refusal at registration beats a finding at review. "We shipped a model call and nothing
inspects it" and "we turned tools on and nothing decides which" are both deployment failures now,
with the fix in the message (
DependencyInjection.cs:198-201). A bound nobody reaches is not a bound, and the tenth user is the wrong person to discover it. - Hashing the prompt is what makes a model dependency evaluable at all. Recorded golden answers
are valid for one exact combination of name, version, model and text. Deriving the identity from
all four means an edit without a version bump still moves the hash, so the evaluation cannot be
skipped by forgetting, which is exactly the property ADR-111's
PromptContractTestsproved worth having. - A corpus recorded in the abstraction's shape outlives the provider that produced it. Storing
the vendor's own envelope would tie every recorded answer to the adapter that made it, so a
provider swap would mean re-recording the corpus and losing the regression history it exists to
hold (
RecordedResponses.cs:9-19). The code under test only ever sees aChatResponse, so the recording is one too. - Least privilege needs a middle position. "No tools" and "every tool the caller attached" are
the two positions a boolean has; the rubric asks for explicit, authorized, confirmable tools
(
ArchitectureEvaluationCriteria.md:480). An intersection of policies plus a per-request confirmation for consequential tools is that middle position, and reading is recoverable where writing is not (ChatToolPolicy.cs:17-23). - One meter name across every app is what makes a dashboard portable. A per-feature meter means a
per-feature query, and the spend question is asked across services, not within one. Reading the
providertag from the client rather than from configuration keeps that dashboard honest when the client did not come from the configured adapter.
Trade-offs
- Off by absence puts a null on the consumer. Because nothing is registered when
Ai:Enabledis false, a feature resolves the client withGetServiceand holds a nullable dependency: ADC's registration does exactly that and its scoring service takesIChatClient?(MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:36-50,SessionScoringService.cs:48). The disabled path is a branch the consumer writes, not a flag the framework reads for it. - A provider is now two references and a name, not one. A host has to add the adapter package
beside the governed one and call its registration method; the governed package cannot fall back to
a provider it was never handed. The compensation is that the failure is a boot failure naming the
registered providers (
AiProviderValidator.cs:48-63) rather than a missing type at compile time or a surprise at the first call. - Spend is queried under framework names, and the
providerdimension changed value. ADC's token counters report onMMCA.Common.AIunder the framework's counter names, so the App Insights spend alert and any saved query key on those rather than on a per-service meter (MMCA.ADC.Conference.Service/Program.cs:179-180). Reading the tag from the client's metadata also moves the value fromAnthropictoanthropic, which a saved query has to follow in the same release (MMCA.Common/UPGRADING.md:1145-1147). - Two defaults can now refuse a host that used to start.
Ai:RequireGuardrailtrue andAi:AllowToolswith noIChatToolPolicyare both startup failures, and pinning the model turns aPromptContractthat disagrees withAi:Modelinto a refused call (MMCA.Common/UPGRADING.md:1149-1160). That is the intended direction, and it is a breaking one. - The input budget is an estimate, and says so. It reads message text plus instructions only, so
images, tool schemas, provider-side additions and non-Latin scripts are under-counted
(
BoundedChatClient.cs:52-58). It is a runaway guardrail with headroom, never a billing figure. The authoritative numbers arrive after the call. - A cache hit still records usage.
UsageRecordingChatClientsits inside the optional response cache, so a hit counts what the call would have cost rather than what was billed (DependencyInjection.cs:36-39). The provider span is absent on a hit, so the two are distinguishable, but a spend graph read without that context over-reports. - The framework ships two content policies, and both are narrow. Contact-detail redaction
(
PiiRedactionGuardrail.cs:40) and the injection and blocked-response policy (ContentPolicyGuardrail.cs:64) are shipped because neither judgement varies by application: a phone number is never evidence for anything a model is asked, and an imperative aimed at the model is never the input's own argument. The built-in marker list stays deliberately small (eight phrases,ContentPolicyGuardrail.cs:77-87) so ordinary prose about instructions or an initial prompt survives it (ContentPolicyGuardrailTests.cs:37); a list broad enough to catch every phrasing would redact the evidence it was protecting. Everything that depends on what the input IS stays where ADR-111 put it, in the feature that knows: delimiting untrusted input, escaping it, constraining the response schema, and any domain-specific check (IChatGuardrail.cs:9-14). A second feature still makes those decisions for itself, and an application with a stricter idea of its own content addsAi:ContentPolicy:AdditionalRequestPatterns(ContentPolicySettings.cs:45) or registers a guardrail of its own. - The streamed path inspects fragments, not the answer. Per-update inspection stops a stream at
the offending update, but the caller has already seen everything yielded before it, and a rule that
needs the whole text has to accumulate it inside the implementation
(
GuardrailChatClient.cs:23-30). Buffering a streamed answer in the framework would defeat the reason a caller chose streaming. - The framework ships the evaluation harness, not the evaluation. The replay client, the golden base, the pin base and the protocol are framework code; the corpus, the assertions and any live-judge tier remain the feature's, because only the feature knows what a good answer is. The framework's own reference contract proves the bases work, not that any product prompt is good.
- Retrieval stays out of scope. Nothing here addresses vector or hybrid search, embedding
freshness or retrieved-content injection; the rubric's retrieval criterion
(
ArchitectureEvaluationCriteria.md:481) is answered by the feature that builds one, if one ever is. - An optional package nobody installs governs nothing. Store and Helpdesk adopt none of this until a feature of theirs calls a model, which is the correct answer and also means the rules are proven by exactly one consumer for now.
Consequences
- ADC's
SessionScoringServiceruns onIChatClient(SessionScoringService.cs:47-50). The portIAiScoringServicestays,PromptVersionstays and keeps being persisted with every score, and the two-tier evaluation gate stays. The module holds no hand-written provider request, no free-standing model literal and no per-service meter; its output ceiling is one named constant (:71) thatAi:MaxOutputTokensclamps again. - Upgrading across this release is a mapped change, not a silent one.
MMCA.Common/UPGRADING.md:1115-1164carries the old-to-new table (the removedAiProviderenum, thestring? configuredProviderconstructor, the factory), the four mechanical steps for a host on the Anthropic provider, theprovidertag casing change and the three new default bounds. - ADC runs on the adapter package, the shipped guardrails and the harness. The Conference module
references
MMCA.Common.AIalone (MMCA.ADC.Conference.Infrastructure.csproj:19); the host references the adapter (MMCA.ADC.Conference.Service.csproj:34) and callsAddAnthropicAiProvider()(MMCA.ADC.Conference.Service/Program.cs:147),AddConferenceAiGuardrails(configuration)(:158) andAddMmcaChatClient(:160).AddConferenceAiGuardrails(MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:85) registersAddPiiRedactionGuardrail()(:95),AddContentPolicyGuardrail(configuration)(:96) and the module's ownSessionScoreResponseGuardrail(:99). The golden suite runs on the shipped bases (GoldenReplayTests.cs:35,PromptContractTests.cs:180). - Store and Helpdesk adopt nothing. Section 16 remains N/A for both until a product feature of theirs calls a model.
- Section 16 scores M4/I9 for ADC. Token usage, model id and prompt version reach telemetry
through one shared source, the per-call ceiling is clamped by configuration at the framework
boundary, and
GuardrailChatClientis composed becauseAddConferenceAiGuardrailsregisters three guardrails (Website/docs-src/governance/adc-ArchitectureScorecard.md:80). The framework side of that is the shippedPiiRedactionGuardrail,ContentPolicyGuardrailand theAi:RequireGuardraildefault; the ADC side is the registration above.
Revision (2026-10-01)
No decision or rationale changed; the current-state sections now match the code. ADC adopted the
package fully: the host calls AddAnthropicAiProvider()
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:140) and
AddConferenceAiGuardrails (:151), which registers the PII and content policies plus a module
response guardrail (MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:95-99), and section
16 reads M4/I10 (Website/docs-src/governance/adc-ArchitectureScorecard.md:80). Both content
policies are released, in v1.207.0 and v1.208.0 (MMCA.Common/CHANGELOG.md:402, :381). Three
v1.213.0 changes harden the boundary without changing it: PiiRedactionGuardrail also redacts
reasoning text, string tool results and string tool-call arguments (PiiRedactionGuardrail.cs:105-123,
CHANGELOG.md:165), a consequential-tool marker in an unrecognised shape reads as consequential
(ChatToolPolicy.cs:49, CHANGELOG.md:164), and Ai:Timeout is capped at one hour
(AiSettings.cs:38, validated at :184-189). The scorer is now SessionScoringService, and its
256-token ceiling is a module constant (SessionScoringService.cs:70, set on the request at :115)
rather than a field of the prompt contract (PromptContract.cs:23). Refreshed citations: the ADC
scorer, registration and host lines, AiSettings, ChatToolPolicy,
GuardrailServiceCollectionExtensions, ContentPolicyGuardrail (markers at :77-87),
ReplayChatClient, the [1.207.0] block of MMCA.Common/UPGRADING.md (:200-248) and the
Anthropic SDK pin (12.52.0).
Revision (2026-10-06)
- The official Anthropic SDK pin is 12.53.0 (
MMCA.Common/Directory.Packages.props:111), not 12.52.0; decision item 2 now also states the OpenAI adapter's pins (Microsoft.Extensions.AI.OpenAI10.10.1 andOpenAI2.14.0,:116-117). No decision, rationale or trade-off changed. - The PII and content-policy release entries now point at the specific CHANGELOG lines
(
MMCA.Common/CHANGELOG.md:773,:727); the v1.213.0 hardening recorded in the 2026-10-01 revision sits atCHANGELOG.md:505,:506and:516, and the PII content switch atPiiRedactionGuardrail.cs:109-128. - Anchors re-verified against current source: the
Directory.Packages.propspins,BoundedChatClient,IChatGuardrail,PiiRedactionGuardrail,MMCA.Common.AI.Testing.csproj, the[1.207.0]block ofMMCA.Common/UPGRADING.md(:471-520), ADC'sSessionScoringServiceand the Conference host'sProgram.cs.
Revision (2026-10-07)
Re-verified against current source. No decision, rationale or trade-off changed in direction; the PII policy's scope grew and the release anchors moved when the v1.233.0 and v1.232.1 CHANGELOG blocks landed above them.
- Decision item 7 now states that
PiiRedactionGuardrailredacts tool results and tool-call arguments that arrive asJsonElementvalues as well as strings, rewriting only string values (MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/PiiRedactionGuardrail.cs:112,:129-134,RedactJsonat:144), released in v1.232.0 (MMCA.Common/CHANGELOG.md:46, M195). - Anchors re-verified against current source:
PiiRedactionGuardrail(class at:40, also in the Trade-offs, patterns at:71-81, content switch at:108-127,RedactTextat:136-137, theInstructionsremark at:35-37), and the release entries forPiiRedactionGuardrailandContentPolicyGuardrail(MMCA.Common/CHANGELOG.md:798,:752). The v1.213.0 hardening the 2026-10-01 revision records now sits atCHANGELOG.md:530,:531and:541; the earlier revisions keep their anchors as written.
Revision (2026-10-21)
Re-verified against current source. No decision, rationale or trade-off changed in direction; the PII policy gained two pass-through cases and metadata preservation in v1.247.0, three package pins moved, and the release and UPGRADING anchors moved as later blocks landed above them.
- Decision item 7 now states that
PiiRedactionGuardrailpasses a reasoning block carryingProtectedDatathrough unredacted and an item with nothing to redact through by reference, and that a rebuilt item keepsAdditionalProperties,Annotations,InformationalOnlyandExceptionbut notRawRepresentation(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/PiiRedactionGuardrail.cs:124,:108-119,:155,:162-168), released in v1.247.0 (MMCA.Common/CHANGELOG.md:113, M239). A rebuilt item carried noRawRepresentationbefore v1.247.0 either; that release added the pass-through cases and the metadata copy. - Decision items 1 and 2 state the current pins:
Microsoft.Extensions.AI10.11.0, the Anthropic SDK 12.60.0 andMicrosoft.Extensions.AI.OpenAI10.11.0, withOpenAIunchanged at 2.14.0 (MMCA.Common/Directory.Packages.props:104,:111,:116,:117). - Anchors re-verified against current source:
PiiRedactionGuardrail(content switch at:122-156,RedactArgumentat:170-174, the JSON checks at:177-199,RedactTextat:203,RedactJsonat:211,WriteRedactedat:223-255),ContentPolicySettings(RedactionPlaceholderat:70,Validateat:83),AiUsageMeter(outcomes at:48-57,AttributionTagsat:158-168), the release entries forPiiRedactionGuardrailandContentPolicyGuardrail(MMCA.Common/CHANGELOG.md:1117,:1071), and the[1.207.0]block ofMMCA.Common/UPGRADING.md(:887-936, casing note at:917-919, defaults at:921-932). The earlier revisions keep their anchors as written.
Revision (2026-10-26)
Re-verified against current source. No decision, rationale or trade-off changed in direction; the Anthropic SDK took a major-version bump, the content policy gained the PII policy's item fidelity in v1.261.0, the live sections' section 16 score is corrected, and many anchors moved.
- Decision item 2 states the Anthropic SDK pin as 13.3.0
(
MMCA.Common/Directory.Packages.props:112); the adapter's construction is unchanged (MMCA.Common/Source/Core/MMCA.Common.AI.Anthropic/AnthropicAiProviderFactory.cs:48). - The Context and Consequences state ADC's section 16 score as M4/I9, which is what the scorecard
row carries (
Website/docs-src/governance/adc-ArchitectureScorecard.md:80) and what the 2026-09-19 Status sentence already said; they read M4/I10. - Decision item 7 states that
ContentPolicyGuardrailpasses a user content item with no marker through by reference, keepsAdditionalPropertiesandAnnotationson a rebuilt text item, and that both shipped policies keepChatMessage.CreatedAt(MMCA.Common/Source/Core/MMCA.Common.AI/Guardrails/ContentPolicyGuardrail.cs:337-341,:356,PiiRedactionGuardrail.cs:97), released in v1.261.0 (MMCA.Common/CHANGELOG.md:22, L244). The metadata copy now lives in one internal helper both policies call (Guardrails/ContentMetadata.cs:22,RawRepresentationstill not copied,:13-16). - Decision item 3 states that the type-level rule matches the undotted namespace root
MMCA.Common.AIwhile assembly classification keeps the dotted prefix (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Layering/ArchitectureRules.Ai.cs:19,:25, reason at:10-18). - Anchors re-verified against current source:
Directory.Packages.props(:105,:112,:117-118),ArchitectureRules.Ai.cs(SDK list at:38-44, rules at:50and:75, classification at:99-107),ChatToolPolicy(confirmed-tools key at:43, read at:78-109),PiiRedactionGuardrail(content switch at:123-157,RedactArgumentat:159-164,RedactTextat:192,RedactJsonat:200,WriteRedactedat:212-244),ContentPolicyGuardrail(InspectResponseAsyncat:167,InspectStreamedUpdateAsyncat:184,InspectAnswerTextat:270, user-role rewrite at:111-138),UsageRecordingChatClient(RecordUsageInat:185-195), ADC's Conference host (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:147,:158,:160,:179-180), the release entries forPiiRedactionGuardrailandContentPolicyGuardrail(MMCA.Common/CHANGELOG.md:1332,:1286), and the[1.207.0]block ofMMCA.Common/UPGRADING.md(:1115-1164, casing note at:1145-1147, defaults at:1149-1160). The earlier revisions keep their anchors as written.
Revision (2026-10-28)
Re-verified against current source. No decision, rationale or trade-off changed; the release dating of both guardrails and of the v1.261.0 metadata fix is unchanged, and only their CHANGELOG anchors moved as the v1.264.0, v1.265.0 and v1.265.1 blocks landed above them.
- Anchors re-verified against current source: in Decision item 7 the v1.261.0 entry that keeps
ChatMessage.CreatedAton a rebuilt message (L244) is atMMCA.Common/CHANGELOG.md:65(the[1.261.0]block starts at:50), thePiiRedactionGuardrailrelease entry at:1375(the[1.207.0]block starts at:1345) and theContentPolicyGuardrailrelease entry at:1329(the[1.208.0]block starts at:1324). The earlier revisions keep their anchors as written.
Related
ADR-111 (the record this one extends: every scoring-specific
rule, the prompt-change protocol, the golden and live evaluation tiers, the input and output
guardrails and the budgeted ceiling alert stay there and are not superseded),
ADR-041 (the telemetry posture the meter and the
ActivitySource join, and the reason both share one name),
ADR-110 (the rubric revision that created section 16),
ADR-015 (the shared rule library
AiDependencyIsolationTestsBase ships in),
ADR-101 (why an optional package stays out of the metapackage),
ADR-070 (the bind-validate-on-start contract AiSettings
follows),
ADR-061 (the Key Vault path Ai:ApiKey binds from in
production),
ADR-016 (the lockstep release the package and its
adapters ship in and the consumer bump that carries ADC onto them).