↑↓ to navigate Enter to open "…" all these words ANDOR to combine

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).

  1. Compile-time guard. MMCA.Common.LayerEnforcement.targets (imported for every Source/ project) inspects ProjectReferences 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.
  2. Runtime fitness functions. A shared MMCA.Common.Testing.Architecture package holds the rule bodies once: an ArchitectureRules.* 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 *TestsBase classes parameterized by an IArchitectureMap. Each repo (Common / Store / ADC / Helpdesk) supplies a primary IArchitectureMap implementation (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 named Framework so 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 IArchitectureMap extension 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 IArchitectureMap and subclass the bases to get the gating (opt-in wiring), even though the framework ships the rules. Common-only checks that cannot generalize live in FrameworkSanityTests.

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 ProjectReference violation 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.txt edit in the same PR, and an author who does not know the gate exists meets it as an error on 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>, ValueTask and ValueTask<T> qualify (ArchitectureRules.CancellationTokens.cs:105-112), so an IAsyncEnumerable<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.md and are deliberately not restated here. MMCA.Common.UI.Maui is 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. FrozenProtoContracts is the same shape of obligation as PublicAPI.Shipped.txt above: 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_namespace breaks 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
    1. 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. StronglyTypedIdTestsBase is 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 example Source/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 21 PublicAPI.Shipped.txt files hold 8,254 declarations across 8,275 non-empty lines (the 2026-10-01 entry read 7,881 across 7,902); only the MMCA.Common metapackage's shipped file is header-only; all 21 PublicAPI.Unshipped.txt files 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.proto joins the other three and is driven by Parser_PinsMembersBehindAnRpcBodyAndInsideOneLineBodies (.../Contracts/ProtoContractFitnessTests.cs:114-119); the other six cases sit at :44, :52, :66, :76, :89 and :100, with the class at :14 and the pinned and drifted fixtures at :20 and :22.
  • Anchors re-verified against current source. PublicApiAnalyzers 5.6.0 PackageVersion: Directory.Packages.props:228. In Directory.Build.props: the gate comment :80-83, the Maui rationale :85-87, the ItemGroup :89-96 (its exclusion condition on :89), the AdditionalFiles :94-95, the RS0041 rationale :22-26 with the NoWarn line at :30, TreatWarningsAsErrors :7. The v1.153.0 changelog bullet: CHANGELOG.md:3374-3380 (analyzer on :3378) under its heading at :3319. [HubMethodName] sits at NotificationHub.cs:127 and :144 (constants :36, :39; Context.ConnectionAborted passed at :136 and :150); the client uses its own constants at NotificationHubService.cs:45-46. In ArchitectureRules.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, transparent oneof :271-273. The .editorconfig anchors in the 2026-10-01 entry, the ci.yml:161 floor, and the Status block's FACTS.md:51 / :54 pointers 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.

  1. MMCA.Common supplies two architecture maps, not one. Besides CommonArchitectureMap (Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:15), it ships FrameworkModuleArchitectureMap (Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/EntityModel/FrameworkModuleArchitectureMap.cs:16), which registers the framework's Shared, Domain and Application assemblies as one module named Framework (: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 under CommonArchitectureMap, 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) and EntityConventionTests (.../Domain/EntityModel/EntityConventionTests.cs:12-14) subclass their bases over it. The Decision's "single IArchitectureMap implementation" is corrected in place to a primary map per repo plus this second one in MMCA.Common.
  2. The shipped baselines hold 8,260 declarations across 8,281 non-empty lines in the 21 PublicAPI.Shipped.txt files, each carrying exactly one #nullable enable header (the 2026-10-06 entry read 8,254 across 8,275 against v1.232.0). The 21-pair count stands, the MMCA.Common metapackage's shipped file (Source/MMCA.Common/PublicAPI.Shipped.txt:1) is still the only header-only shipped file, and all 21 PublicAPI.Unshipped.txt files still hold the header alone.
  3. 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 :160 and the --minimum-expected-tests 2000 floor :163 (the Decision's citation is re-anchored in place); the restore guard :124, the build step :134-136 with its guard :135, and the unguarded FACTS gate :118-121 still resolve. The ui-e2e run is now a direct dotnet test --project Tests/Presentation/MMCA.Common.UI.E2E.Tests/... with --minimum-expected-tests 1 at ci.yml:292 (step name :287). In MMCA.Common/.editorconfig: the PublicApiAnalyzers overrides header :888-894, the RS0026 / RS0027 rationale (19 shipped members) :896-900 with both set to error at :901-902, RS0041 none at :906 (its comment :903-905), and RS0051-RS0056 none at :907-914. The v1.153.0 changelog heading is CHANGELOG.md:3344 and its "Three new build gates" bullet :3399-3405 (PublicApiAnalyzers on :3403). The framework client's hub method constants are Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:49-50; [HubMethodName] at NotificationHub.cs:127 and :144 and Context.ConnectionAborted at :136 and :150 still resolve. The Status block's FACTS.md:51 / :54 pointers still resolve.

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).