In most projects a merge is an act of trust: the commit message says “tested locally,” then a build fed green by a dirty machine cache lands on main and turns everyone’s pipeline red that evening. BitzOrcas refuses that gamble — the quality gates run the same script set locally and in CI, so a passing local merge predicts a passing CI build.
The single merge entry point is scripts/build/verify-all.sh against the explicit BitzOrcas.Modern.slnx. For a stage-completion or PR report, scripts/build/report-acceptance.sh runs the same steps with fail-fast off and emits .verify-output/acceptance-report.md for review.
Per-merge gates
Ten commands run without Docker; one Docker-conditional gate covers Testcontainers integration tests.
| # | Command | Gate |
|---|---|---|
| 1 | dotnet restore BitzOrcas.Modern.slnx | Central Package Management restore |
| 2 | dotnet format BitzOrcas.Modern.slnx --verify-no-changes | .editorconfig formatting consistency |
| 3 | dotnet build BitzOrcas.Modern.slnx -c Release | 0 errors, 0 warnings (TreatWarningsAsErrors) |
| 4 | dotnet test BitzOrcas.Modern.slnx --filter "FullyQualifiedName!~Integration" | Unit + application tests |
| 5 | check-xml-files.sh | XML well-formedness (csproj/props/targets/slnx/config/resx) |
| 6 | check-xml-comments.sh | XML doc-comment rules (full coverage, no bare inheritdoc) |
| 7 | dotnet test BitzOrcas.Architecture.Tests | Dependency direction, module boundaries, banned references |
| 8 | dotnet test BitzOrcas.Integration.Tests --filter "Category!=Docker" | DI-closure smoke |
| 9 | dotnet publish src/Hosts/BitzOrcas.Api -c Release -p:PublishTrimmed=true | Hard gate trim publish & AOT compatibility |
| 10 | dotnet test BitzOrcas.Framework.ConsumerContract.Tests | Catalog-driven local feed with cold-cache restore/build/test/publish |
dotnet test tests/BitzOrcas.Integration.Tests --filter "Category=Docker" (Testcontainers / MsSqlBuilder / RabbitMqBuilder) is the Docker-conditional gate; every class using those builders must carry the Docker trait, otherwise it detonates randomly on Docker-less developer machines.
CI matrix
CI is defined in .github/workflows/ci.yml, triggered by push/PR to main plus a daily cron sweep. The solution is pinned to BitzOrcas.Modern.slnx and every command passes --disable-build-servers for reproducibility. Five jobs split the risk:
| Job | Responsibility |
|---|---|
portability-gate | ubuntu/windows/macos matrix restore + Release build, followed by architecture gate tests (preceded by test-architecture-process-profiles.sh all) — keeps platform-private APIs out of the framework |
fast-gate | Gitleaks container secret scan, dotnet format --verify-no-changes, IDE0005 unused-usings zeroed, OpenAPI drift check (check-openapi-drift.sh), layered test filters, XML file and XML comment checks |
release-candidate | Pre-assembly validation of commercial release candidates |
publish-trim | -p:PublishTrimmed=true publish across linux-x64 / win-x64 / osx-arm64 — this is where reflection calls broken by trimming surface |
integration-docker | Testcontainers contract tests against real SQL Server / Redis / RabbitMQ |
Commercial GA runs in separate protected workflows (commercial-ga.yml etc.): read-only verification of signed artifacts, with dedicated build-and-scan and sign-finalize-attest jobs pinned to full commit SHAs — no repository scripts execute inside the signing environment. See Commercial GA gate.
Test layers
The repository deliberately ships no numeric coverage gate — coverlet-style collectors are absent from the package ledger and CI runs no coverage step. Quality semantics ride on scripted hard gates: every layer’s targeted tests must be green, and core paths must include failure branches and negative cases. When judging whether a change is adequately tested, answer “which gate asserts this risk,” never a percentage.
| Layer | Scope | Tooling |
|---|---|---|
| Unit | Pure domain/rules | xUnit + Shouldly + NSubstitute |
| Application | Handler/Pipeline/Authorization/Result/Port contracts | xUnit + Shouldly + NSubstitute |
| Integration | DB / CAP / RabbitMQ / API / Auth / Outbox | Testcontainers (digest-pinned images) |
| Architecture | Dependency direction, module boundaries, banned references | ArchUnitNET |
Core paths (transactions/outbox/authentication/data permissions) must include failure paths and negative tests. InMemoryDb cannot replace core integration tests — it never surfaces real SQL dialect differences, connection-pool exhaustion, or transaction isolation behavior.
Data/transaction consistency matrix
Eight scenarios must be automated: a successful Command commits business data; a successful Command writes to the outbox; Result.Failure rolls back; system exceptions roll back; the outbox remains recoverable when RabbitMQ is down; tenant QueryFilters apply; soft-delete QueryFilters apply; asynchronous audit writes never block the business transaction. These eight scenarios are horizontal slices — every newly onboarded module must re-prove them over its own persistence.
API status-code contract
Every error is an RFC 9457 application/problem+json: standard fields type/title/status/detail/instance, plus observability extensions errorCode/errorType/traceId/correlationId/requestId. Mapping funnels through Results/ResultExtensions.ToProblem; endpoints must not fork their own error bodies.
| Status | Meaning |
|---|---|
| 400 | Malformed request/format/basic validation |
| 401 | Unauthenticated |
| 403 | No permission or plan disallows |
| 404 | Resource not found |
| 409 | Concurrency/idempotency/state conflict |
| 422 | Business rule not satisfied |
| 429 | Rate limit triggered |
| 500 | System exception only |
A real 409 response shape follows (errorCode comes from an existing entry in the strongly-typed error catalog; front ends branch on code exactly, while title/detail localize through external i18n):
{ "type": "https://docs.vnext.ailinkedlaw.com/errors/AI.Message.RequestConflict", "title": "Request conflict", "status": 409, "detail": "A request is already in flight for this conversation; wait for it to finish or cancel it first.", "instance": "/api/ai/conversations/3fa1c2/messages", "errorCode": "AI.Message.RequestConflict", "errorType": "Conflict", "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "correlationId": "6f3a2c9e4d7b4f1a", "requestId": "0HN7GJQKQV9PF0001"}errorCode values follow the {Owner}.{Scenario}.{Reason} three-segment shape and are all registered in 0008-error-catalog.json (currently 1,968 entries); inventing codes outside the catalog is itself a gate violation.
REQ-GATE-001 — zero tolerance for reflection
MakeGenericType(, GetMethod(, Activator.CreateInstance(, and Assembly.GetTypes( are P0 blockers in runtime code; Assembly.GetType( is a P1 warning. Any unregistered IL event in the SqlSugar adapter/Application/Domain auto-escalates to P0 and blocks the merge. The scanner is scripts/build/check-reflection.sh, driven by src/Tooling/BitzOrcas.ReflectionGuard.Cli; paired mutation tests keep the scanner itself honest.
Where reflection is genuinely required, three things must accompany the escape hatch: a // AOT-EXEMPT: annotation stating the business reason, unit tests covering the trimmed scenario, and registration in the exception ledger at docs/architecture/06-aot/0601-aot-exception-ledger.md:
// An unexempted Assembly.GetType is a P1 warning; these lines make it pass the gate.// AOT-EXEMPT: used only by a startup-time diagnostic probe, with the target type preserved via [DynamicallyAccessedMembers].var probeType = assembly.GetType(probeTypeName);// The ledger entry must also state owner, removal condition, and review date; exemption lapses if quarterly review slips.See ADR 0103 for the source-generation strategy that replaces reflection wholesale.
REQ-GATE-002 — no T-SQL
check-no-tsql.sh scans .cs string literals and hard-blocks "SELECT ", "INSERT ", "UPDATE ", "DELETE ", "EXEC " (P0 unless whitelisted). The whitelist covers the audit Dapper read-side exception and Dapper Query Store read-only queries, reviewed quarterly — and every review asks the same question: why can’t this SQL become an ORM expression yet? Replacements: ORM Lambda/LINQ, Mapperly ProjectToDto(), [BitzTable]-driven ORM configuration.
REQ-GATE-003 — ArchUnitNET assertions and banned packages
The architecture test project BitzOrcas.Architecture.Tests (TngTech.ArchUnitNET + Shouldly + xUnit, roughly a hundred test classes) continuously asserts: Domain has no upstream dependencies; Application has no Infrastructure dependencies; Domain/Application reference no ORM packages; cross-module calls target {Module}.Contracts only; Mapperly appears only in Infrastructure/Workflow; IRepository<T> signatures contain no ORM types; FluentValidation is referenced nowhere.
Technology-choice red lines are mechanized too. The excerpt below shows the core of BannedPackagesTests (comments added for documentation); this prefix list is the executable form of the ADR-level selection matrix:
// Banned prefixes come from ADR 0101/0002/0003; changing this list requires revising the ADR first.private static readonly string[] BannedPrefixes =[ "Autofac", // DI goes through built-in Microsoft.Extensions.DependencyInjection "Newtonsoft.Json", // Serialization goes through System.Text.Json "Swashbuckle", // OpenAPI docs come from the built-in pipeline and source generation "AutoMapper", // Mapping goes through compile-time Mapperly generation "FluentValidation", // Validation goes through the IRequestRule pure-function rule pipeline "Hangfire", "Finbuckle", "MediatR", // CQRS messaging goes through the Mediator source generator "Mapster",];
[Fact]public void Project_Assemblies_Should_Not_Reference_Banned_Libraries(){ // 1. Enumerate direct references of each controlled assembly and match banned prefixes. var offenders = ( from assembly in ProjectAssemblies from reference in assembly.GetReferencedAssemblies() let name = reference.Name ?? string.Empty from banned in BannedPrefixes where name.StartsWith(banned, StringComparison.Ordinal) select $"{assembly.GetName().Name} -> {name}").ToList();
// 2. A single violation turns fast-gate red, with the full dependency chain in the assertion message. offenders.ShouldBeEmpty($"Banned packages referenced by project assemblies:\n{string.Join("\n", offenders)}");}Sibling red lines include logging isolation (Domain/Application must not touch Serilog) and clock discipline (system time reads go through IAppClock only — a repo-wide scan bans raw DateTime.Now/UtcNow, keeping conflict-of-interest windows and limitation-period calculations deterministically testable). The full list lives in CodingRedLineTests.
Template completion definition
Ten conditions define a complete architecture template: docs include ADR/context-map/selection-matrix/redlines; code includes the 4-layer projects + AppHost + ServiceDefaults + tests; one real business slice runs end-to-end API→DB→outbox; architecture tests block wrong dependencies; integration tests stand up real SQL Server/Redis/RabbitMQ; CI runs build/unit/integration/architecture/trim; the template generates shell + business source only (never copying Framework/Platform/Licensing cores); every Profile completes cold-cache restore/build/test/publish; commercial packages pass signature/hash/SBOM/vulnerability/license gates; Package Entitlement, Runtime License, and tenant Feature Entitlement are layered with online/offline/failure-state tests.
Miss one, and what ships isn’t an architecture template — it’s a source archive that makes the recipient do the homework.