Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/data/platform-facts.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ window.PLATFORM_FACTS = {
{ name: "Testing", note: "Integration bases, architecture rule library, Playwright and bUnit harnesses", items: ["MMCA.Common.Testing", "MMCA.Common.Testing.Architecture", "MMCA.Common.Testing.E2E", "MMCA.Common.Testing.UI"] },
],
/* FACTS.md "Architecture fitness functions": test methods, not base classes */
fitnessTests: 100,
fitnessTests: 104,
/* Store, ADC, Helpdesk */
referenceApps: 3,
/* docs-src/governance/ArchitectureEvaluationCriteria.md */
Expand Down
2 changes: 1 addition & 1 deletion assets/data/search-index.json

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion docs-src/adr/008-service-extraction-topology.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
# ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway

## Status
Accepted.
Accepted. **Amended by [ADR-089](089-gateway-topology-owned-by-configuration.md) (2026-08-18)**: the
Gateway keeps the route-to-service map this record gave it, but stops expressing it as `MapForwarder`
calls in code. YARP `ReverseProxy` configuration becomes the single route source, the per-destination
HTTP version policy moves into cluster configuration, and a route-map test becomes the drift gate. The
topology decision itself is unchanged. See also
[ADR-088](088-gateway-edge-responsibilities.md) (2026-08-18) for the cross-cutting behavior the Gateway
gains at the same time, which is the first added to it since this record.

## Context
ADC began as a modular monolith: one `MMCA.ADC.WebAPI` host loaded every module (Identity, Conference,
Expand Down
72 changes: 71 additions & 1 deletion docs-src/adr/015-architecture-fitness-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
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).
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.

## Context
The codebase rests on invariants that are easy to state and easy to erode by accident: clean-
Expand Down Expand Up @@ -218,6 +221,73 @@ in the repository that owns it.
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` now reads 104 test
methods across 36 abstract `*TestsBase` classes, of which MMCA.Common's own build executes 96
(`FACTS.md:47`), where the entry above recorded 102 across 34 with 87 executed. 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](010-integration-event-schema-versioning.md) made every integration-event payload a versioned,
contract-tested shape. The other cross-service wire format, [ADR-007](007-grpc-extraction.md)'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/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/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 `ProtoContractFitnessTests.cs:11`. A clean fixture proves the
parser accepts a real contract and a drifted one proves the rule actually fails, 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](017-request-idempotency.md)'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 (96 of
104) 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.

## 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
Expand Down
82 changes: 80 additions & 2 deletions docs-src/adr/017-request-idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ Accepted. Revised 2026-08-01: the guard around execute-and-store is now an `IDis
from DI (Redis-backed wherever a connection multiplexer is registered, which is every deployed service
host), with the striped semaphore kept only as the no-lock-registered fallback, and body-less 2xx
results (204/`NoContent()`) are cached and replayed as well as `ObjectResult` bodies. See Decision and
Trade-offs.
Trade-offs. Revised 2026-08-18: the filter is still opt-in, but **the opt-in is no longer silent**. A
`[NonIdempotent(justification)]` attribute joins `[Idempotent]`, and a fitness gate
(`IdempotencyConventionTestsBase`) fails any `[HttpPost]` action that declares neither, so the last
Trade-off below ("an action that should be idempotent but is missing `[Idempotent]` gets no
protection") becomes a declared decision rather than an oversight. The shared auth controllers declare
their intent both ways. See the Revision (2026-08-18) at the end.

## Context
Write endpoints (POST / PUT / PATCH) are exposed to **client retries and double-submits**: a flaky
Expand Down Expand Up @@ -104,8 +109,81 @@ Provide opt-in, client-driven request idempotency as an MVC action filter in `MM
- **Opt-in.** An action that should be idempotent but is missing `[Idempotent]` gets no protection: the
same audit-the-inventory caveat as ADR-005's `IAnonymizable`.

## Revision (2026-08-18)
The last Trade-off above is the one this revision addresses, and it does so by changing what is
required. **Nothing here makes an endpoint idempotent.** What it requires is that every `[HttpPost]`
action *declare which it is*, so the absence of `[Idempotent]` stops being indistinguishable from
having forgotten it.

### `[NonIdempotent]` is the other half of the annotation
`NonIdempotentAttribute`
(`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/NonIdempotentAttribute.cs:23`,
`[AttributeUsage(AttributeTargets.Method)]` at `:22`) takes a `justification` as its only constructor
argument and exposes it as `Justification` (`:28`). It is inert at runtime: it changes no behavior,
registers no filter, and exists only to be read by the gate below and by whoever is looking at the
action. The justification is positionally required by the compiler and **not otherwise validated**, so
`[NonIdempotent("")]` compiles and passes.

### The gate: every POST declares an intent
`ArchitectureRules.PostActionsDeclareIdempotencyIntent`
(`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Idempotency.cs:44`)
flags any action carrying `[HttpPost]` that has neither `[Idempotent]` nor `[NonIdempotent]`
(`:67-74`). Consumers inherit it through `IdempotencyConventionTestsBase`
(`.../Bases/IdempotencyConventionTestsBase.cs:10`), whose single `[Fact]`
`PostActions_ShouldDeclare_IdempotencyIntent` (`:14-16`) is the whole body, in the ADR-015 pattern.

Three scope facts matter for reading a green result correctly. The lookup is **inherit-aware**
(`GetCustomAttributes(inherit: true)`, `:80-82`), so a consumer overriding a shared controller action
inherits the base's declaration rather than silently losing it, which is the same question ADR-019 had
to settle empirically for `[EnableRateLimiting]`. Only **concrete** classes are scanned (`:49`), so an
abstract base's declaration is checked where it is used rather than twice. And both the HTTP verb and
the two markers are matched by attribute **simple name** (`:39-41`), which keeps the rule free of a
reference to `MMCA.Common.API` and means a differently-named lookalike attribute does not count.

### The shared auth controllers declare both ways, and the split is the interesting part
`AuthControllerBase` marks register `[Idempotent]`
(`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:76-77`), which is
the canonical double-submit: a user pressing the button twice should get one account and one response.
Login (`:54-55`), refresh (`:98-99`) and revoke (`:117-118`) are `[NonIdempotent]`, as is the OAuth code
exchange (`.../Controllers/OAuthControllerBase.cs:136-137`). Token issuance must never be replay-cached,
and the sharpest case is refresh: [ADR-050](050-jwt-refresh-token-rotation.md) rotates the stored
refresh token on every use, so a cached replay would hand a client a token pair that has already been
rotated away and revoked, turning this filter's safety feature into an authentication failure. The
OAuth `complete` action is a `[HttpGet]` (`OAuthControllerBase.cs:74`) and is therefore outside the
gate's scope rather than unmarked.

### The no-op-without-a-key contract is now pinned by tests
The Decision has always said an absent or blank `Idempotency-Key` means the action runs normally. That
is now asserted rather than asserted-about: the filter returns `next()` untouched at the action stage
with no key (`.../Idempotency/IdempotencyFilter.cs:133-138`), only enables request buffering when a key
is present (`:123-124`), and treats blank as absent (`:165-172`), with
`Tests/Presentation/MMCA.Common.API.Tests/Idempotency/IdempotencyFilterPassthroughTests.cs:46`, `:68`
and `:85` covering the missing-header, blank-header and unbuffered-body cases. That matters because it
is the premise of adopting `[Idempotent]` widely: annotating an existing endpoint cannot change what an
existing client sees, since a client that sends no key is guaranteed the old path.

### What this revision costs
- **The gate covers POST only.** The Context above names POST, PUT and PATCH as the verbs exposed to
client retries; the rule keys on `[HttpPost]` (`ArchitectureRules.Idempotency.cs:39-41`). A `PUT` or
`PATCH` that should be deduplicated still gets no prompting, so two thirds of the stated problem is
outside the gate.
- **A justification is required to exist, not to be a reason.** `[NonIdempotent("")]` satisfies the
compiler and the rule. The gate raises the cost of not thinking from zero to one string.
- **Declared intent is not verified intent.** An action marked `[NonIdempotent]` because someone did
not want to reason about caching is indistinguishable to the gate from one marked after analysis.
What is bought is a reviewable diff at the moment of the decision, which is the same trade
ADR-015's public-API baselines make.
- **Adoption is per repo, like every other fitness base.** A consumer that never subclasses
`IdempotencyConventionTestsBase` gets exactly the previous posture.

## Related
ADR-003 (handler idempotency for outbox/event consumers, a distinct concern), ADR-013 (Result is the
response the filter caches/replays), ADR-014 (the filter keeps the handler thin), ADR-009 (the resilience
pipeline that re-issues requests is the main source of the duplicates this filter absorbs), ADR-026 (the
`ICacheService` substrate whose distributed-vs-memory backing determines cross-instance replay).
`ICacheService` substrate whose distributed-vs-memory backing determines cross-instance replay),
[ADR-015](015-architecture-fitness-functions.md) (the fitness-function suite the new convention gate
joins, and whose invariant-over-discipline posture it applies to this record's opt-in trade-off),
[ADR-050](050-jwt-refresh-token-rotation.md) (the refresh-token rotation that makes replay-caching a
token response actively harmful, which is why the auth endpoints declare `[NonIdempotent]`),
[ADR-035](035-optimistic-concurrency.md) (the mirror-image concern, two distinct edits racing, whose
own 2026-08-18 revision adds the `If-Match` precondition surface at the same edge).
Loading