Architecture Decision Record
ADR-015: Architecture Invariants Enforced as Fitness Functions
Status
Accepted. Revised 2026-08-18 (two new rule families, namespace dependency cycles and trailing
CancellationToken declarations, plus a third enforcement layer: a compile-time public-API surface
gate with committed baselines. The "in two layers" framing in the Decision below is superseded; see the
Revision (2026-08-18) at the end). Revised again the same day for the Section B wave: two further rule
families join the library, a .proto wire-contract gate and an idempotency-intent gate, and the counts
move to 104 test methods across 36 bases, superseding the 102/34 figure the first revision
recorded. See the second section, Revision (2026-08-18): Section B rule families. Revised 2026-08-18
against the released framework v1.154.0: MMCA.Common's own build then executed 99 of the 104 methods
and the public-API baselines then held 5,150 declarations. Revised 2026-08-23: the method and base
counts moved again and are no longer restated in this record's live text, since MMCA.Common/FACTS.md
owns them (:51, :54) and is drift-gated in CI; the Decision's --minimum-expected-tests figure is
corrected to the floor CI actually applies. See Revision (2026-08-23) at the end. Revised 2026-09-01:
the public-API gate's file, declaration and coverage figures are corrected in place (sixteen baseline
pairs, one per published package except MMCA.Common.UI.Maui, with the package count left to
MMCA.Common/FACTS.md), and three citations are re-anchored. See Revision (2026-09-01) at the end.
Revised 2026-09-03: the one accepted namespace cycle changed shape (its middle node is Messaging, not
the dissolved Settings folder), the shared rule library and MMCA.Common's own subclasses moved into
feature folders, and the unshipped-baseline declaration figure is re-counted. See
Revision (2026-09-03) at the end.
Revised 2026-09-09: ADR-115 adds a rule family
(StronglyTypedIdsAreReadonlyRecordStructs, exposed as StronglyTypedIdTestsBase, subclassed by
MMCA.Common, MMCA.ADC and MMCA.Store but not MMCA.Helpdesk, and vacuously satisfied where it runs).
Revised 2026-09-11: the method, base and executed counts that entry
restated are removed from this record's live text, because
FACTS.md owns them (FACTS.md:51,
:54), is generated from source by FactsGenerator and is drift-gated in CI, so a figure transcribed
here goes stale between audits while the generated one cannot. What this record states instead is the
structure the figures describe: one shared rule library of abstract *TestsBase classes, subclassed
per repo. Six citations that moved are re-anchored. See Revision (2026-09-11) at the end.
Revised 2026-09-19: the public API gate covers two more projects than this record said, so the baseline
file count, the header-only list and the unshipped declaration figure are corrected in the live text
(eighteen baseline pairs then, twenty-one today, still one per published package except
MMCA.Common.UI.Maui). See Revision (2026-09-19) at the end.
Revised 2026-10-01 (RS0026 / RS0027 move from off to error, with each already-shipped overload pair
suppressed at its own declaration; see Revision below).
Revised 2026-10-06: the ADR-115 rule runs in three of the four repos rather than every repo, the
idempotency gate is exercised by MMCA.Common's own controllers, and the baseline and proto fixture
figures are re-counted. See Revision (2026-10-06) at the end.
Revised 2026-10-07: MMCA.Common runs a second architecture map, FrameworkModuleArchitectureMap, over
its own aggregates, the shipped baselines are re-counted against v1.233.0, and the anchors that moved
are refreshed.
Context
The codebase rests on invariants that are easy to state and easy to erode by accident: clean-
architecture layer flow (Domain depends on nothing above it), module isolation (no module reaches into
another's internals), transport staying at the edge (no MassTransit / gRPC / Protobuf in Domain /
Application / Shared, ADR-006/007/008), every integration event declaring a SchemaVersion
(ADR-010), every outbound client wiring resilience (ADR-009), and the MassTransit-v8 pin (ADR-016).
"Remember the rule" does not survive a growing change history. Several existing ADRs already rely on
"a fitness function enforces this" without an ADR that establishes the approach itself.
Decision
Enforce architectural invariants as automated checks that gate the build, in two layers (a third joined them on 2026-08-18: see the Revision at the end).
- Compile-time guard.
MMCA.Common.LayerEnforcement.targets(imported for everySource/project) inspectsProjectReferences in a pre-build step and fails the build before tests run if a layer references a forbidden upstream layer. This catches the most common mistake (a bad project reference) with the fastest possible feedback. - Runtime fitness functions. A shared
MMCA.Common.Testing.Architecturepackage holds the rule bodies once: anArchitectureRules.*library (layers, modules, transport, events, entities, handlers, naming, controllers, immutability, governance, purity, specifications, slices, and localization in two rules: resx translation-coverage and no-hardcoded-UI-literal text, among others) plus abstract*TestsBaseclasses parameterized by anIArchitectureMap. Each repo (Common / Store / ADC / Helpdesk) supplies a primaryIArchitectureMapimplementation (CommonArchitectureMap,StoreArchitectureMap,AdcArchitectureMap,HelpdeskArchitectureMap) declaring its layer and module assemblies, then inherits the test bases. MMCA.Common adds a second map,FrameworkModuleArchitectureMap, that registers its own Shared, Domain and Application assemblies as one module namedFrameworkso the module-scoped DDD rules run over the aggregates the framework ships (see the Revision (2026-10-07) at the end). The same rules run identically everywhere via NetArchTest over the compiled assemblies.
These tests run inside the normal dotnet test / CI tier, so a violated invariant fails CI like any
other test (the whole-solution run that carries them is additionally floored at
--minimum-expected-tests 2000, .github/workflows/ci.yml:163, so a discovery or filter regression
that silently drops the suite fails the job instead of passing green on a handful of tests). Centralizing the rules in a package, rather than copying them per repo, means a new
invariant is written once and inherited by every consumer.
Rationale
- Invariant over discipline. Turning "do not do X" into a red build is the only enforcement that scales. It is the same lever used by the layer rules, the resilience gate (ADR-009), the event-version gate (ADR-010), and the MassTransit pin (ADR-016).
- Write once, run everywhere. The
IArchitectureMapextension point keeps the four repos' rules in lockstep with zero duplication; a rule change lands for all consumers at once. - Two layers, two speeds. The MSBuild guard fails at compile time on the common case; the NetArchTest suite catches the subtler assembly-level violations a project-reference check cannot see.
Trade-offs
- The tests assert structure / registration, not runtime behavior. ADR-009's test proves a client wires resilience, not that its policy values are correct; parameter tuning stays a review concern.
- Some rules are necessarily reflection / convention based (naming, namespace shape) and can be brittle; keeping them in the shared library means a fix propagates everywhere in one change.
- A consumer must implement
IArchitectureMapand subclass the bases to get the gating (opt-in wiring), even though the framework ships the rules. Common-only checks that cannot generalize live inFrameworkSanityTests.
Revision (2026-08-18)
Two new rule families joined the shared library, and a third enforcement layer joined the two the
Decision above describes. The counts in MMCA.Common/FACTS.md move with them: 102 test methods
across 34 abstract *TestsBase classes, of which MMCA.Common's own build executes 87
(FACTS.md:44-48). Those are method counts derived lexically by FactsGenerator
(MMCA.Common/build/facts/FactsGenerator.cs:138-151, :155-170, :178), so a [Theory] counts once
regardless of how many data rows it runs: they are not test-case counts.
Both new families are required merge gates, not advisory. They live in
Tests/Architecture/MMCA.Common.Architecture.Tests, which is inside MMCA.Common.slnx (:56) and
therefore runs in the build-and-test job (.github/workflows/ci.yml:87, whole-solution test step at
:175-183), and the public API gate fails that same job's build step (ci.yml:131-133);
build-and-test is one of the eight required
gates on main (CONTRIBUTING.md:60-61). One caveat: a docs-only PR skips restore, build and test by
path filter (ci.yml:121,132,175) while still reporting green, so none of this fires on a
documentation change.
ArchitectureRules.Cycles: namespace dependency cycles
NamespacesHaveNoDependencyCycles(map, allowedCycleNamespaces)
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Layering/ArchitectureRules.Cycles.cs:45-47)
builds a namespace graph for every layer in the map (:54, so all seven for MMCA.Common) and
reports each strongly connected component in it. Nodes are the layer's root namespace plus one segment
beneath it (:137) and edges never cross assemblies (:104). Components are found by mutual
reachability over a boolean transitive closure rather than by Tarjan or Kosaraju (:292, :310), with
a breadth-first search picking the shortest path to display (:330). Edges are the type signature
surface: base types, implemented interfaces, field and property types, method return and parameter
types (declared members, public and non-public), and attribute types, with generic arguments and
array/by-ref/pointer element types recursively expanded (:25-27, implemented at :174-212). Types
outside the layer's root namespace are ignored.
The exemption hook is checked against the whole strongly connected component, not a single namespace
and not merely the displayed path: component.TrueForAll(allowed.Contains) (:61, rationale at
:58-60), so an allowance can never hide a new cycle that merely touches an accepted namespace, and a
fourth namespace joining an accepted tangle still fails. Consumers subclass NamespaceCycleTestsBase
(Bases/Layering/NamespaceCycleTestsBase.cs:15, test at :29) and override AllowedCycleNamespaces. Note that
the XML doc on both the rule (:41-43) and the base (NamespaceCycleTestsBase.cs:22-23) describes the
check in terms of the cycle's path; the code is the stricter whole-component test above, and the code
is what runs.
MMCA.Common has exactly one accepted tangle, inside MMCA.Common.Infrastructure:
root -> Messaging -> Persistence -> root
(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Layering/NamespaceCycleTests.cs:42-47),
and the subclass justifies it per edge rather than as a blanket allowance (:13-41): the
composition root binds the buses and their settings, the buses are the outbox transport
(InProcessEventBus and BrokerEventBus enqueue OutboxMessage rows and wake the OutboxProcessor
through IOutboxSignal, so splitting the two would put half of one delivery guarantee on each side of
a package boundary), and the EntityTypeConfiguration* shims carry the [UseDataSource] /
[UseDatabase] marker attributes that live in the root namespace precisely because consumers annotate
their own configurations with them. All three namespaces ship in one assembly and one package, so none
is independently extractable and the tangle costs nothing the layer rules were protecting. The middle
node changed with the 2026-09 feature-by-folder reorganization and the subclass records the change
itself (:38-40): the Settings folder dissolved into the features it configured, and the tenancy
validator that carried the old Settings -> Persistence edge now lives in Persistence/Tenancy.
The rule is a signature-level statement and says so. The testing package carries no IL or Roslyn
dependency, so this is pure reflection: a reference that exists only inside a method body (a local, a
constructor call, a static call) is invisible to it, and compiler-generated closure and iterator types
are skipped deliberately so the result stays signature-level rather than half-body (:30-36). A clean
report therefore means no structural cycle, never zero coupling. That is the same honest limitation
the Trade-offs above already record for the reflection-based rules, stated at the rule this time.
ArchitectureRules.CancellationTokens: trailing cancellation tokens
AsyncMethodsDeclareTrailingCancellationToken(map, exemptMethods)
(.../Rules/Cqrs/ArchitectureRules.CancellationTokens.cs:35-37) requires every method returning Task,
Task<T>, ValueTask or ValueTask<T>, declared (not inherited) as a public member of a
publicly-visible type in an Application or Infrastructure assembly, to take a CancellationToken
as its last parameter named exactly cancellationToken (:15-18, layer scope at :45-47, awaitable
check at :105-112, trailing check at :120-122, with distinct diagnostics for a misnamed versus a
mispositioned parameter at :129-131). Members are bound Public | Instance | Static | DeclaredOnly
(:73-74), so public static methods are in scope and inherited members are not. A method with no
parameters is not excused: the token would simply be its only parameter (:17-18).
The stated reason is mechanical pass-through rather than tidiness: a uniform trailing position and
name is what lets the ADR-014 decorator pipeline, the ADR-055 repositories and the generated clients
forward a linked token without special-casing each call, and an async method that cannot be cancelled
leaves work running against the database after its caller is gone (:9-13), which is exactly what the
new Timeout decorator's budget and a stopping host both produce.
Exemptions come in two kinds. Automatic ones cover signatures the repository does not own
(:20-27, implemented at :95, :99-102, :148-149): Dispose / DisposeAsync,
compiler-generated and special-name members (property accessors, operators, event accessors), members
of delegate types, an override of a base method declared outside the map's assemblies, and an implicit
implementation of an interface method declared outside them (IHostedService.StartAsync,
IHealthCheck, framework middleware). Declared ones go through
CancellationTokenConventionTestsBase.CancellationTokenExemptMethods in "TypeName.MethodName" form,
which the rule documents as being for cases where adding the parameter would break a shipped public
API, with the reason recorded beside the entry (:30-34, and the base's own wording at
Bases/Cqrs/CancellationTokenConventionTestsBase.cs:21-26).
The suite found exactly two real violations, both on NotificationHub
(.../Cqrs/CancellationTokenConventionTests.cs:23-27), and they are the interesting case because the
exemption is not a waiver. A SignalR hub method signature is the client-visible RPC contract, bound
by name and argument list by the dispatcher, and every shipped consumer's client already invokes
JoinChannelAsync / LeaveChannelAsync (the two exemption entries, :25-26) with one argument, so
adding a parameter would break the wire contract (:14-22). The work was made cancellable anyway: both methods pass Context.ConnectionAborted straight
into their group calls, so the token is there, just not through a parameter reflection can see. The
exemption records a genuine blind spot in the rule rather than an accepted defect.
A third enforcement layer: the public API surface gate
This is the structural change to the Decision above, which framed enforcement as two layers (an MSBuild
project-reference guard and a NetArchTest suite). The gate is neither: it is a compile-time analyzer
with a committed baseline, Microsoft.CodeAnalysis.PublicApiAnalyzers 5.6.0
(MMCA.Common/Directory.Packages.props:226), applied to every Source project through one
Directory.Build.props ItemGroup rather than per csproj (:86-93), with PublicAPI.Shipped.txt and
PublicAPI.Unshipped.txt added as AdditionalFiles (:91-92). Eighteen projects carry the pair, one
for every published package except the single exclusion below.
MMCA.Common.UI.Maui is deliberately excluded and the condition says why (:82-84): it lives
outside MMCA.Common.slnx and builds only on the windows build-maui job across four MAUI TFMs
(ADR-042), "so its baseline could neither be bootstrapped nor kept honest from the normal build".
The gate is exactly two rules: RS0016 fails the build on a public member absent from
PublicAPI.Shipped.txt and RS0017 on a declared member that disappeared
(Directory.Build.props:77-80, intent stated at :26 and .editorconfig:888-890). Neither is
explicitly set to error: both are left at the repository's global analyzer-error default
(dotnet_analyzer_diagnostic.severity = error, .editorconfig:312, plus TreatWarningsAsErrors,
Directory.Build.props:7), so they are errors by inheritance rather than by their own entry.
Widening or breaking a package's shipped surface therefore becomes a reviewable diff in a text file
instead of something a consumer discovers after the release.
The shipped baselines hold 5,034 declarations across the eighteen files (5,052 non-empty lines, each
file opening with a #nullable enable header; four of them, MMCA.Common.AI, MMCA.Common.Gateway,
MMCA.Common.Testing.Aspire and the MMCA.Common metapackage, hold the header alone). The
PublicAPI.Unshipped.txt files are no longer header-only
stubs: seventeen of the eighteen now carry 2,710 declarations of surface added since their shipped
baseline was last written (2,728 non-empty lines), the one exception being the MMCA.Common
metapackage. What is baselined is the surface as of this branch, which is whichever release
MMCA.Common/FACTS.md:14 currently names (v1.154.0 when this revision was first written; the figures
above were re-counted on 2026-09-19), so they cover the Section A additions this
revision described plus every wave that followed, not a frozen picture of one release. The
start version is no longer uncited: the gate shipped in v1.153.0, whose changelog entry names
Microsoft.CodeAnalysis.PublicApiAnalyzers on every in-slnx Source project with committed baselines as
one of three new build gates (MMCA.Common/CHANGELOG.md:2004-2010, the analyzer named on :2008,
under the v1.153.0 heading at :1949). The discipline therefore begins with
v1.153.0 rather than applying retroactively.
Three rules from the same analyzer are off, each with the reason recorded rather than silently
suppressed (.editorconfig:893-902): RS0026 / RS0027 (no multiple public overloads with optional
parameters) because the surface being baselined already ships those pairs, mostly the repository read
and query methods, so obeying the rule now would mean a breaking signature change on every consumer
("off rather than silently baselined as a lie"); and RS0041 (no oblivious reference types in public
members) because every hit is inside Razor generated code that is not nullable-annotated and is not
ours to annotate. RS0041 additionally sits in the global NoWarn (Directory.Build.props:22-27), and
the duplication is load-bearing rather than sloppy: a dotnet_diagnostic severity does not reach
generated code, so the .editorconfig entry alone would not suppress it. RS0051-RS0056, the
internal-API analog over InternalAPI.Shipped.txt, are off too (.editorconfig:903-910): only the
public, packaged surface is under contract.
This formalizes what the consumer-source-build canary only sampled. That CI job builds
MMCA.Helpdesk against the PR's framework source, so it catches a breaking public-API change only where
Helpdesk happens to use the member; the baselines catch it at the declaration, for the whole surface,
in the repository that owns it.
What this revision costs
- The Decision's "two layers" framing is now wrong as written, and the three layers fail at three
different moments with three different diagnostics: a
ProjectReferenceviolation at pre-build, a public-surface change at compile, a structural rule at test time. - A baseline is a file that must be maintained, and its failure mode is friction. Every deliberate
public API addition now needs a
PublicAPI.Unshipped.txtedit in the same PR, and an author who does not know the gate exists meets it as anerroron a build that was previously green. - Both new rules ship with a live exemption, so neither is a clean sweep: one accepted namespace cycle and two exempted hub methods. Recorded that way on purpose, since a rule with no exemption hook either gets deleted or gets satisfied by a worse design.
- The cycle rule cannot see method bodies and the token rule cannot see
Context.ConnectionAborted. Both are structural checks over signatures, which is the same limitation the Trade-offs above already accept for this whole suite, now with two more instances of it. - The token rule's return-type test is a closed list. Only
Task,Task<T>,ValueTaskandValueTask<T>qualify (ArchitectureRules.CancellationTokens.cs:105-112), so anIAsyncEnumerable<T>method or any custom awaitable is silently out of scope despite being exactly the kind of long-running work a token exists for. - The public API gate covers every published package except one, and only in MMCA.Common. The
package list and count live in
MMCA.Common/FACTS.mdand are deliberately not restated here.MMCA.Common.UI.Mauiis excluded for build-topology reasons even though it has its own required windows build gate, so "every shipped package's surface is gated" would be an overstatement. ADC, Store and Helpdesk publish nothing and get no baselines, which leaves the three enforcement layers unevenly distributed across the four repos.
Revision (2026-08-18): Section B rule families
A second entry on the same date, kept separate rather than folded into the one above because it lands with a different wave and changes a number that revision states. Two rule families join the shared library, and both are consumer-facing: the framework ships the rule and exercises it, but the thing being pinned lives in a consumer repository.
The counts in the preceding revision are superseded. MMCA.Common/FACTS.md:44 read, on the date of
this entry, 104 test methods across 36 abstract *TestsBase classes, of which MMCA.Common's own build
executed 99 (FACTS.md:47), where the entry above recorded 102 across 34 with 87 executed (both are
themselves superseded: see Revision (2026-08-23)). Those figures were
correct when written and are left in place, consistent with how this library treats a superseded count.
Cite FACTS.md rather than either number: it is generated by FactsGenerator and gated against drift
in CI, so it cannot quietly disagree with the code the way a transcribed figure can.
ArchitectureRules.Protos: the gRPC wire contract gets the freeze integration events already had
ADR-010 made every integration-event payload a versioned, contract-tested shape. The other cross-service wire format, ADR-007's protobuf contracts, had nothing equivalent: renumbering a field or flipping a streaming flag is a silent, binary-compatible-looking change that breaks every deployed peer, and only a code review stood between it and a release.
ArchitectureRules.Protos
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Contracts/ArchitectureRules.Protos.cs:37)
parses a
repository's .proto files and compares them against a frozen list. What is pinned is exactly the
wire: the package; every service rpc with its name, request and response types and both streaming
flags (:198-208, :299-312); every message field with its name, declared type, label and number
(:219-227); every enum value with its number (:211-217); and nested types under their qualified
name (:234-241). A oneof is transparent, its members pinned as ordinary fields (:182-185).
What is deliberately not pinned is syntax, import and every option, csharp_namespace
included (:27-31). The line is drawn at "would a deployed peer notice": a reordered import or a
changed file-level option produces the same bytes on the wire, and pinning them would turn a formatting
change into a red build. Consumers subclass ProtoContractTestsBase
(.../Bases/Contracts/ProtoContractTestsBase.cs:19), supplying SolutionFileName, ProtoFiles and
FrozenProtoContracts, with one [Fact] as the whole body (:32-34).
MMCA.Common ships no .proto of its own and does not subclass the base. The only proto files in
the repository are a matched fixture pair,
Tests/Architecture/MMCA.Common.Architecture.Tests/TestData/fitness-catalog.proto and its deliberately
drifted twin, driven through the rule by five [Fact]s in ProtoContractFitnessTests
(.../Contracts/ProtoContractFitnessTests.cs:14, the fixture pair at :20-22, the cases at :44, :52, :66,
:76 and :89). A clean fixture proves the
parser accepts a real contract and a drifted one proves the rule actually fails, a missing path fails
loudly rather than pinning an empty contract (:76-87), and one case asserts the unpinned syntax,
import and option lines really are ignored (:89-98), which is the only way
a rule whose subject lives in another repository can be tested by the repository that owns it.
The idempotency-intent gate
IdempotencyConventionTestsBase requires every [HttpPost] action to declare [Idempotent] or
[NonIdempotent(justification)], inherit-aware, over concrete controllers. It is the same
invariant-over-discipline move this record has made for resilience, event versioning and concurrency,
applied to a decision that was previously expressed by an attribute's absence. The rule, its scope
limits (POST only, simple-name matching) and the auth controllers' declarations are recorded where they
belong, in ADR-017's Revision (2026-08-18).
What this revision costs
- A frozen list is another baseline to maintain.
FrozenProtoContractsis the same shape of obligation asPublicAPI.Shipped.txtabove: a deliberate contract change means editing the list in the same PR, and an author who does not know the gate exists meets it as a failing test. - The unpinned options are not entirely harmless. Changing
csharp_namespacebreaks the generated client code in every consumer even though the wire is untouched, and this gate passes it. The rule protects the protocol, not the compile, and that distinction is easy to misread as "protos are gated". - Both new families are consumer-facing, so Common's own green build proves less than usual. The
proto rule is exercised against fixtures rather than a real contract, and the idempotency rule needs
a consumer's controllers to have anything to say. The count of methods the framework executes (99 of
- is the honest version of that gap.
- Six rule families now carry an exemption, allowance or scope limit each. Accepted cycles, exempted hub methods, unpinned proto options, POST-only intent: every family ships with a documented hole, which is what keeps them adoptable and also what makes "the suite is green" a weaker statement than it sounds.
Revision (2026-08-23): superseded counts, a re-anchored citation, and the real test floor
No rule family joined or left the library in this entry. It corrects three things the two 2026-08-18 revisions above state, and it is kept as its own entry rather than edited into them because those entries were correct when written.
The counts are superseded a second time, and this record stops restating them.
MMCA.Common/FACTS.md is generated by FactsGenerator and gated against drift in CI, so it is the
only place either figure can be read without a transcription risk. As of 2026-08-23 (framework
v1.160.0, FACTS.md:4, :14) it reads 110 test methods across 38 abstract *TestsBase classes
(FACTS.md:44), of which MMCA.Common's own build executes 129 (FACTS.md:47), where the first
revision recorded 102 across 34 with 87 executed and the Section B entry recorded 104 across 36 with 99
executed. Read FACTS.md, not this paragraph, for the current values.
The "N of M executed" framing in the Section B cost list no longer parses, and that is the honest
correction, not a rounding. The executed figure now exceeds the shipped method count because it
counts the methods of the bases MMCA.Common subclasses plus its Common-only direct tests
(FrameworkSanityTests, SpecificationFitnessTests), which FACTS.md:47 says in its own parenthetical.
So "the framework executes 99 of 104" cannot be restated as "129 of 110": the two numbers are no longer
a subset and its container. The gap that bullet was pointing at, consumer-facing families whose subject
lives in another repository, is unchanged and still real (the proto rule runs against fixtures, the
idempotency rule needs a consumer's controllers); it simply cannot be measured by subtracting one
FACTS.md line from the other any more.
The Decision's test-count floor was wrong and is corrected in place. The architecture tests have no
floor of their own: they run inside the whole-solution step of build-and-test, which is floored at
--minimum-expected-tests 2000 against a suite of roughly 2,254 (.github/workflows/ci.yml:175-183;
the floor value is live at :183, while the suite size is the step comment's own estimate and is not
verifiable from a static read).
The value 1 belongs to a different job entirely, the ui-e2e matrix, which runs the out-of-slnx
MMCA.Common.UI.E2E.Tests project by path (ci.yml:346). The correction makes the guard weaker than
the Decision claimed in one specific way worth naming: a floor of 2,000 over a 2,254-test suite would
not notice the architecture suite disappearing on its own, only a collapse of the whole run. A
per-project floor is only available to a job that runs one project by path, which is what the ui-e2e
job does and the solution-wide run cannot.
One citation was re-anchored, not rewritten. The v1.153.0 changelog bullet naming
Microsoft.CodeAnalysis.PublicApiAnalyzers is verbatim unchanged but has moved to
MMCA.Common/CHANGELOG.md:2004-2010 (its release heading at :1949) as newer releases were prepended
above it. Line-anchored citations into an append-at-top file are a known cost of citing this precisely;
the alternative, citing nothing, is worse. The anchors above are the current ones and are re-anchored
in place on each audit, since a pointer that has drifted is broken rather than merely superseded.
Revision (2026-09-01): the public API gate's real coverage, and re-anchored citations
No rule family joined or left the library in this entry and no decision changed. It corrects the public-API figures the first 2026-08-18 revision recorded, which had gone stale as the framework grew, and it re-anchors three citations that moved.
The gate is wider than this record said, and the coverage ratio is retired rather than updated.
Sixteen Source projects carry the PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt pair, one for
every published package except MMCA.Common.UI.Maui, where the first revision counted fourteen
projects and described the gate as covering "14 of the 15 packages". The package count is no longer
restated in this record at all: MMCA.Common/FACTS.md owns the package list and is generated and
drift-gated in CI, and a transcribed "N of M" here is precisely the figure that went stale. What this
record states instead is the shape of the coverage, which the MSBuild condition itself guarantees:
every Source project except the one the condition names by hand
(MMCA.Common/Directory.Build.props:86).
The declaration figures were re-counted and the unshipped files are no longer empty. The shipped
baselines hold 5,034 declarations across 5,050 non-empty lines, where the first revision recorded 5,150
across 5,164 for fourteen files, and the PublicAPI.Unshipped.txt files, described in that revision as
holding the #nullable enable header and nothing else, now carry 1,457 declarations between fourteen
of the sixteen. That is the gate working, not a backlog: surface added since a baseline was last
written has to be declared somewhere for the build to stay green, and the unshipped file is where it
lands. Both figures are a reading of one branch on one date and will move with the next wave, which is
why the sentence around them points at FACTS.md:14 for the release they describe.
Three citations were re-anchored, not rewritten. The Microsoft.CodeAnalysis.PublicApiAnalyzers
PackageVersion moved to MMCA.Common/Directory.Packages.props:199 (a Meziantou.Analyzer entry was
inserted above it in the Analyzers block, and it has since moved on to :226); the v1.153.0 changelog
bullet moved to
MMCA.Common/CHANGELOG.md:2004-2010 under its heading at :1949, the append-at-top cost the
2026-08-23 entry above predicted; and the proto fixture pair's citation now names the test class and
its five cases (ProtoContractFitnessTests.cs:14, :20-22, :44, :52, :66, :76, :89) rather
than a line inside that file's XML doc comment. The two FACTS.md pointers in the Status block moved
from :44 / :47 to :46 / :49 as that generated file grew (they are :48 / :51 today); the dated readings inside the
revisions above keep their own anchors, since re-pointing a historical number at a line that now says
something else would be worse than leaving it dated.
Revision (2026-09-03): the accepted cycle's middle node, the feature-folder move, re-anchored citations
No rule family joined or left the library in this entry and no decision changed. One accepted allowance changed shape, the files that hold the rules and the tests moved, and the citations that moved with them are re-anchored.
The one accepted namespace cycle now runs through Messaging, not Settings. The tangle inside
MMCA.Common.Infrastructure is root -> Messaging -> Persistence -> root
(.../Layering/NamespaceCycleTests.cs:42-47), and the middle edge's justification changed with it: the
buses are the outbox transport (InProcessEventBus and BrokerEventBus enqueue OutboxMessage
rows and wake the OutboxProcessor through IOutboxSignal), where the old Settings -> Persistence
edge rested on TenancySettingsValidator taking an optional IDataSourceResolver. The Settings
folder dissolved into the features it configured and the tenancy validator now lives in
Persistence/Tenancy, which the subclass records in its own XML doc (:38-40). The shape of the
allowance is unchanged: still exactly one accepted component, still checked against the whole strongly
connected component rather than a path, so a fourth namespace joining it still fails. The Section A
passage is rewritten in place rather than kept dated, because it describes a live allowance rather than
a reading taken on a date.
The rules and the tests moved into feature folders, and no rule body moved with them. The shared
library now groups its rules and bases by concern: Rules/Layering/ArchitectureRules.Cycles.cs,
Rules/Cqrs/ArchitectureRules.CancellationTokens.cs, Rules/Contracts/ArchitectureRules.Protos.cs,
Bases/Layering/NamespaceCycleTestsBase.cs, Bases/Cqrs/CancellationTokenConventionTestsBase.cs and
Bases/Contracts/ProtoContractTestsBase.cs. MMCA.Common's own subclasses follow the same split
(Layering/NamespaceCycleTests.cs, Cqrs/CancellationTokenConventionTests.cs,
Contracts/ProtoContractFitnessTests.cs, with FrameworkSanityTests under Governance/ and
SpecificationFitnessTests under Domain/). Every line anchor inside those files still resolves, so
only the directory segment of each citation changed: this is a re-anchor, not a rewrite.
The unshipped declaration figure was re-counted; the shipped one had not moved. The
PublicAPI.Unshipped.txt files hold 1,486 declarations across 1,502 non-empty lines, where the
2026-09-01 entry read 1,457, and still exactly fourteen of the sixteen carry content (the two
header-only exceptions are still MMCA.Common.UI.Web and the MMCA.Common metapackage). The shipped
baselines are unchanged at 5,034 across 5,050 non-empty lines. That the unshipped side grows between
audits while the shipped side holds is the gate behaving as designed: surface added since a baseline
was last written lands in the unshipped file and stays there until a release rolls it over.
The other re-anchored citations. The architecture test project's entry in MMCA.Common.slnx moved
from :46 to :52, and is at :56 today. The v1.153.0 changelog bullet moved a second time, to
MMCA.Common/CHANGELOG.md:2004-2010 (the analyzer named on :2008) under its heading at :1949; that
is the append-at-top cost the 2026-08-23 entry predicted, now on its second occurrence, and it is
re-anchored in the two revision entries that cite it as well as in the live text. The three
.editorconfig blocks for the public API gate are :888-890 (the intent), :893-902 (the three rules
turned off with their reasons) and :903-910 (RS0051-RS0056). The token rule's "a method with no
parameters is not excused" sentence spans :17-18, not :18-19. The declared-exemption doc is the
rule's exemptMethods <param> at :30-34, where :44-48 had pointed into the method body. And the
two exempted hub methods are entered as JoinChannelAsync and LeaveChannelAsync, which is the form
the rule matches, so the Section A passage now uses those names rather than the shorter ones the
surrounding XML doc prose uses.
One known-broken pointer is left broken on purpose. The dated FACTS.md readings inside the
revisions above keep their original anchors under the convention the 2026-09-01 entry set, so
FACTS.md:44 and :47 as cited there no longer point at the lines those numbers were read from.
Re-pointing a historical reading at a line that now says something else would be worse than
leaving it dated, but a reader following one of those anchors should expect to land on unrelated text.
The live pointers in the Status block are re-anchored on each audit and read FACTS.md:48 and :51
today (see Revision (2026-09-11)).
Revision (2026-09-11): the restated counts come out, six citations move
No rule family joined or left the library in this entry and no decision changed. It removes the one kind of number this record keeps failing to keep current and re-anchors the pointers that drifted since 2026-09-03.
The 2026-09-09 entry restated three FACTS.md figures, and restating them was the mistake. That
entry recorded the method, base and executed counts inline while MMCA.Common/FACTS.md was already the
generated, CI-gated owner of all three, and they went stale with the next release exactly as the
2026-08-23 entry warned. The Status block now carries a link to
FACTS.md and the structural statement the
figures were standing in for: the shared library ships abstract *TestsBase classes, each repo
subclasses them, and MMCA.Common's own build additionally runs its Common-only direct tests, which is
why the executed figure is not a subset of the shipped method count (FACTS.md:48, :51). The
ADR-115 rule family the 2026-09-09 entry added is unchanged and still vacuously satisfied everywhere.
The dated figures inside the earlier revisions stay as they are, under the convention this record has
used since 2026-09-01.
Six anchors moved and are re-anchored in place. The whole-solution test step that carries the
architecture suite is .github/workflows/ci.yml:175-183 (the --minimum-expected-tests 2000 floor on
:183), where the Decision cited :144 and the 2026-08-23 entry cited :141-144; the build step the
public API gate fails is ci.yml:131-133, where :106-108 now falls inside the setup-dotnet cache
configuration; the docs-only path-filter conditions on restore, build and test are ci.yml:121, :132
and :175; the ui-e2e project-by-path run is ci.yml:346; the PublicApiAnalyzers PackageVersion
is Directory.Packages.props:226; the architecture test project's MMCA.Common.slnx entry is :56;
and the v1.153.0 changelog bullet is CHANGELOG.md:2004-2010 (analyzer on :2008) under its heading at
:1949, its third move, which is the append-at-top cost the 2026-08-23 entry named and now measures
about 520 lines per audit cycle.
One figure stays unverified rather than corrected. The "roughly 2,254" suite size beside the 2,000 floor is the CI step's own comment, not something a static read can confirm, and it is left as the estimate it always was. The floor itself is live.
Revision (2026-09-19): the gate covers eighteen projects, and the unshipped side nearly doubled
No rule family joined or left the library in this entry and no decision changed. It corrects the public-API gate's file and declaration figures, which had gone stale in the direction the gate's own design predicts.
The pair count is eighteen, not sixteen. PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt pairs
now exist under seventeen Source/<tier>/<package>/ directories plus Source/MMCA.Common/ for the
metapackage. That is still every project under Source/ except MMCA.Common.UI.Maui, which the
Directory.Build.props ItemGroup condition continues to exclude by name for the reason quoted in the
live text, so the rule the record states did not change: the two additions are new packages picking up
the gate automatically, not a widening of its scope. The published-package total stays with
FACTS.md (FACTS.md:19) rather than being
transcribed here.
Four shipped baselines are header-only now, not two. MMCA.Common.AI and
MMCA.Common.Testing.Aspire join MMCA.Common.Gateway and the MMCA.Common metapackage in holding
the #nullable enable header and nothing else. For the two new ones that is the expected state of a
package whose whole surface postdates the last baseline roll: everything they expose sits in their
unshipped file (104 and 94 declarations) waiting for a release to move it across.
The unshipped side carries 2,710 declarations across seventeen of the eighteen files, where the
2026-09-03 entry read 1,486 across fourteen of sixteen, and the shipped side is unchanged at 5,034
declarations (the non-empty line total moves from 5,050 to 5,052 only because two more header lines
exist). MMCA.Common.UI.Web is no longer an exception: its unshipped file holds eight declarations, so
the only header-only unshipped file left is the metapackage's. The pattern the 2026-09-03 entry
described is now visible at scale: the shipped figure holds flat between releases while the unshipped
figure absorbs every wave, which is what a baseline that rolls over at release time is supposed to look
like.
No citation was re-anchored in this entry, and the dated figures inside the earlier revisions stay as they are, under the convention this record has used since 2026-09-01.
Revision (2026-10-01): RS0026 / RS0027 become errors, the baselines roll over, citations move
No rule family joined or left the library in this entry. One public API gate setting changed, the
baseline figures moved with the v1.216.0 release (MMCA.Common/FACTS.md:14), and the citations that
drifted are listed here rather than rewritten inside the earlier entries.
RS0026 and RS0027 are now errors, not off. The first 2026-08-18 entry records them as turned off
because the already-shipped overload pairs could not change ("off rather than silently baselined as a
lie"). The reasoning about the shipped pairs still holds, but the mechanism changed: both rules are set
to error (MMCA.Common/.editorconfig:899-900, rationale at :894-898, intent restated at
:888-891), so a NEW public overload pair with optional parameters fails the build, and each of the
19 shipped members that form those pairs carries a targeted [SuppressMessage] with its justification on the
member itself (19 occurrences across 10 Source files). The exception is now visible at each
declaration instead of being a repository-wide off switch. RS0041 is the only rule from that block
still off (.editorconfig:901-904, plus the global NoWarn at Directory.Build.props:22-27), and
RS0051-RS0056 stay off at .editorconfig:905-912. The "three rules from the same analyzer are off"
sentence in the first 2026-08-18 entry is therefore superseded: one of the three is off today.
The pair count is twenty-one, and the release rolled every unshipped declaration across.
PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt pairs now exist in 21 Source projects, the three
new ones being MMCA.Common.AI.Anthropic, MMCA.Common.AI.OpenAI and MMCA.Common.AI.Testing; the
exclusion rule is unchanged (Directory.Build.props:86), so the gate still covers every published
package except MMCA.Common.UI.Maui, with the package count left to FACTS.md:19. The shipped
baselines hold 7,881 declarations across 7,902 non-empty lines, and only the MMCA.Common
metapackage's shipped file is header-only (MMCA.Common.AI, MMCA.Common.Gateway and
MMCA.Common.Testing.Aspire now carry content). All 21 PublicAPI.Unshipped.txt files hold the
#nullable enable header and nothing else. This is the roll-over the 2026-09-03 and 2026-09-19 entries
described, and it supersedes the 5,034 shipped / 2,710 unshipped figures and the eighteen-pair count in
the first 2026-08-18 entry.
The hub exemptions are keyed by C# name; the wire names are shorter. The exemption entries are
NotificationHub.JoinChannelAsync and NotificationHub.LeaveChannelAsync
(.../Cqrs/CancellationTokenConventionTests.cs:25-26), which is the form the rule matches. The names a
client invokes are JoinChannel and LeaveChannel, set through [HubMethodName]
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/NotificationHub.cs:36, :39,
:118, :135) and used by the framework's own client
(Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:37-38), which is
what the subclass's XML doc says (.../Cqrs/CancellationTokenConventionTests.cs:14-22).
The proto fixtures are three, driven by six cases. A third fixture,
TestData/fitness-catalog-commented.proto, joins the matched pair, and a sixth [Fact],
BuildProtoContract_SurvivesBlockCommentsInEveryPosition, drives it
(.../Contracts/ProtoContractFitnessTests.cs:100-110). The existing anchors (:14, :20-22, :44,
:52, :66, :76-87, :89-98) still resolve.
Current anchors for citations that moved. The whole-solution test step is
.github/workflows/ci.yml:152-161 (the --minimum-expected-tests 2000 floor on :161, the "~2,254"
comment on :158); the build step the public API gate fails is ci.yml:134-136; the docs-only guards
on restore, build and test are ci.yml:124, :135 and :153 (the FACTS gate just above them is
deliberately not guarded, :119-121); the ui-e2e project-by-path run is ci.yml:324. The
architecture test project's MMCA.Common.slnx entry is :59. The PublicApiAnalyzers 5.6.0
PackageVersion is Directory.Packages.props:232. The v1.153.0 changelog bullet is
CHANGELOG.md:3033-3039 (analyzer on :3037) under its heading at :2978. In
ArchitectureRules.Protos.cs the entry point is :38-41, the not-pinned list :28-31, rpc rendering
:199-209 with the streaming captures in the RpcLine regex (:321-325) and StreamPrefix
(:247-248), fields :220-228, enum values :212-217, the qualified nested name :235-242 and the
transparent oneof :183-185. The live FACTS.md pointers in the Status block are :51 (methods and
bases) and :54 (executed); the Decision's floor citation is re-anchored in place. The dated figures
and anchors inside the earlier revisions stay as they are, under the convention this record has used
since 2026-09-01.
Revision (2026-10-06): the ADR-115 rule's reach, Common's own idempotency gate, re-counted baselines
No rule family joined or left the library in this entry and no decision changed. It corrects four statements and lists the anchors that moved, leaving the earlier entries as written.
- The ADR-115 rule does not run in every repo.
StronglyTypedIdTestsBaseis subclassed in MMCA.Common (Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/StronglyTypedIdConventionTests.cs:12), MMCA.ADC and MMCA.Store (Tests/Architecture/*/Domain/StronglyTypedIdTests.cs), and MMCA.Helpdesk has no subclass, so the Status clause now says three of the four. Where it runs it still passes vacuously (StronglyTypedIdConventionTests.cs:7). - The idempotency rule is not consumer-only. MMCA.Common subclasses the base over its own map
(
Tests/Architecture/MMCA.Common.Architecture.Tests/Cqrs/IdempotencyConventionTests.cs:10-12) and ships concrete controllers with real POST actions, for exampleSource/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:43-44([HttpPost]plus[Idempotent]). The Section B cost bullet and the 2026-08-23 parenthetical that say the rule "needs a consumer's controllers" are superseded: of the two Section B families, only the proto rule is exercised against fixtures alone in Common's own build. - The baselines were re-counted against v1.232.0 (
MMCA.Common/FACTS.md:14). The 21PublicAPI.Shipped.txtfiles hold 8,254 declarations across 8,275 non-empty lines (the 2026-10-01 entry read 7,881 across 7,902); only theMMCA.Commonmetapackage's shipped file is header-only; all 21PublicAPI.Unshipped.txtfiles still hold the header alone. The first 2026-08-18 entry's baseline passage (eighteen pairs, 5,034 shipped, 2,710 unshipped, "the surface as of this branch") and its "three rules ... are off" paragraph are dated readings, superseded by the 2026-10-01 entry and this one, even though their wording reads as current. - The proto fixtures are four, driven by seven
[Fact]s.TestData/fitness-shapes.protojoins the other three and is driven byParser_PinsMembersBehindAnRpcBodyAndInsideOneLineBodies(.../Contracts/ProtoContractFitnessTests.cs:114-119); the other six cases sit at:44,:52,:66,:76,:89and:100, with the class at:14and the pinned and drifted fixtures at:20and:22. - Anchors re-verified against current source.
PublicApiAnalyzers5.6.0PackageVersion:Directory.Packages.props:228. InDirectory.Build.props: the gate comment:80-83, the Maui rationale:85-87, the ItemGroup:89-96(its exclusion condition on:89), theAdditionalFiles:94-95, the RS0041 rationale:22-26with theNoWarnline at:30,TreatWarningsAsErrors:7. The v1.153.0 changelog bullet:CHANGELOG.md:3374-3380(analyzer on:3378) under its heading at:3319.[HubMethodName]sits atNotificationHub.cs:127and:144(constants:36,:39;Context.ConnectionAbortedpassed at:136and:150); the client uses its own constants atNotificationHubService.cs:45-46. InArchitectureRules.Protos.cs: entry point:38-41, not-pinned list:28-31, rpc rendering:293-304,RpcLine:418-422,StreamPrefix:341-342, field rendering:314-322(FieldLine:424-428), enum values:306-312(EnumValueLine:430-434), qualified name:329-336, transparentoneof:271-273. The.editorconfiganchors in the 2026-10-01 entry, theci.yml:161floor, and the Status block'sFACTS.md:51/:54pointers still resolve.
Revision (2026-10-07): Common's second architecture map, re-counted baselines, moved anchors
Re-verified against current source. No rule family joined or left the library and no decision changed:
the two enforcement layers plus the public API gate, the --minimum-expected-tests 2000 floor and the
RS rule severities are as the earlier entries describe. One structural statement in the Decision was
incomplete, the baselines moved with the v1.233.0 release (MMCA.Common/FACTS.md:14), and several
anchors drifted, including some the 2026-10-06 entry said still resolve.
- MMCA.Common supplies two architecture maps, not one. Besides
CommonArchitectureMap(Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:15), it shipsFrameworkModuleArchitectureMap(Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/EntityModel/FrameworkModuleArchitectureMap.cs:16), which registers the framework's Shared, Domain and Application assemblies as one module namedFramework(:19,:25-27) and keeps Infrastructure a framework layer (:28). Its purpose, per its XML doc (:5-15), is that the module-scoped DDD rules (sealed entities, no public setters, no public aggregate constructors, immutable DTOs, commands, queries and domain events) are vacuous underCommonArchitectureMap, where the same assemblies are framework layers, and actually run over the aggregates the framework ships under this map.ImmutabilityTests(.../Domain/EntityModel/ImmutabilityTests.cs:11-13) andEntityConventionTests(.../Domain/EntityModel/EntityConventionTests.cs:12-14) subclass their bases over it. The Decision's "singleIArchitectureMapimplementation" is corrected in place to a primary map per repo plus this second one in MMCA.Common. - The shipped baselines hold 8,260 declarations across 8,281 non-empty lines in the 21
PublicAPI.Shipped.txtfiles, each carrying exactly one#nullable enableheader (the 2026-10-06 entry read 8,254 across 8,275 against v1.232.0). The 21-pair count stands, theMMCA.Commonmetapackage's shipped file (Source/MMCA.Common/PublicAPI.Shipped.txt:1) is still the only header-only shipped file, and all 21PublicAPI.Unshipped.txtfiles still hold the header alone. - Anchors re-verified against current source: the whole-solution test step is
.github/workflows/ci.yml:154-163, its docs-only guard:155, the "~2,254" comment:160and the--minimum-expected-tests 2000floor:163(the Decision's citation is re-anchored in place); the restore guard:124, the build step:134-136with its guard:135, and the unguarded FACTS gate:118-121still resolve. Theui-e2erun is now a directdotnet test --project Tests/Presentation/MMCA.Common.UI.E2E.Tests/...with--minimum-expected-tests 1atci.yml:292(step name:287). InMMCA.Common/.editorconfig: the PublicApiAnalyzers overrides header:888-894, the RS0026 / RS0027 rationale (19 shipped members):896-900with both set toerrorat:901-902, RS0041noneat:906(its comment:903-905), and RS0051-RS0056noneat:907-914. The v1.153.0 changelog heading isCHANGELOG.md:3344and its "Three new build gates" bullet:3399-3405(PublicApiAnalyzers on:3403). The framework client's hub method constants areSource/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:49-50;[HubMethodName]atNotificationHub.cs:127and:144andContext.ConnectionAbortedat:136and:150still resolve. The Status block'sFACTS.md:51/:54pointers still resolve.
Related
ADR-009 (resilience gate), ADR-010 (event-version gate), ADR-016 (MassTransit pin gate, and the
lockstep release cadence the public API baseline is pinned to), ADR-006/007/008 (the transport and
module-isolation rules the suite enforces), ADR-053 (the dual-registry publishing this surface gate
protects: the packages whose consumers a breaking change reaches),
ADR-014 and
ADR-055 (the decorator pipeline and repository contract
whose mechanical token pass-through the trailing-CancellationToken rule exists to keep possible),
ADR-058 (the runtime conformance suites, the behavioral counterpart this record deliberately scopes
itself against).