Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

AIFrameWork

CI License: Apache 2.0

A .NET 10 and React 19 reference application built on Clean Architecture, where the architectural rules are enforced by tooling rather than by convention.

The domain is deliberately small — users place orders against a catalogue — so that the interesting part is the scaffolding around it: a hook that blocks a layering violation at edit time, a transactional outbox, RabbitMQ for every asynchronous hop behind a durable Wolverine outbox, background jobs in a worker host of their own, outbound integrations over mTLS (OCES3 certificates) and OAuth 2.0 client credentials, an API contract generated from the application and committed to the repository, and a local Kubernetes cluster that runs the whole stack at two replicas to rehearse the failures that only appear above one.

Table of contents

Background

Most Clean Architecture samples describe their dependency rule in prose and then rely on review to uphold it. This repository takes the position that a rule nobody can enforce is a rule that erodes, so the dependency rule is a pre-edit hook that refuses the write, the analyzers run as errors rather than warnings, and the generated artifacts that couple the two stacks together are committed and re-checked by CI.

The design rationale is recorded in docs/adr/ and in the specs under docs/superpowers/specs/. Working notes for contributors and agents live in CLAUDE.md and in a per-layer CLAUDE.md inside each project.

Architecture

Five projects: three inner layers, and two composition roots over them. Dependencies point inward only.

     ┌─────────────────────────────┐   ┌─────────────────────────────┐
     │             Api             │   │           Worker            │
     │  Controllers, DTOs, hub,    │   │  Job handlers and the       │
     │  outbox pumps, exceptions   │   │  Quartz scheduler           │
     └──────┬───────────────┬──────┘   └──────┬───────────────┬──────┘
            │               │ DI only         │ DI only       │
            │               ▼                 ▼               │
            │        ┌──────────────────────────────┐         │
            │        │        Infrastructure        │         │
            │        │  EF Core, repositories,      │         │
            │        │  outbox, cache, Wolverine    │         │
            │        └──────────────┬───────────────┘         │
            ▼                       ▼                         ▼
     ┌───────────────────────────────────────────────────────────────┐
     │                          Application                          │
     │           Use cases, ports, Result<T>, validators             │
     └───────────────────────────────┬───────────────────────────────┘
                                     ▼
     ┌───────────────────────────────────────────────────────────────┐
     │                            Domain                             │
     │          Entities, value objects, domain events               │
     └───────────────────────────────────────────────────────────────┘
From ↓ / To → Domain Application Infrastructure Api Worker
Domain — ✗ ✗ ✗ ✗
Application ✓ — ✗ ✗ ✗
Infrastructure ✓ ✓ — ✗ ✗
Api ✓ ✓ ✓ DI only — ✗
Worker ✓ ✓ ✓ DI only ✗ —

Api and Worker are siblings, not layers, and neither may reference the other (ADR 0016). Worker → Api is blocked for a concrete reason beyond tidiness: it is exactly what would let the two share one Wolverine generated-code tree, and keeping them separate is what makes each host's codegen write independently correct.

The rule is enforced twice. .claude/hooks/dependency-rule.ps1 blocks the edit, and an ArchitectureTests class in every test project fails CI on any commit, Claude's or not:

  • The inner layers' tests check assembly references by reflection and reject every AiFramework.* reference the table does not allow.
  • The Api and Worker tests enforce "DI only" with ArchUnitNET, which reads the compiled IL: outside a short composition allowlist (Program, each host's observability registration, and the generated Wolverine adapters), no type may depend on an Infrastructure type — method bodies, lambdas and async methods included. That is what tells a controller reaching into a repository apart from a services.AddScoped<>() line.

Domain additionally may not reference EF Core, ASP.NET Core, the DI abstractions, System.Data, or System.ComponentModel.DataAnnotations.

Three caveats about the hook, all covered by the tests in CI:

The hooks are PowerShell, and they are invoked as powershell.exe. On Linux or macOS without PowerShell installed, none of them run — the dependency rule, the secrets guard, and the migration guard are all silently inert at edit time. The architecture tests run everywhere, so a violation still fails dotnet test.

The hook's banned-namespace table lists Worker only under Api, so Domain → Worker, Application → Worker, and Infrastructure → Worker pass it at edit time. The inner layers' tests reject them.

The hook reads using directives in .cs files only, so a fully-qualified inline reference passes it. The tests read what the compiler emitted, so they catch it — but not a <ProjectReference> that no code uses yet, which nothing catches until the first type is named across it.

Five mechanisms are worth knowing about before reading the code:

  • In-process messaging without MediatR. ICommandHandler<TCommand, TResponse> and IQueryHandler<TQuery, TResponse> are dispatched through ICommandDispatcher and IQueryDispatcher, with cross-cutting concerns applied as pipeline behaviors. Expected failures return a failed Result<T> rather than throwing. See ADR 0003.
  • A transactional outbox. Domain events raised by an aggregate are captured by an EF Core interceptor and written in the same transaction as the aggregate itself, then pumped out by a background service — in the API only, because the notifiers it runs push over SignalR, which lives there (ADR 0028). A durable Wolverine outbox on PostgreSQL sits behind it, and publishes integration events for other systems to RabbitMQ. See ADR 0005 and ADR 0026.
  • Opt-in query caching, scoped to the caller. A query implements ICacheable, a command implements IInvalidatesCache, and the behavior composes the cache key from the query type and the current user's id. Nothing on the authentication path is cached. See ADR 0009.
  • Background jobs in a worker host of their own. A job is a message with a lane (Light/Heavy, one RabbitMQ quorum queue each), enqueued through IJobScheduler. The API publishes but listens to nothing; the worker is the only host with listeners, which is asserted from the runtime's own endpoint list rather than intended. Scheduled jobs use Quartz as the clock and fire on exactly one worker. See ADR 0016 and ADR 0017.
  • Outbound integrations with external systems. Each system is configured by name under ExternalSystems:Systems:<Name>, with secrets as mounted file paths, never values. One registration, AddClient<TApi>(name), builds a Refit client with a fixed handler chain: outbound traffic counting, the standard resilience handler with this system's own options, per-attempt counting, a zero-delay resend on a 401 with a fresh token, the OAuth 2.0 client-credentials token handler (Keycloak; a client secret or private_key_jwt), and a primary handler that presents the system's OCES3 certificate with its full chain. Each system gets probe, certificate-expiry and token health checks, kept out of /health/ready so that a partner's outage never takes a pod out of rotation. See ADR 0031 and the external-systems skill.

Tech stack

Backend

Runtime .NET 10 (net10.0), C# 14, SDK 10.0.400
Web ASP.NET Core, controller-based
Persistence PostgreSQL 17 via EF Core 10 and Npgsql
Messaging Hand-rolled dispatchers; WolverineFx 6 for the durable event path, jobs and integration events; RabbitMQ 4 as the broker
Jobs Worker host over RabbitMQ quorum queues; Quartz.NET 4 as the scheduler clock
Caching HybridCache (L1 only today)
Realtime SignalR, opt-in, with a Redis backplane above one replica
Validation FluentValidation
Auth Cookie session with a rotating security stamp; Data Protection key ring in PostgreSQL; capability-named policies over a Member/Admin role
Resilience Microsoft.Extensions.Http.Resilience outbound; EnableRetryOnFailure on Npgsql
Integrations Refit 16 (source-generated) typed clients; Duende.AccessTokenManagement 4 for OAuth 2.0 client credentials against Keycloak; OCES3 client certificates over mTLS
Documents PDFsharp + MigraDoc 6 for the order-export PDF, rendered in the worker
Observability Microsoft.Extensions.Logging + OpenTelemetry logs, traces and metrics over OTLP; Seq locally; OpenSearch, Prometheus and Grafana in-cluster
API docs Microsoft.AspNetCore.OpenApi rendered by Scalar
Analysis .NET analyzers, SonarAnalyzer, Meziantou.Analyzer, AsyncFixer — all as errors

Frontend

Framework React 19 with TypeScript 6
Build Vite 8
Routing React Router 7
Server state TanStack Query 5
API types Generated from the committed OpenAPI document
PDF viewing react-pdf (pdf.js), lazy-loaded only on the exports page
Styling Plain CSS with design tokens
Lint/format ESLint (--max-warnings 0) and Prettier

Testing and infrastructure

Backend tests xUnit, FluentAssertions, NSubstitute, Testcontainers (PostgreSQL, RabbitMQ, Keycloak), ArchUnitNET, an in-process mTLS partner simulator
Frontend tests Vitest, React Testing Library, MSW
End-to-end Playwright against a built preview bundle
Containers Docker Compose for development, chiselled images for deployment
Orchestration kind with Kustomize overlays and ingress-nginx
CI GitHub Actions, Debug and Release; CodeQL default setup

Exact pins live in CLAUDE.md, Directory.Build.props, the .csproj files, and frontend/package.json. CI pins the SDK and Node versions in .github/workflows/ci.yml.

Repository layout

.
├── src/
│   ├── Domain/            Entities, value objects, domain events, domain exceptions
│   ├── Application/       Use cases, ports, Result<T>, validators
│   ├── Infrastructure/    EF Core, repositories, outbox, caching, Wolverine, jobs, security, exports,
│   │                      and ExternalSystems/ — the outbound integration plumbing
│   ├── Api/               Controllers, DTOs, the SignalR hub, the outbox pumps, exception handling, composition root
│   │   └── Internal/Generated/    Wolverine adapters for the event path — committed
│   └── Worker/            The job host: handlers, Quartz scheduling, the inbound shipments listener
│       └── Internal/Generated/    Wolverine adapters for the jobs — committed, and its own
├── tests/
│   ├── Domain.Tests/
│   ├── Application.Tests/
│   ├── Infrastructure.Tests/     Testcontainers-backed PostgreSQL fixtures
│   ├── Api.IntegrationTests/     WebApplicationFactory over the real pipeline
│   ├── Worker.IntegrationTests/  Job delivery, scheduling, and the worker's codegen
│   └── PartnerSimulator/         Test-only: a throwaway PKI and an in-process mTLS partner
├── frontend/              Vite + React workspace, Vitest specs, Playwright e2e
├── k8s/
│   ├── base/              api, web, worker, postgres, rabbitmq, redis, ingress, autoscaling
│   ├── components/        observability — OTel Collector, OpenSearch, Prometheus, Grafana; opt-in
│   └── overlays/          local, and local-observability
├── deploy/                kind cluster definition and deployment scripts
├── scripts/               Development loop, prerequisite and dev-certificate scripts
├── local-run/             control-panel.bat, a double-clickable menu
├── openapi/               The committed API contract
├── docs/
│   ├── adr/               Architecture decision records
│   ├── local-development.md
│   └── superpowers/       Specs and implementation plans
└── .claude/               Hooks, agents, skills, and slash commands

Install

Prerequisites

  • Windows 11 with PowerShell (the helper scripts are PowerShell; everything else is portable)
  • .NET SDK 10
  • Node.js 24.15 or newer (24.20.0 is what CI pins)
  • Docker Desktop
  • kubectl, kind, and optionally k9s, for the Kubernetes path only

On a new machine you do not have to install these by hand:

./scripts/install-prereqs.ps1

It checks for each tool and installs whatever is missing through winget. It only installs what is entirely absent; a tool that is present but older than expected is reported rather than silently upgraded, because upgrading Docker Desktop or Node.js affects every other project on the machine. Docker Desktop's own first run (WSL2 backend, licence terms, a restart) cannot be scripted and the script says so when it reaches that point.

The same script is option 1 of local-run/control-panel.bat, a double-clickable menu that wraps every script in this section.

Clone and restore

git clone https://github.com/Surentz/AIFrameWork.git
cd AIFrameWork
dotnet tool restore        # dotnet-ef, from .config/dotnet-tools.json
npm install --prefix frontend

scripts/dev.ps1 runs npm install by itself when frontend/node_modules is missing, so a first run needs no separate step.

Usage

Running locally

One command starts PostgreSQL and RabbitMQ, applies migrations, and launches the API, the job worker, and the Vite dev server in windows of their own:

./scripts/dev.ps1              # three windows: API (5234), worker (5235), Vite (5173)
./scripts/dev.ps1 -WithSeq     # same, plus Seq for structured logs at localhost:55341
./scripts/worker.ps1           # just the worker, in this window — for restarting it alone

To stop all of it:

./scripts/stop-dev.ps1

The worker is not optional scenery: jobs run there and the API listens to nothing, so without that window an enqueued job simply sits on its RabbitMQ queue and nothing says so. worker.ps1 exists because codegen write requires a worker restart before new adapters take effect, and restarting it otherwise means stopping everything.

By hand, if you want the pieces separately:

docker compose up -d --wait
dotnet ef database update --project src/Infrastructure --startup-project src/Infrastructure
dotnet run --project src/Api
dotnet run --project src/Worker
npm start --prefix frontend

The app opens at http://localhost:5173 and the API reference at http://localhost:5234/scalar/v1. Both open a browser tab of their own. Vite proxies /api to the backend, which is what lets the session cookie be same-origin.

Further detail, including one-keystroke startup in Rider and Visual Studio, is in docs/local-development.md.

External systems in development

No external system is configured by default, so the dev loop needs nothing extra. To try one, generate a throwaway PKI into the git-ignored .certs/ folder and point a system at the files:

./scripts/new-dev-certs.ps1          # ca.pem, client.pfx/.pass, server.pfx/.pass; -Force to replace
$env:ExternalSystems__Systems__Partner__BaseAddress = "https://partner.example/api/"
$env:ExternalSystems__Systems__Partner__ClientCertificate__Path = "$PWD/.certs/client.pfx"
$env:ExternalSystems__Systems__Partner__ClientCertificate__PasswordFile = "$PWD/.certs/client.pass"

Secrets are always file paths, never values — in a deployed environment they are files that Vault Secrets Operator mounts (the contract is in ADR 0031; the manifests are not written yet). The external-systems skill has the full configuration, including OAuth.

To see the monitoring page with a real partner, ./scripts/dev.ps1 -WithPartners starts the test partner simulator (mTLS on 55690, health on 55691) from .certs/ (generated if missing) and points the worker at it. Without the flag the worker has no external system and the page is empty.

Running on Kubernetes

A local kind cluster that runs the stack at two API replicas across two worker nodes, to rehearse what only breaks above one: a shared Data Protection key ring, cache eviction under ingress cookie affinity, and a realtime push that has to reach a user whose connection is held by the other pod. Docker Compose remains the inner development loop; this is additive.

./deploy/start-cluster.ps1     # creates the cluster if missing, otherwise redeploys
./deploy/deploy.ps1            # rebuild, migrate, roll out onto an existing cluster
./deploy/deploy.ps1 -WithObservability   # same, plus OTel Collector -> OpenSearch, Prometheus, Grafana
./deploy/teardown.ps1          # deletes the cluster, and the data inside it

Two replicas means two failure domains, not two processes: deploy/kind-cluster.yaml declares a control-plane node plus two workers, and api spreads across them with whenUnsatisfiable: DoNotSchedule. HPAs cover api and web with minReplicas: 2 as a floor, and all three workloads have PodDisruptionBudgets. The worker has a budget but no autoscaler — its work arrives through queues, so queue depth is the signal an autoscaler would need, and CPU would scale it down exactly when it is blocked and falling behind. See ADR 0018.

-WithObservability is opt-in and is by a wide margin the largest thing in the cluster (OpenSearch wants real JVM heap, Dashboards is a second Node process) — reach for it only when the telemetry pipeline itself is what you are rehearsing. It is deliberately not part of the e2e-k8s.ps1 readiness gate: a log store has no business in the readiness path of an e2e run.

The deployment is three phases in a fixed order, because Kustomize has no hook mechanism: configuration and PostgreSQL first, then migrations as a Job run to completion, and only then the Deployments and the Ingress. An API pod therefore never starts against an unmigrated schema.

Open https://aiframework.localtest.me:8443. That name resolves to 127.0.0.1 publicly, so there is nothing to add to your hosts file, and the certificate is self-signed so the browser warns once. The ingress is published on 8080/8443 rather than 80/443 because those ports are frequently taken on Windows by IIS or BranchCache through http.sys.

Four things that will cost you time are documented in CLAUDE.md: TLS is not optional in Production because the session cookie is Secure; configuration keys need double underscores (Cache__Enabled, not Cache_Enabled — a single underscore binds nothing and warns nothing); the migration Job is deleted before being re-applied, because a completed Job has immutable fields; and metrics-server is installed patched with --kubelet-insecure-tls, without which every HPA reports unknown and never scales — as a condition on the HPA, not as a deploy failure, so the cluster looks healthy while the autoscalers do nothing.

PostgreSQL is pinned to whichever node it first scheduled on by kind's node-local storage class, so draining that node will not reschedule it. Target a different node for a drain test.

The local overlay commits a PostgreSQL password and a self-signed private key on purpose. They are throwaway values for a localhost-only cluster that is never deployed, each file says so in its own header, and neither is a pattern to copy into an overlay that targets a real environment.

Ports

Port What Source
5173 Vite dev server frontend/vite.config.ts
5234 API src/Api/Properties/launchSettings.json
5235 Job worker (health endpoints only) src/Worker/Properties/launchSettings.json
4173 Vite preview, used by the e2e suite frontend/vite.config.ts
55433 Development PostgreSQL, data persists docker-compose.yml
55432 End-to-end PostgreSQL, throwaway docker-compose.e2e.yml
55672/55673 Development RabbitMQ: AMQP / management UI (aiframework/aiframework) docker-compose.yml
55682/55683 End-to-end RabbitMQ, throwaway docker-compose.e2e.yml
55690/55691 Dev partner simulator: mTLS / health, only with dev.ps1 -WithPartners tests/PartnerSimulator
55692/55693 End-to-end partner simulator: mTLS / health frontend/playwright.config.ts
55341 Seq, only with dev.ps1 -WithSeq docker-compose.yml (observability profile)
8080/8443 kind ingress deploy/kind-cluster.yaml

The two databases are meant to coexist. The two API processes are not: npm run e2e starts its own API on 5234 with reuseExistingServer: false, so stop the development API before an e2e run or set API_PORT.

API

All endpoints are under /api. Authentication is a HttpOnly, SameSite=Lax session cookie named aiframework.session; SameSite=Lax combined with JSON-only request bodies is the CSRF defence, so no antiforgery token is issued.

Method Route Auth Purpose
POST /api/auth/register anonymous, rate limited Create an account and sign in
POST /api/auth/login anonymous, rate limited Sign in
POST /api/auth/logout cookie Sign out
GET /api/auth/me cookie The current session
POST /api/auth/change-password cookie Change your own password
POST /api/auth/sign-out-everywhere cookie Invalidate every session for this user
POST /api/orders cookie Place an order
GET /api/orders cookie List your orders, paged
GET /api/orders/{id} cookie One of your orders
POST /api/orders/{id}/cancel cookie Cancel one of your orders; 409 if it has already shipped
POST /api/orders/exports cookie Ask for a PDF of all your orders; 202, built by the worker, notified when ready. Returns the one in progress instead of starting another
GET /api/orders/exports cookie Your exports, newest first, kept for seven days. One still building after 45 minutes reads as Failed
GET /api/orders/exports/{id}/download cookie The PDF; 404 if it is someone else's or not built yet
GET /api/fulfilment/orders Orders.Fulfil Every buyer's orders in one status, oldest first
POST /api/fulfilment/orders/{id}/ship Orders.Fulfil Ship any buyer's order — the only way an order ships over HTTP; 409 on an illegal transition
POST /api/products Catalogue.Manage Add a product to the catalogue
GET /api/products cookie List the catalogue, paged
GET /api/products/{id} cookie One product
PUT /api/products/{id} Catalogue.Manage Replace a product's editable fields — not the sku
GET /api/notifications cookie Your notification feed, newest first, paged
GET /api/notifications/unread-count cookie Your unread count, for the badge
POST /api/notifications/{id}/read cookie Mark one notification read
POST /api/notifications/read-all cookie Mark every unread notification read
GET /api/rates?from=&to= cookie An exchange rate; 503 once the provider's retry budget is spent
GET /api/statistics/population?area= cookie The latest quarter's population for an area (Statistics Denmark, the external systems pilot); 400 for a malformed area, 404 for an unpublished one, 503 when the source is down
GET /api/statistics/population/areas cookie Every area population figures are published for
GET /api/monitoring/external-systems Monitoring.Read Each external system's last health check, certificate expiry and outbound traffic; written by the worker (ADR 0032)
GET /api/monitoring/* Monitoring.Read The operator's views: access, sign-ins, traffic, job runs and dead letters
POST /api/monitoring/jobs/trigger, …/dead-letters/{messageId}/retry Monitoring.Operate Run a scheduled job now; put a dead letter back in play
GET/POST /api/monitoring/users/* Users.Manage List accounts, promote or demote, revoke sessions, an account's history
GET /health anonymous Liveness; never touches the database
GET /health/ready anonymous Readiness; checks PostgreSQL. External systems' health checks are deliberately excluded

Auth is cookie for any signed-in user, or a capability policy that only an Admin holds (ADRs 0020, 0024, 0025). Controllers name the capability, never the role, so a later permission model changes one mapping in Program.cs rather than every controller. Administrators are seeded from Admin__Usernames and managed from the monitoring page.

Orders also ship without anyone calling the API: a warehouse publishes shipment.confirmed.v1 to RabbitMQ and the worker records it (ADR 0026).

The worker serves /health and /health/ready of its own on 5235, and nothing else — it is a web host only so that Kubernetes has something to probe.

Both hosts register each configured external system's probe, certificate and token checks, tagged external, and both /health/ready endpoints filter them out (ExternalSystemHealth.IsNotExternal). A partner that is down makes its own checks Unhealthy; it never takes an API or worker pod out of rotation. The worker's status publisher records those checks every minute and the monitoring page shows them (/monitoring/integrations, ADR 0032); neither endpoint does.

/hubs/notifications is a SignalR hub, mapped only when Realtime__Enabled is on. It is best-effort by contract: the feed is the truth, and the push only closes the window in which the badge would otherwise be up to thirty seconds stale. See ADR 0019.

The catalogue is [Authorize] rather than anonymous even for reads, and that is mechanical rather than a product decision: an ICacheable query dispatched with no current user throws, because the cache key is composed with ICurrentUser.Id and an unscoped entry would be shared across every caller.

Orders belong to the user who placed them and are never reassigned (ADR 0007), so a request for someone else's order is a 404 rather than a 403.

Failures are RFC 9457 ProblemDetails throughout, including the rate limiter's 429. Expected failures never throw: a handler returns a failed Result, and ErrorKind maps to a status code (Validation → 400, Unauthorized → 401, NotFound → 404, Conflict → 409, Unavailable → 503 with Retry-After). Thrown DomainExceptions become 400 through the global IExceptionHandler; anything else is a logged 500 whose message is not leaked.

Credential endpoints are protected twice over (ADR 0008): a fixed-window rate limiter partitioned by remote address, and a self-expiring per-account lockout after five failed attempts. The lockout is deliberately silent, so that neither mechanism reveals which accounts exist.

Sessions can be invalidated after the fact (ADR 0011). Each user carries a rotating SecurityStamp; the cookie carries the stamp it was issued under, and every authenticated request compares the two in OnValidatePrincipal before the endpoint sees it. Three things rotate it — a password change, the failed sign-in that locks an account, and POST /api/auth/sign-out-everywhere — and a rotation ends every cookie already issued for that user. That read is deliberately uncached and always will be: HybridCache here is L1-only, so caching it would let a revoked session survive on another replica for the length of the TTL. Changing your own password re-issues your own cookie, which is why ChangePassword returns a SessionView rather than a bool.

The interactive reference is Scalar at /scalar/v1, and it is Development only — a deployed instance must not publish its endpoint surface, and an integration test asserts that in both directions.

Frontend application

frontend/src/
├── api/            Generated schema, typed client, endpoint wrappers
├── features/
│   ├── auth/           Login, register, change password, RequireAuth and RequireRole guards
│   ├── orders/         List, detail, place-order form, exports page, query hooks
│   ├── products/       Catalogue list, detail, create and edit forms
│   ├── notifications/  The header bell, the feed, and the SignalR stream
│   ├── fulfilment/     The operator's queue of orders to ship
│   └── monitoring/     Overview, jobs, sign-ins, traffic, and user management
├── components/
├── styles/         tokens.css, global.css, controls.css
├── test/           MSW handlers, setup, query-client helper
└── routes.tsx
Route Component Access
/login LoginPage anonymous, full-bleed
/register RegisterPage anonymous, full-bleed
/ redirect to /orders authenticated
/orders OrderList authenticated
/orders/new PlaceOrderForm authenticated
/orders/exports OrderExportsPage authenticated
/orders/:id OrderDetail authenticated
/products ProductList authenticated
/products/new CreateProductForm administrator
/products/:id ProductDetail authenticated
/products/:id/edit EditProductForm administrator
/notifications NotificationList authenticated
/statistics/population PopulationPage authenticated
/account/password ChangePasswordPage authenticated
/fulfilment FulfilmentPage administrator
/monitoring MonitoringPage administrator
/monitoring/jobs, /logins, /traffic, /users JobsPage, LoginsPage, TrafficPage, UsersPage administrator
/monitoring/integrations IntegrationsPage administrator

/products/new and /orders/exports are declared before /products/:id and /orders/:id, so that new and exports match their literal routes rather than being captured as an id.

RequireAuth wraps the layout rather than the other way round, so a signed-out visitor is redirected before a header they cannot use is rendered. RequireRole allow="Admin" nests inside it for the administrator routes; it only hides UI, and the API's policies are what refuse the request. Server state is TanStack Query; there is no client state library, because there is no client state worth the dependency.

npm start   --prefix frontend    # dev server on 5173
npm run build --prefix frontend  # tsc -b && vite build
npm test    --prefix frontend    # vitest (watch); add -- --run for one pass
npm run lint --prefix frontend   # eslint, zero warnings tolerated
npm run e2e --prefix frontend    # Playwright

Generated code

Three artifacts are generated, committed, and re-checked by CI. Each exists because the failure it prevents is invisible in a normal build.

The API contract. openapi/AiFramework.Api.json is generated from the running application and frontend/src/api/schema.d.ts from that. After changing a controller, a DTO, or a [ProducesResponseType]:

dotnet restore src/Api
ConnectionStrings__Default='Host=localhost;Port=55433;Database=placeholder;Username=x;Password=y' \
  Wolverine__Durable=false Admin__ReconcileOnStart=false \
  dotnet msbuild src/Api -t:"Build;GenerateOpenApiDocuments"
npm run generate:api --prefix frontend

Generation runs the whole application, which is why all three environment variables are required: the startup guard rejects an empty connection string, a durable Wolverine dials PostgreSQL, and so does the startup administrator reconciler. The connection string is never actually opened, and no broker is needed — RabbitMQ is configured only when Wolverine is durable. It is an explicit MSBuild target rather than part of dotnet build, so that an ordinary build stays ordinary.

Wolverine handler adapters — there are TWO trees, not one. Wolverine builds its adapters with Roslyn, and Release ships without the compiler because it costs 33MB (measured: a Release publish is 17MB without it, 50MB with). Release instead loads adapters generated ahead of time. TypeLoadMode.Static resolves them out of each host's own opts.ApplicationAssembly, and the worker cannot share the API's — that would need the Worker → Api reference the dependency rule forbids. After adding or changing a handler, regenerate the tree(s) it belongs to:

dotnet run --project src/Api -- codegen write      # event-path handlers
dotnet run --project src/Worker -- codegen write   # job handlers, and JobUserMiddleware

This is the trap the Release dimension in CI exists for: stale generated code leaves Debug green and the build succeeding, and fails only in Release, at startup. Debug stays green with either tree stale, and CI checks both.

CI's Linux output is the authority, and Windows does not always match it. If a Windows regeneration reorders statements inside a handler you did not change, keep the committed version — the two orders do the same thing, each platform is stable, and CI's "generated code is current" job is what decides.

EF Core migrations. Never hand-edit an applied migration; add a new one. .claude/hooks/protect-migrations.ps1 enforces this against git history, so a migration you have just generated and not yet committed is still yours to adjust, while one that is in HEAD is refused even if you delete the file first.

Testing

dotnet test                          # five test projects; needs Docker for Postgres, RabbitMQ and Keycloak
npm test --prefix frontend -- --run  # Vitest
npm run e2e --prefix frontend        # Playwright

Inside Claude Code, /verify runs all of the above plus both lint steps and the two generated-artifact drift checks, and reports what actually ran — skipping any toolchain that is not installed rather than reporting a false pass.

Suite What belongs there
Domain.Tests Invariants and domain events, no infrastructure
Application.Tests Handlers against substituted ports; architecture tests
Infrastructure.Tests Repositories, outbox, caching, against real PostgreSQL via Testcontainers; external systems over real TLS against the in-process partner simulator and a Keycloak container
Api.IntegrationTests The real pipeline through WebApplicationFactory, including authorization policies
Worker.IntegrationTests Job delivery, Quartz scheduling, inbound shipments, and the worker's own codegen
frontend/src/**/*.test.tsx Components and hooks, with MSW standing in for the API
frontend/e2e/specs/**/*.spec.ts Browser journeys against a built preview bundle, the worker and a real broker

Testcontainers needs a running Docker daemon; both integration projects start a RabbitMQ container beside Postgres, because a durable host cannot boot without a broker. The caching behavior, the rate limiter, and the resilience pipeline's retries are all disabled by default under test and re-enabled by the specific suites that exercise them, so that none becomes an intermittent failure — or a source of backoff delay — in tests that are not about them.

Several suites exist specifically to catch a class of regression that review alone would miss: JobRegistrationTests fails the build on an unregistered job, ApiPublishesOnlyTests asserts against the runtime's own endpoint list that no aiframework.jobs.* queue has a listener on the API host, SensitiveCommandLoggingTests catches a logging change that would write plaintext passwords to the log store, and WolverineCodegenTests/WorkerCodegenTests catch stale adapters in Debug — where they would otherwise stay invisible until Release. For external systems, NoAcceptAnyCertificateTests fails on any certificate-validation callback under src/ (the only way to write "accept any certificate"), HandlerChainTests pins the handler order, and ReadinessTests in both hosts prove an unreachable partner leaves /health/ready at 200.

Certificate tests are only proven on Linux. Windows can complete a certificate chain from its own store, so a TLS test that passes locally may still fail in CI's Linux jobs. That has happened once already, and CI is the authority here.

Continuous integration

.github/workflows/ci.yml runs on every push to main and every pull request:

Job What it proves
backend (Debug) / backend (Release) Builds and tests in both configurations; Debug also fails on unformatted C# (dotnet format whitespace --verify-no-changes)
generated code is current Re-runs codegen write for both trees and fails on any diff
api contract is current Regenerates the OpenAPI document and the TypeScript schema, and fails on any diff
frontend Lint, build, and Vitest
e2e / e2e Playwright against the full stack, gated behind backend and frontend; also runnable by hand from the Actions tab
pr title and labels From pr-labels.yml: the title is a Conventional Commit, and the PR is labelled from it and the files changed

Every one of these must pass before a pull request can merge into main. The branch ruleset requires them by name, so renaming a job means updating the ruleset in the same change.

CodeQL scans C#, TypeScript and the workflows on every pull request and weekly, from repository settings (default setup) rather than a workflow file, so there is no codeql.yml — GitHub rejects one while default setup is on. Results land in the Security tab; it is not a required check.

Release is a separate matrix leg rather than an afterthought: Release was broken in this repository for the whole life of the Wolverine spike without anyone noticing, because dotnet build succeeded with zero warnings and only the startup failed.

The two "is current" jobs fail on a diff, not on a build error, which is what lets a working tree build, test, and lint clean while CI rejects it. /verify runs both as its step 3 for exactly that reason — so the one thing CI does that a local /verify does not is the Release leg.

Every pull request gets one comment with its test results and coverage. It is edited in place on each push, and shows:

  • a result per suite (✅, ❌, or the number of tests that passed only on retry), with passed, failed, skipped, total and duration;
  • the failed tests by name;
  • backend and frontend line, branch and method coverage, against main's last green run, and the backend per assembly.

CI's last job, test report, builds it from the artifacts the other jobs uploaded, using pr-report.js. It reports and never blocks: it is not a required check. Backend coverage covers unit and integration tests together and leaves out generated code (the Wolverine adapters, EF migrations). The full HTML report is the coverage-backend artifact. To get the same numbers locally:

dotnet test --settings tests/coverage.runsettings --collect "XPlat Code Coverage" --results-directory TestResults
dotnet reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:TestResults/coverage
npm test --prefix frontend -- --run --coverage      # report in frontend/coverage/

Conventions

Non-negotiable, and mostly machine-checked:

  • Warnings are errors, from the compiler, the analyzers, and the build. Repository-wide exemptions live in .editorconfig; anything narrower is a local #pragma warning disable/restore pair with a justification comment above it.
  • Nullable is enabled. A missing null check does not compile.
  • required in Domain, never [Required]. DataAnnotations belong on Api DTOs.
  • Never catch (Exception). The global IExceptionHandler receives it as a parameter. throw;, never throw ex;. CA1031 is an error everywhere except the outbox's background pumps, which have no such parameter and must not die mid-loop.
  • No secrets in appsettings*.json. Use dotnet user-secrets or environment variables. A pre-edit hook refuses the write (not the commit — nothing here hooks git). The committed development connection string and the local Kubernetes overlay are deliberate, documented exceptions: throwaway credentials for localhost-only containers that are never deployed.
  • Never hand-edit an applied EF migration. Add a new one. The hook keys on git history, so a migration you have just generated and not yet committed is still yours to adjust, while one that is in HEAD is refused even if you delete the file first.
  • Handlers do not log their own outcome. Every command and query is already wrapped by a logging behavior that records outcome and duration, and it logs typeof(TRequest).Name rather than the request instance — SignIn, RegisterUser, and ChangePassword all carry a plaintext password field. See ADR 0015.
  • An external system's secrets are file paths, and nothing accepts any certificate. Certificates, passwords and client secrets are mounted files named in configuration; server trust is a per-system CA bundle through CertificateChainPolicy, never a validation callback. See ADR 0031.

Per-language detail lives in the dotnet-conventions, dotnet-testing, react-conventions, and react-testing skills; regenerate and jobs cover the two procedures most likely to be got wrong — which generated artifact to rebuild after a change, and the job framework's traps — and external-systems covers adding a partner. Each layer has a CLAUDE.md of its own.

Architecture decision records

All accepted, in docs/adr/.

# Decision
0001 Record architecture decisions
0002 Clean Architecture with a machine-enforced dependency rule
0003 In-process messaging without MediatR
0004 React over Angular
0005 Wolverine for the event path, on PostgreSQL, without a broker
0006 Username and password authentication, on a session cookie
0007 Orders belong to the user who placed them
0008 Brute-force protection: rate limiting and a self-expiring lockout
0009 Query caching: an opt-in pipeline behavior, scoped to the caller
0010 Running on Kubernetes: shared state across two replicas
0011 Session invalidation on a rotating security stamp
0012 End-to-end test architecture
0013 A global catalogue behind a caller-scoped cache
0014 Retry and resilience policies on the request path
0015 Centralized logging with MEL and OpenTelemetry
0016 Jobs run in a worker host, with lanes as queues (the transport superseded by 0026)
0017 Quartz.NET as the job clock
0018 Load balancing within the affinity constraint
0019 Realtime notifications over SignalR, with a Redis backplane
0020 An administrator role
0021 Operational telemetry in Postgres
0022 Configuration seeds the administrator list, rather than mirroring it
0023 The e2e stack starts the job worker
0024 Capability-named policies, and the operator ships
0025 The catalogue is managed by administrators
0026 RabbitMQ is the broker for all asynchronous work
0027 OpenTelemetry metrics beside operational telemetry
0028 The outbox is delivered only where the push transport lives
0029 Order exports are stored in Postgres and fail by staleness
0030 Order exports are PDFs rendered by MigraDoc
0031 Outbound integrations with external systems: mTLS, OAuth 2.0, one fixed handler chain
0032 External system status is published by the worker, not by a job

Record a new one with /adr <title>. Check the open branches as well as docs/adr/ before taking a number — two branches that each take "the next one" produce a duplicate, which has happened once already and needed a renumbering commit to undo.

Troubleshooting

dotnet ef cannot see user-secrets. Migrations run through DesignTimeDbContextFactory, which reads only the ConnectionStrings__Default environment variable, so a database update against anything but the default needs it passed explicitly. The reverse also bites: a user-secret sits above appsettings.Development.json in configuration order, so a stale value there wins at runtime while dotnet ef ignores it. Check with dotnet user-secrets list --project src/Api when the app and the migrations disagree about which database they are using.

'vite' is not recognized. frontend/node_modules is missing. Run npm install --prefix frontend, or just use scripts/dev.ps1, which does it for you.

Cannot find native binding from rolldown. A global ~/.npmrc pinning os= or cpu= to the wrong platform makes npm skip every platform-specific optional dependency silently. Remove the line, delete node_modules and package-lock.json, and reinstall. scripts/install-prereqs.ps1 flags this.

The browser drops the session cookie on Kubernetes. ASPNETCORE_ENVIRONMENT=Production sets CookieSecurePolicy.Always, so over plain HTTP login appears to succeed and every later request is a 401 with nothing in the logs. Use the HTTPS ingress.

kind create cluster fails to bind port 80. Something is holding it through http.sys, usually IIS or BranchCache. The cluster definition already publishes 8080/8443 instead; if you changed that back, either free the port from an elevated shell or restore the remap.

Certificate tests start failing on Windows after many runs. The .NET TLS stack on Windows copies each test PKI's intermediate certificate into Cert:\CurrentUser\CA, and at about a hundred same-named certificates chain building fails. The test PKI removes its own certificates when disposed, so this now grows by well under one per run. If it ever bites, delete the AiFramework … Intermediate CA and … Root CA test certificates from that store. Never delete anything else.

A TLS handshake fails with PartialChain. The server did not send its intermediate certificate and the client trusts only the root. Windows often hides this by finding the intermediate in its own store; Linux does not. For a partner, it is the partner's server configuration to fix.

A Vite proxy ECONNREFUSED for /api/... at startup. The frontend came up before the API finished starting. scripts/dev.ps1 pauses five seconds between the two for exactly this; reload once and it resolves.

Contributing

  1. Read CLAUDE.md and the CLAUDE.md of the layer you are touching.
  2. Scaffold with /feature <name>, /react-feature <name>, or /job <Name> so the layering and the tests come out right the first time.
  3. Keep the dependency rule intact. The hook will stop you on Windows — but it is a backstop, not a design tool, and on Linux or macOS it is not running at all.
  4. Regenerate and commit any affected generated code: the API contract after touching a controller, a DTO, or a [ProducesResponseType]; the Wolverine tree(s) after touching a handler.
  5. Run /verify before opening a pull request. CI runs the same checks plus the Release configuration.
  6. Record anything architecturally load-bearing with /adr <title>.

Issues and pull requests go to https://github.com/Surentz/AIFrameWork.

License

Apache License 2.0. The copyright line in LICENSE is still the unfilled template placeholder; set it before publishing this anywhere that matters.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages