{"owner":"fullstackhero","repo":"dotnet-starter-kit","hasSkills":true,"totalSkillsCount":41,"totalTokensCount":35363,"categories":["anthropic-skill","root-instruction","claude-rule"],"hasMcp":false,"mcpConfig":null,"found":[".agents/rules/api-conventions.md",".agents/rules/architecture.md",".agents/rules/buildingblocks-protection.md",".agents/rules/caching.md",".agents/rules/database.md",".agents/rules/eventing.md",".agents/rules/frontend/admin.md",".agents/rules/frontend/dashboard.md",".agents/rules/frontend/shared.md",".agents/rules/integration-testing.md",".agents/rules/jobs.md",".agents/rules/localization.md",".agents/rules/logging.md",".agents/rules/modules/auditing.md",".agents/rules/modules/billing.md",".agents/rules/modules/catalog.md",".agents/rules/modules/chat.md",".agents/rules/modules/files.md",".agents/rules/modules/identity.md",".agents/rules/modules/multitenancy.md",".agents/rules/modules/notifications.md",".agents/rules/modules/tickets.md",".agents/rules/modules/webhooks.md",".agents/rules/realtime.md",".agents/rules/resilience.md",".agents/rules/security.md",".agents/rules/storage.md",".agents/rules/testing.md",".agents/skills/add-entity/SKILL.md",".agents/skills/add-feature/SKILL.md",".agents/skills/add-full-slice/SKILL.md",".agents/skills/add-integration-event/SKILL.md",".agents/skills/add-module/SKILL.md",".agents/skills/add-permission/SKILL.md",".agents/skills/add-react-page/SKILL.md",".agents/skills/create-migration/SKILL.md",".agents/skills/mediator-reference/SKILL.md",".agents/skills/query-patterns/SKILL.md",".agents/skills/testing-guide/SKILL.md","AGENTS.md","CLAUDE.md"],"skills":{".agents/rules/api-conventions.md":"# API conventions\n\nRead before adding endpoints, commands/queries, validators, or error handling.\n\n## Endpoints\n\nStatic extension methods on `IEndpointRouteBuilder`, returning `RouteHandlerBuilder`. The handler delegates to Mediator. Gate with `.RequirePermission(...)`.\n\n```csharp\npublic static class RegisterUserEndpoint\n{\n    internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) =>\n        endpoints.MapPost(\"/register\", (RegisterUserCommand command,\n                IMediator mediator, CancellationToken cancellationToken) =>\n                mediator.Send(command, cancellationToken))\n            .WithName(\"RegisterUser\")\n            .WithSummary(\"Register user\")\n            .RequirePermission(IdentityPermissionConstants.Users.Create);\n}\n```\n\n- **Always accept and forward `CancellationToken`** to `mediator.Send`. ASP.NET injects it.\n- Wire each endpoint in the module's `MapEndpoints()`. Endpoints group under `api/v{version:apiVersion}/{module}`.\n- Use `TypedResults` / `.Produces<T>(...)` for accurate OpenAPI. Add `.WithIdempotency()` on POSTs that must be replay-safe.\n\n## CQRS\n\n- **Commands/Queries** live in `Modules.{Name}.Contracts` — implement `ICommand<TResponse>` / `IQuery<TResponse>`. Records preferred.\n- **Handlers** live in `Modules.{Name}/Features/` — `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on awaits.\n- Paginated queries implement `IPagedQuery` (`PageNumber`, `PageSize`, `Sort`) and return `PagedResponse<T>`.\n\n## Validation\n\nFluentValidation, auto-registered by `ModuleLoader`. Name `{Command}Validator`. Live in the same feature folder.\n\n- **Every command handler needs a validator; every paginated query handler needs one too.** Enforced by `Architecture.Tests` (`HandlerValidatorPairingTests`). A handler legitimately without rules can be added to that test's known-missing allowlist, but prefer writing the validator.\n- Validators run via the `ValidationBehavior<,>` Mediator pipeline before the handler.\n\n## Exceptions → ProblemDetails\n\nThrow framework exception types; the global handler converts to RFC 9457 `ProblemDetails`:\n\n| Throw | HTTP |\n|---|---|\n| `NotFoundException` | 404 |\n| `ForbiddenException` | 403 |\n| `UnauthorizedException` | 401 |\n| `CustomException(msg, errors?, HttpStatusCode)` | as specified (default 400) |\n\nDon't catch broadly to swallow. Background loops may `catch (Exception)` to stay alive, but must **log with context** and exclude `OperationCanceledException` (filtered catch or a preceding `catch (OperationCanceledException)`).\n\n## Permissions\n\nConstants in `Shared/Identity/*Permissions.cs` (e.g. `IdentityPermissionConstants`). Apply with `.RequirePermission(...)` on the endpoint. `RequiredPermissionAttribute` implements `IRequiredPermissionMetadata` — never let a duplicate of that interface appear; it silently disables **all** `.RequirePermission()` gates.\n\n## Specifications\n\nUse `Specification<T>` (`src/BuildingBlocks/Persistence/Specifications/`) for query composition. Default `AsNoTracking = true` — see `database.md` for when tracking is required instead.\n\n## Adding a feature (checklist)\n\n1. Command/query + response in `Modules.{Name}.Contracts/v1/{Area}/{Feature}/`.\n2. Handler in `Modules.{Name}/Features/v1/{Area}/{Feature}/`.\n3. Validator in the same folder.\n4. Endpoint in the same folder; wire in module `MapEndpoints()`.\n5. Tests in `Tests/{Name}.Tests/` (+ integration test if it touches DB/IO).\n",".agents/rules/architecture.md":"# Architecture rules\n\nModular Monolith + Vertical Slice Architecture (VSA). Read this before adding/moving modules or touching wiring.\n\n## Layers & dependency direction\n\n```\nHost (composition root)  →  Modules.{Name} (runtime)  →  Modules.{Name}.Contracts (public API)\n                         →  BuildingBlocks (shared framework)\n```\n\n- **BuildingBlocks** (`src/BuildingBlocks/`) — Core, Persistence, Web, Caching, Eventing, Storage, Quota, Jobs, Mailing, Shared. Consumed by all modules. **Do not modify without explicit approval.**\n- **Modules** (`src/Modules/{Name}/`) — bounded contexts. Each = a runtime project (internal) + a `.Contracts` project (public API: commands, queries, events, DTOs, service interfaces).\n- A module **MUST NOT** reference another module's runtime project — only its `.Contracts`. Enforced by `Architecture.Tests` (NetArchTest).\n\n## Module = runtime + Contracts\n\n```\nModules.Identity/            ← runtime (internal): handlers, services, domain, data\nModules.Identity.Contracts/  ← public: ICommand/IQuery types, DTOs, events, service interfaces\n```\n\nCross-module communication: through Contracts service interfaces or integration events only.\n\n## Feature folder layout (VSA)\n\nEach feature is a vertical slice in `Features/v{version}/{Area}/{Feature}/`:\n\n```\nFeatures/v1/Users/RegisterUser/\n├── RegisterUserEndpoint.cs          # minimal API endpoint\n├── RegisterUserCommandHandler.cs    # CQRS handler (public sealed)\n└── RegisterUserCommandValidator.cs  # FluentValidation\n```\n\nModule support folders: `Domain/`, `Data/`, `Services/`, `Events/`, `Authorization/`.\n\n## IModule registration\n\nEach module implements `IModule`, declared via an **assembly-level** `[FshModule]` attribute (positional `(Type moduleType, int order = 0)`) — **not** a class-level `[FshModule(Order = n)]`:\n\n```csharp\n[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 1)]   // above the namespace\n\nnamespace FSH.Modules.Identity;\n\npublic sealed class IdentityModule : IModule\n{\n    public void ConfigureServices(IHostApplicationBuilder builder) { ... }\n    public void ConfigureMiddleware(IApplicationBuilder app) { ... }   // optional, runs AFTER UseAuthentication\n    public void MapEndpoints(IEndpointRouteBuilder endpoints) { ... }\n}\n```\n\n`ModuleLoader.AddModules` (`src/BuildingBlocks/Web/Modules/ModuleLoader.cs`) discovers `[FshModule]` attributes, orders by `Order` then name, instantiates each, and calls `ConfigureServices`. Endpoints map under `api/v{version:apiVersion}/{module}`.\n\n## ⚠️ The four-place registration footgun\n\nAdding a module requires editing **four** lists. Miss one and it fails *silently*:\n\n| Place | File | Symptom if missed |\n|---|---|---|\n| Mediator `o.Assemblies` (two markers: Contracts type **and** module type) | `src/Host/FSH.Starter.Api/Program.cs` | Handlers silently undiscovered |\n| `moduleAssemblies` array | `src/Host/FSH.Starter.Api/Program.cs` | Module never loaded |\n| Mediator assemblies (same pair) | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |\n| module assemblies array | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |\n\nAfter wiring, the fastest sanity check is: build, hit the endpoint, confirm the handler runs.\n\n## DI & handler conventions\n\n- Mediator handlers: `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on every await. `ServiceLifetime.Scoped`.\n- Validators auto-register via `ModuleLoader` (`AddValidatorsFromAssemblies`). Name them `{Command}Validator`.\n- Prefer constructor injection / primary constructors. Watch DI lifetimes: stateful singletons must be thread-safe (use `ConcurrentDictionary` / immutable snapshots).\n\n## Middleware ordering (critical)\n\nIn `src/BuildingBlocks/Web/Extensions.cs` (`UseHeroPlatform`):\n\n1. ExceptionHandler → ResponseCompression\n2. **CORS before HTTPS redirect** (so OPTIONS preflight isn't 307-redirected)\n3. HttpsRedirection → SecurityHeaders → static files → Routing\n4. **`UseAuthentication`**\n5. **`UseHeroLocalization`** — request localization, sits **between `UseAuthentication` and `UseAuthorization`** so the user-`locale`-claim culture provider can read `HttpContext.User`\n6. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth\n7. RateLimiting → Quotas → `UseAuthorization` → `MapModules`\n\n`app.UseHeroMultiTenantDatabases()` (Finbuckle `UseMultiTenant()`) runs in `Program.cs` **before** `UseHeroPlatform`, i.e. **before `UseAuthentication`**. Finbuckle's `ClaimStrategy` authenticates on its own, so authenticated requests resolve by tenant claim and anonymous ones by the `tenant` header. See `modules/multitenancy.md`.\n\n## Static/global state\n\nNo global mutable static collections enumerated under concurrency. `Audit` (Auditing module) swaps an immutable `IAuditEnricher[]` atomically; `ModuleLoader` guards with a lock. Follow that pattern if you must hold process-global state.\n\n## Configuration & options\n\n- `appsettings.json` (+ `.Development`/`.Production`) live in `src/Host/FSH.Starter.Api/`. DbMigrator links the same files.\n- Bind config with the Options pattern: `AddOptions<T>().BindConfiguration(nameof(T))`, section name == type name (e.g. `JwtOptions`, `DatabaseOptions`, `CachingOptions`, `CorsOptions`, `QuotaOptions`, `RateLimitingOptions`; **storage section is `Storage`**, not `StorageOptions`). Add `.ValidateDataAnnotations().ValidateOnStart()` for fail-fast.\n- Validate critical options via `IValidatableObject` — `JwtOptions` requires `SigningKey` ≥32 chars and **rejects placeholder strings containing `\"replace-with\"`**; `DatabaseOptions` rejects empty connection strings.\n- **Production fail-fast** (`Program.cs`, before service registration): missing `DatabaseOptions:ConnectionString`, `CachingOptions:Redis`, or `JwtOptions:SigningKey` throws. Dev secrets via `dotnet user-secrets` (AppHost has a `UserSecretsId`); RustFS (S3) creds are Aspire secret parameters.\n- Platform composition is one call each: `builder.AddHeroPlatform(o => { o.Enable... })` (DI) and `app.UseHeroPlatform(...)` (middleware). Feature flags toggle Caching/Jobs/Mailing/Quotas/Sse/Realtime/OpenTelemetry/CORS/Idempotency.\n",".agents/rules/buildingblocks-protection.md":"---\npaths:\n  - \"src/BuildingBlocks/**/*\"\n---\n\n# ⚠️ BuildingBlocks Protection\n\n**STOP. You are modifying BuildingBlocks.**\n\nChanges to BuildingBlocks affect ALL modules across the entire framework. These are core abstractions that many projects depend on.\n\n## Before Proceeding\n\n1. **Confirm explicit approval** - Has the user specifically approved this change?\n2. **Consider alternatives** - Can this be done in the module instead?\n3. **Assess impact** - What modules will this affect?\n\n## If Approved\n\n- Make minimal, focused changes\n- Ensure backward compatibility\n- Update all affected modules\n- Run full test suite: `dotnet test src/FSH.Starter.slnx`\n- Document the change\n\n## Alternatives to Consider\n\n| Instead of... | Consider... |\n|---------------|-------------|\n| Modifying Core | Extension method in module |\n| Changing Persistence | Custom repository in module |\n| Updating Web | Module-specific middleware |\n\n## If Not Approved\n\nDo not proceed. Suggest alternatives that don't require BuildingBlocks modifications.\n",".agents/rules/caching.md":"# Caching\n\n`src/BuildingBlocks/Caching/`. Read before adding cached reads or invalidation.\n\n## What's registered\n\n`AddHeroCaching(config)` always registers **`HybridCache`** (L1 in-memory + optional L2 Redis). Inject `HybridCache`, not `IDistributedCache`.\n\n- `CachingOptions.Redis` empty → in-memory only (dev fallback). Set → a **single shared `ConnectionMultiplexer`** (singleton `IConnectionMultiplexer`) backs both the L2 cache and the DataProtection key ring.\n- Defaults: total expiration 1h, L1 (local) expiration 2min (`CachingOptions`).\n- `ObservableHybridCache` transparently decorates `HybridCache` to emit OpenTelemetry (hits/misses/factory-duration/invalidations). You don't reference it — just inject `HybridCache`.\n\n## Pattern\n\n```csharp\nvar perms = await cache.GetOrCreateAsync(\n    CacheKeys.UserPermissions(userId),\n    async ct => await LoadPermissionsAsync(userId, ct),\n    tags: [CacheKeys.Tags.Permissions, CacheKeys.Tags.User(userId)],\n    cancellationToken: ct);\n```\n\n- **Keys & tags live in `CacheKeys.cs`** — add new keys/tags there, don't inline strings. Existing: `UserPermissions(userId)`, `TenantTheme(tenantId)`, `IdempotencyEntry(tenantId,key)`, `ImpersonationGrantStatus(jti)`; tags `Permissions`, `Themes`, `Idempotency`, `Tenant(id)`, `User(id)`.\n- Invalidate with `RemoveAsync(key)` or `RemoveByTagAsync(tag)` in the relevant mutation handler.\n- `GetOrCreateAsync` gives **stampede protection** for free (factory runs once per key).\n\n## Gotchas\n\n- **No L1 backplane.** `RemoveByTagAsync` on one node does **not** evict L1 on peer nodes — cross-node staleness is bounded only by the 2-min local expiration. Don't rely on instant cross-node invalidation; keep local expiration short for hot, mutable data.\n- Don't reach for `IDistributedCache` directly except where the framework already does so deliberately (idempotency probe-read) — prefer `HybridCache`.\n",".agents/rules/database.md":"# Database & EF Core conventions\n\nRead before touching entities, DbContexts, migrations, or query filters.\n\n## Entities\n\n- `BaseEntity` — `Id`, `CreatedAt`, `UpdatedAt`, `TenantId`.\n- `AggregateRoot` — `BaseEntity` + domain events (`IHasDomainEvents`, `_domainEvents` list).\n- Marker interfaces: `IHasTenant`, `IAuditableEntity`, `ISoftDeletable`, `IGlobalEntity`.\n- Domain events inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Integration events implement `IIntegrationEvent`; handlers `IIntegrationEventHandler<T>`.\n\n## Tenant isolation (default-ON)\n\n- `BaseDbContext` auto-applies a tenant query filter to every entity. **Isolation is on by default.**\n- Opt out **only** via `IGlobalEntity` (e.g. `BillingPlan`, `ImpersonationGrant`, `Outbox`/`InboxMessage`).\n- A subclass DbContext that overrides `OnModelCreating` **must call `base.OnModelCreating(modelBuilder)` LAST**, or the auto-applied filters are lost.\n- Cross-tenant reads use `IgnoreQueryFilters()` **plus an explicit re-filter** — never rely on the absence of the filter.\n- **Query-filter naming:** SoftDelete filter is *named*; the tenant filter stays *anonymous* (Finbuckle owns it). Don't rename the tenant filter.\n\n## AsNoTracking — and when NOT to\n\n- Read-only queries: add `.AsNoTracking()` (Specifications default to it).\n- **Do NOT add `AsNoTracking()` to a read-then-mutate-then-`SaveChanges` query** — the entity must stay tracked or your changes won't persist. The analyzer (AP010) flags these as a smell, but for mutate-and-save flows it is a false positive — leave them tracked.\n- `AnyAsync(...)` materializes no entity, so `AsNoTracking()` there is a no-op — skip it.\n\n## Value generation for nav-collection children\n\nA child entity reached **only** through a parent's navigation collection needs `Property(x => x.Id).ValueGeneratedNever()` in its EF config — otherwise EF treats it as `Modified` instead of `Added` and the insert silently misbehaves.\n\n## Migrations\n\nAll migrations live in **one** project, `src/Host/FSH.Starter.Migrations.PostgreSQL`, organized **per-module by folder** (`Identity/`, `Catalog/`, `Chat/`, …), each with its own `{Module}DbContextModelSnapshot`.\n\n```bash\ndotnet ef migrations add {Name} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {Module}DbContext\n```\n\n- **`migrations remove` operates on the snapshot** — run a full build *before* `migrations add` so the snapshot is current, or you can lose the previous migration.\n- The DB is **not** migrated at API startup. The `DbMigrator` host is a separate step: `apply` (default), `seed`, `seed-demo` (dev only), `list-pending`; flags `--tenant <id>`, `--catalog-only`, `--seed`. It migrates the tenant catalog first, then each tenant's per-module schema, serialized by a Postgres advisory lock.\n- `dotnet-ef` is pinned in `.config/dotnet-tools.json` — run `dotnet tool restore` first.\n\n## Tests + EF\n\n- Integration tests use Testcontainers (real PostgreSQL) — **Docker must be running**.\n- In integration tests, set the Finbuckle tenant context **inline in the same method** as the `UserManager`/`DbContext` call; an awaited-helper set is lost (AsyncLocal) and the tenant query filter NREs.\n",".agents/rules/eventing.md":"# Eventing — domain events, integration events, Outbox/Inbox\n\nRead before publishing/handling cross-module events. `src/BuildingBlocks/Eventing/`.\n\n## Two tiers\n\n- **Domain events** (in-process, pre-commit) — inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Raised on aggregates (`IHasDomainEvents`).\n- **Integration events** (cross-module, async) — implement `IIntegrationEvent` (`Id`, `OccurredOnUtc`, `TenantId`, `CorrelationId`, `Source`). Handlers implement `IIntegrationEventHandler<T>` (single `HandleAsync(T, ct)`), are `sealed`, live in `Events/` or `IntegrationEventHandlers/`.\n\n## The Outbox is the only way to publish\n\n**Do not call `IEventBus` directly from a handler.** Publish via the outbox so the event commits with the business write and survives a crash:\n\n```csharp\nawait _outbox.AddAsync(integrationEvent, ct).ConfigureAwait(false);   // IOutboxWriter\n```\n\nInject **`IOutboxWriter`** (`FSH.Framework.Eventing.Abstractions`) — the publish-side contract, and all a module ever needs. `IOutboxStore` is the full dispatcher-side surface and lives in the eventing runtime, which modules don't reference.\n\n`EfCoreOutboxStore.AddAsync` serializes + `SaveChanges` immediately, joining the caller's transaction when there is one. `OutboxDispatcherHostedService` polls every `OutboxDispatchIntervalSeconds` (default 10), `OutboxDispatcher` **claims** a batch (`OutboxBatchSize`, default 100), publishes via `IEventBus`, and dead-letters after `OutboxMaxRetries` (default 5) → `IsDead`. Failures back off exponentially (`NextRetryAt`); `RedriveDeadLettersAsync` recovers dead rows. `OutboxMessage`/`InboxMessage` are `IGlobalEntity` (no tenant filter — the dispatcher has no tenant context; `TenantId` is an explicit column).\n\n**Publishing is asynchronous.** The consumer runs on the next dispatch cycle, not inside the request. Don't write a caller — or a test — that assumes the side effect already happened. Integration tests drain explicitly via `OutboxDrain.DrainAsync`.\n\n**The one documented exception:** Chat `SendMessageCommandHandler` still publishes mentions on the bus, because the Notifications handler pushes over SignalR and a delayed mention badge reads as broken. Adding a second exception needs the same kind of reason, in a comment at the call site.\n\n## One store, owned by the framework\n\n`OutboxMessages`/`InboxMessages` live in schema `framework`, owned by `EventingDbContext` (`src/BuildingBlocks/Eventing/Persistence/`) — **not** by any module's context. That is what keeps `IOutboxStore`/`IInboxStore` to a single, non-keyed DI registration: registering them per module DbContext made .NET DI resolve whichever module registered last for the whole application, so a second module publishing broke every module's outbox (issue #1349). `EventingRegistrationTests` guards the registration count; don't add a second one.\n\n`EventingDbContext` derives from `BaseDbContext`, so a tenant with a dedicated database gets its outbox rows in that database, next to the business data they accompany.\n\n## Dispatch across tenant databases\n\nBecause rows follow the tenant connection, the dispatcher can't just poll one database. Each cycle it asks `IEventingDrainTargetProvider` for the drain targets and runs one pass per target inside `IEventingDrainScope`, which installs the tenant context **before** the scope's `EventingDbContext` is built (it captures `TenantInfo`, and with it the connection string, at construction).\n\nDefaults in BuildingBlocks are single-database (`SingleDatabaseDrainTargetProvider`, `NullEventingDrainScope`); the multitenancy module replaces them with `TenantStoreDrainTargetProvider` (default DB + one target per distinct **active** per-tenant connection string; tenants sharing a database collapse to one target) and `FinbuckleEventingDrainScope`. One unreachable tenant database is logged and skipped, not fatal to the cycle.\n\n## Multi-instance safety\n\n`ClaimBatchAsync` leases rows with `FOR UPDATE SKIP LOCKED` in a single `UPDATE … RETURNING`, so several API instances partition a batch instead of all publishing the same message. `ClaimedUntilUtc` is an expiry (`OutboxClaimLeaseSeconds`, default 300), so a dispatcher that dies mid-batch has its rows recovered rather than stranded — raise it if a batch can take longer than the lease, or a second instance re-claims rows still in flight. Completing or failing a message releases the lease. Non-Postgres providers have no portable `SKIP LOCKED`: they fall back to an unclaimed read and log a warning that only one instance is safe.\n\n## Atomicity\n\n`IScopedDbConnectionProvider` gives every DbContext in a DI scope the same `DbConnection`, which is the only way EF Core can enlist a second context in a transaction another one opened (Npgsql has no distributed-transaction promotion). `AmbientDbTransactionRegistry` — an `IDbTransactionInterceptor` on every Hero context — records open transactions, since `DbConnection` can't be asked. `AddAsync` joins the ambient transaction when there is one, so the outbox row commits or rolls back with the business data; with none, it commits on its own exactly as before.\n\n## Idempotency is free (in-memory bus)\n\n`InMemoryEventBus` resolves handlers in a fresh DI scope and applies the **Inbox**: skips if `IInboxStore.HasProcessedAsync(eventId, handlerName)`, marks processed after success. Composite key `{Id, HandlerName}`; concurrent-insert race is swallowed. Don't hand-roll dedup.\n\n## Wiring\n\nThe **host** bootstraps eventing once (`FSH.Starter.Api/Program.cs` and `FSH.Starter.DbMigrator/Program.cs`, before `AddModules` so `EventingDbInitializer` migrates the `framework` schema first):\n\n```csharp\nbuilder.Services.AddEventingCore(builder.Configuration);   // serializer + bus + dispatcher + EventingDbContext + stores\n```\n\nA **module** only registers its handlers:\n\n```csharp\nservices.AddIntegrationEventHandlers(typeof(MyModule).Assembly);        // scans IIntegrationEventHandler<>\n```\n\nThere is no per-module store registration — `AddEventingForDbContext<T>` was removed in #1349. A module publishes by injecting `IOutboxWriter`; nothing else is needed.\n\nBus = `EventingOptions.Provider`: `\"RabbitMQ\"` → `RabbitMqEventBus` (durable topic exchange); else `InMemoryEventBus` (default).\n\n## Gotchas\n\n- **Renaming/moving an integration event type breaks deserialization** — the outbox stores the assembly-qualified type name; `Type.GetType()` returns null → the message dead-letters. Keep event type names/namespaces stable, or migrate dead rows.\n- **Background handlers carry no HTTP/tenant context.** An open-generic or background handler that reads a tenant-filtered DbContext must restore Finbuckle context first via `IMultiTenantContextSetter` (see `WebhookFanoutHandler`, `modules/webhooks.md`).\n- In-memory bus runs handlers **synchronously in the publisher's scope** — keep handler work minimal; exceptions surface to the originating request (relevant for Notifications consuming Chat events). Via the outbox that scope is the dispatcher's, not the request's.\n- Set `UseHostedServiceDispatcher=false` to drive the outbox via Hangfire instead of the hosted service.\n- A background publisher must set the tenant context **before** `AddAsync` — otherwise, with per-tenant databases, the row lands in the wrong one (see `TenantExpiryScanJob`).\n",".agents/rules/frontend/admin.md":"# Frontend — admin app (`clients/admin`)\n\nOperator/SuperAdmin-facing console. Read `frontend/shared.md` first; this file is only the divergences.\n\n- **Port** 5173 · dev proxy target `http://localhost:5030` (HTTP) · localStorage prefix `fsh.admin.*` · login header `X-FSH-App: admin`.\n- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, dashboardUrl }`. `dashboardUrl` is used for the one-way impersonation handoff into the dashboard app.\n\n## Forms — react-hook-form + zod\n\nAdmin is the **only** app with `react-hook-form` + `zod` + `@hookform/resolvers`. Use them:\n\n```ts\nconst form = useForm<Schema>({ resolver: zodResolver(schema) });\n```\n\nForm layout primitives live in `src/components/list/` (`PageHeader`, `Field`, `FormShell`, `FormSection`, `FormActions`, `Pagination`, `ErrorBand`).\n\n## Permissions — fetched, hydrated, gated\n\n- The JWT carries only role names. Admin fetches the permission set separately: `GET /api/v1/identity/permissions` (`getMyPermissions`), cached under `fsh.admin.permissions`.\n- `AuthProvider` hydrates them in an effect keyed on subject change and exposes `permissionsHydrated` to avoid a 403 flash on first paint.\n- **Route gating:** wrap gated route elements in `<RouteGuard perms={[IdentityPermissions.Users.View]}>…</RouteGuard>`. It renders a \"Resolving permissions\" state while `!permissionsHydrated`, else `<ForbiddenView missing={…}/>`. (`ProtectedRoute` also accepts a `permissions?` prop.)\n- **Mirror server permissions by hand** in `src/lib/permissions.ts` (`IdentityPermissions`, `MultitenancyPermissions`, … frozen objects + `PERMISSION_CATALOG` driving the role editor). There is intentionally **no** runtime catalog fetch — when the server adds a permission, mirror the constant here.\n\n## Routing & realtime\n\n- Routes wrap elements in `<RouteGuard perms={…}>` (no per-route Suspense wrapper).\n- `RealtimeProvider` is mounted in `App.tsx` and wires only `[\"NotificationCreated\"]`.\n\n## Theme\n\nCool-cast neutrals (hue 240, small non-zero chroma — **not** chroma 0), a single fixed chartreuse \"signal\" accent (`--accent-signal` / `--signal-500`), Geist / Geist Mono fonts. Defined in `src/styles/globals.css`.\n\n## Add-a-page deltas (on top of shared steps)\n\n- Use RHF + zod for any form.\n- If the endpoint requires a permission, mirror the constant in `src/lib/permissions.ts` (and `PERMISSION_CATALOG` if it belongs in the role editor) and wrap the route in `<RouteGuard perms={[…]}>`.\n- Playwright: `seedAuthedSession` here also pre-seeds `fsh.admin.permissions` so `RouteGuard` passes on first paint.\n",".agents/rules/frontend/dashboard.md":"# Frontend — dashboard app (`clients/dashboard`)\n\nTenant-facing application. Read `frontend/shared.md` first; this file is only the divergences.\n\n- **Port** 5174 · dev proxy target `https://localhost:7030` (HTTPS, with `ws: true` for the SignalR hub) · localStorage prefix `fsh.dashboard.*` · login header `X-FSH-App: dashboard`.\n- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, demoMode }`.\n- Dev-proxy is HTTPS on purpose: routing the bearer token through an HTTP→HTTPS 307 redirect stripped the `Authorization` header.\n\n## No RHF/zod — hand-rolled forms\n\nThe dashboard does **not** depend on react-hook-form or zod. Use controlled inputs + local state. Don't add those deps to match admin.\n\n## Permissions — fetched, not in the JWT\n\nThe JWT carries **only role names**. `auth-context.tsx` fetches the effective permission list from `GET /api/v1/identity/permissions` (`getMyPermissions()` in `src/api/identity.ts`), caches it in `tokenStore` under `fsh.dashboard.permissions`, and exposes a `permissionsHydrated` flag so gated UI doesn't flash while the fetch is in flight (re-fetched on login/impersonation swaps; `refreshPermissions()` for role changes). Nav items are gated via `perm`/`anyPerm` in `src/components/layout/nav-data.ts`. `ProtectedRoute` is still **auth-only** (no per-route permission gating) — don't add `RouteGuard`-style gating here.\n\n## Routing & realtime/SSE\n\n- Every route element is wrapped in `withSuspense(node)` (per-route skeleton fallback). No per-route permission guards.\n- `RealtimeProvider` **and** `SseProvider` are mounted inside `AppShell` (authenticated routes only), under a `CommandPaletteProvider` (cmdk).\n- SignalR provider pre-wires ~11 chat/notification events.\n- **SSE** (`src/sse/`, dashboard-only): two-step token — `POST /api/v1/sse/token`, then `GET /api/v1/sse/stream?token=<guid>` consumed via fetch streaming (`parseSseStream` async generator; EventSource can't send auth headers). **Two split contexts:** `useSseStatus()` (stable, for status dots) vs `useSseEvents()` (mutates per event) to avoid cascading re-renders; `useSse()` is the composite.\n\n## Impersonation\n\n`token-store.ts` has `beginImpersonation` / `endImpersonationWithFreshTokens` / `restoreStashedActor` that stash the operator's tokens under `fsh.dashboard.impersonation.*`. `AuthProvider` exposes `beginImpersonation`/`stopImpersonation` and derives `ImpersonationInfo` from `act_sub` / `act_tenant` / `act_name` claims. Admin triggers the handoff one-way via its `dashboardUrl`.\n\n## Performance\n\n- `@tanstack/react-virtual` for long lists — use it for any large collection (chat history, big tables).\n- `cmdk` powers the command palette.\n\n## Theme\n\n**Chroma-0 neutrals** (`--neutral-*: oklch(L 0 0)` — untinted; the warm-paper tint was deliberately removed). Rose default brand with **swappable accent themes** via `.accent-{rose,indigo,violet,sky,emerald,amber}` classes that override the `--brand-*` oklch stops; saffron secondary; Figtree font. Defined in `src/styles/globals.css`. Keep neutrals at chroma 0.\n\n## Add-a-page deltas (on top of shared steps)\n\n- Hand-roll forms (no RHF/zod).\n- Wrap the route element in `withSuspense(<X/>)`; no permission guard.\n- If it consumes pushes: `useRealtimeEvent(\"EventName\", handler)` (register the name in `realtime-context.tsx`) or `useSseEvents()` for SSE.\n- Use `react-virtual` for long lists; keep neutrals chroma 0.\n",".agents/rules/frontend/shared.md":"# Frontend — shared conventions\n\nApplies to **both** `clients/admin` and `clients/dashboard`. Read this for any React work, then read\nthe app-specific file (`admin.md` / `dashboard.md`) for divergences.\n\nStack: React 19 · Vite 7 · TypeScript · TanStack Query v5 · React Router 7 · Radix UI · Tailwind v4 ·\nclass-variance-authority (shadcn-style) · `@microsoft/signalr`. Path alias `@` → `src` (`vite.config.ts`).\n\n## API client (`src/lib/api-client.ts`, `src/api/*`)\n\n- One fetch wrapper: `apiFetch<T>(path, init)`. No axios.\n- **Types are hand-written**, not generated. (`openapi-typescript` is declared in admin's devDeps but unused — there is no codegen step.) Define DTO `type`s and a `const BASE = \"/api/v1/...\"` in each `src/api/{feature}.ts`, with thin async functions calling `apiFetch`.\n- Auth header `Authorization: Bearer <access>` from `tokenStore.getAccessToken()` (unless `skipAuth:true`).\n- Tenant header (lowercase `tenant`) = `tokenStore.getTenant() ?? env.defaultTenant`, unless overridden per request.\n- Errors: non-OK parses RFC 9457 `application/problem+json` and throws `ApiRequestError(status, message, problem)`. 204/empty → `undefined`.\n- **Single-flight refresh:** on 401 with a refresh token, `POST /api/v1/identity/token/refresh` with `{token, refreshToken}`; a module-level `refreshPromise` dedupes concurrent refreshes; rotated token returns on `token` (not `accessToken`); the original request retries once.\n- Search endpoints: build `URLSearchParams` with **PascalCase** keys (`PageNumber`, `PageSize`, …) to match the API.\n\n## Env (`src/env.ts`) — runtime, not build-time\n\n`loadRuntimeConfig()` fetches `/config.json` once at boot (awaited in `main.tsx` before React mounts); `env` is a getter that throws if read too early. One built image promotes across environments (operator writes `config.json`). The only `VITE_*` var, `VITE_API_BASE_URL`, configures the **Vite dev proxy target only** — it is not the runtime apiBase (`config.json` ships `apiBase: \"\"`, relative).\n\n## Data fetching (TanStack Query v5)\n\n- Shared `queryClient` (`src/lib/query-client.ts`): `staleTime: 30_000`, `refetchOnWindowFocus:false`, no retry on 401/403 else `failureCount < 2`.\n- **Query keys are inline literal arrays**, hierarchical, params object last: `[\"users\", {pageNumber, searchTerm}]`, `[\"user\", id]`, `[\"user\", id, \"roles\"]`. No central key factory.\n- `useQuery`/`useMutation` live inline in page components. Invalidate in `onSuccess`: `queryClient.invalidateQueries({ queryKey: [\"users\"] })`. Pagination: `placeholderData: keepPreviousData`.\n\n### ⚠️ The `mutate(arg)` race-safe pattern (golden rule #9)\n\n`useMutation` reads its options at execute time, so values produced at call time (e.g. a fresh\n`crypto.randomUUID()` client id) must ride **through `mutate(arg)`** and be read from the `variables`\nargument of `onMutate`/`onSuccess`/`onError` — never from component state the callbacks close over,\nor two rapid calls collide.\n\n```ts\nmutation.mutate({ text, clientId: crypto.randomUUID() });\n// onMutate: ({ clientId }) => insert optimistic `temp:${clientId}`\n// onSuccess: (real, { clientId }) => swap temp → real\n// onError:   (_e, { clientId }) => rollback\n```\n\n## Routing (`routes.tsx`, `App.tsx`)\n\n- `createBrowserRouter`, flat config. Pages are **named exports** loaded via a `lazyNamed(importer, name)` helper (adapts named → `React.lazy`'s default contract). No default exports.\n- Nesting: public auth routes → `<ProtectedRoute/>` → `<AppShell/>` → page children. `errorElement: <RouteError/>`.\n- Provider tree: `ThemeProvider > QueryClientProvider > AuthProvider > … > RouterProvider` + `sonner` `<Toaster/>`.\n\n## Auth (`src/auth/`)\n\n`token-store.ts` (localStorage + pub/sub), `jwt.ts` (`decodeJwt`), `AuthProvider`/`useAuth()`, `ProtectedRoute`.\nLogin `POST /api/v1/identity/token/issue` with header `X-FSH-App: \"admin\"|\"dashboard\"`. **localStorage keys are namespaced per app** (`fsh.admin.*` / `fsh.dashboard.*`) so both run side-by-side. Permission *source* differs per app — see the app files.\n\n## Design system (Tailwind v4, shadcn-style)\n\n- **`cn()` is at `src/lib/cn.ts`** (`twMerge(clsx(...))`) — not `lib/utils.ts`. `components.json`: `style:new-york`, `baseColor:slate`, `cssVariables:true`, `iconLibrary:lucide`.\n- UI primitives in `src/components/ui/` are cva-based: `cva(base, { variants, defaultVariants })` + Radix `Slot`/`asChild` + `cn(buttonVariants({...}))`. Layout primitives in `src/components/list/` (admin) / similar (dashboard), re-exported from `index.ts`.\n- **Tailwind v4 is CSS-first — there is NO `tailwind.config`.** Configured via the `@tailwindcss/vite` plugin and one entrypoint `src/styles/globals.css` (imported in `main.tsx`). Tokens: `:root` oklch primitives → semantic vars → an `@theme inline { --color-*: var(--…) }` block exposing them as utilities. `@custom-variant dark (&:is(.dark *))`.\n- Add a new token in `globals.css` (primitive → semantic → `@theme inline`), then use the utility. Don't hard-code colors in components.\n\n### Design language (both apps differ on purpose)\n\n| | admin (operator) | dashboard (tenant) |\n|---|---|---|\n| Neutrals | cool-cast, hue 240, small non-zero chroma | **chroma 0** (untinted — the warm tint was removed) |\n| Accent | single fixed chartreuse \"signal\" (`--accent-signal`) | rose brand + **swappable** `.accent-{rose,indigo,violet,sky,emerald,amber}` |\n| Font | Geist / Geist Mono | Figtree (+ saffron secondary) |\n\nSo \"neutrals must be chroma 0\" is a **dashboard** rule. Admin neutrals are intentionally cool — match the file you're editing.\n\n## Realtime (SignalR)\n\n`src/realtime/realtime-context.tsx`: one `HubConnection` to `/api/v1/realtime/hub`. `@microsoft/signalr` is **dynamically imported** (lazy ~37KB) only when an authed session opens the hub. Auth via `accessTokenFactory`; a `tokenEpoch` (bumped by `tokenStore.subscribe`) forces reconnect on login/refresh/impersonation. Consume with `useRealtimeEvent(\"EventName\", handler, deps)` (handler kept in a ref). Pre-register new event names in the provider's event list.\n\n## Testing (Playwright, route-mocked)\n\n- `playwright.config.ts`: `testDir: ./tests`, chromium, auto-boots `npm run dev`, no real backend.\n- Tests in `tests/{area}/{name}.spec.ts`; helpers in `tests/helpers/`.\n- **JWT seeding:** `seedAuthedSession(page, TEST_USER)` builds a fake JWT and `addInitScript`-writes `fsh.{app}.*` to localStorage before React boots (server isn't called, so signature is junk).\n- **Route mocking:** `mockJsonResponse(page, urlGlob, body)` / `mockProblemDetails(...)`. `installShellMocks(page)` stubs every call `AppShell` fires and **aborts** SSE/SignalR. Playwright matches most-recently-registered first → broad shell mocks in `beforeEach`, page-specific mocks after (they win).\n- `beforeEach`: `seedAuthedSession(page, TEST_USER)` → `installShellMocks(page)`.\n\n## Add a page/feature (shared steps)\n\n1. API: extend `src/api/{feature}.ts` — hand-written types + `apiFetch` calls.\n2. Page: `src/pages/{area}/{name}.tsx`, **named** export. `useQuery` with hierarchical key; `useMutation` invalidating in `onSuccess`, passing per-call data via `mutate(arg)`.\n3. Route: add `const X = lazyNamed(() => import(\"@/pages/area/name\"), \"XPage\")` and a child route under `AppShell`.\n4. Test: `tests/{area}/{name}.spec.ts` with seed + shell mocks + page mocks.\n\nThen apply the app-specific steps in `admin.md` / `dashboard.md` (forms, permission gating, suspense, etc.).\n",".agents/rules/integration-testing.md":"# Integration testing\n\n`src/Tests/Integration.Tests/` + `Integration.Middleware.Tests/`. Read before writing tests that touch the DB/HTTP pipeline. See `testing.md` for unit conventions.\n\n## Harness\n\n`WebApplicationFactory` over **real** infra via Testcontainers — PostgreSQL + Redis + RustFS (S3-compatible, generic container). **Docker must be running**; if it isn't, tests fail fast with `DockerUnavailableException` (environmental, not a regression — run the unit projects instead).\n\n`FshWebApplicationFactory` (`Integration.Tests/Infrastructure/`) boots the containers, overlays in-memory config, swaps `IMailService` → `NoOpMailService`, and rewires storage to RustFS.\n\n## Must-know gotchas\n\n- **Tenant context is AsyncLocal — set it inline.** Set the Finbuckle tenant context **in the same method** as the `UserManager`/`DbContext` call. Setting it in an awaited helper loses it across the async boundary → NRE in the tenant query filter.\n- **Storage is wired eagerly.** `AddHeroStorage` reads `Storage:Provider` before the test config overlay, so it picks `LocalStorageService`. The factory **removes the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-registers the S3 stack** at RustFS. Follow that when a test needs real object storage. (See `storage.md`.)\n- **SignalR tests force long-polling** — TestServer has no WebSocket. Configure the client transport accordingly.\n- **Rate limiting is read eagerly** — `Integration.Middleware.Tests` sets `RateLimitingOptions:Enabled` via env var **before** host build, since flipping it after has no effect.\n\n## Coverage\n\n```bash\ndotnet test --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\nCobertura; includes `[FSH.Modules.*]` + `[FSH.Framework.*]`; excludes tests, the Migrations project, and `*HostedService`.\n",".agents/rules/jobs.md":"# Background jobs (Hangfire)\n\n`src/BuildingBlocks/Jobs/`. Read before enqueuing or scheduling work.\n\n## Fire-and-forget / scheduled — `IJobService`\n\nInject `IJobService` (`Jobs/Services/IJobService.cs`) and use it; don't call Hangfire's `BackgroundJob` directly in feature code.\n\n```csharp\njobService.Enqueue(() => mailService.SendAsync(req, CancellationToken.None));   // default queue\njobService.Enqueue(\"email\", () => mailService.SendAsync(req, CancellationToken.None));\njobService.Schedule(() => DoLater(), TimeSpan.FromMinutes(5));\n```\n\nQueues: `default`, `email` (5 workers, 30s poll). Storage auto-selected from `DatabaseOptions.Provider` (Postgres/MSSQL).\n\n## Recurring jobs — `IRecurringJobManager`\n\n`IJobService` has **no** recurring API. Register recurring jobs in the module's `MapEndpoints` with `IRecurringJobManager.AddOrUpdate<T>(...)`, always `TimeZoneInfo.Utc`:\n\n```csharp\nrecurringJobs.AddOrUpdate<PurgeOrphanedFilesJob>(\"files:purge-orphaned\",\n    j => j.RunAsync(CancellationToken.None), Cron.Hourly(), new() { TimeZone = TimeZoneInfo.Utc });\n```\n\nExamples in the tree: `PurgeOrphanedFiles`/`PurgeDeletedFiles` (Files), `MonthlyInvoiceJob` (Billing), `AuditRetentionJob` (Auditing), `WebhookDispatchJob` (Webhooks).\n\n## Dashboard & config\n\n`/jobs` (default), behind `HangfireOptions.UserName`/`Password` basic auth — both `[Required]`, password `[MinLength(12)]`, so **startup fails in non-dev if unset**.\n\n## Gotchas\n\n- Jobs run on the server with **no HTTP/tenant context** — restore Finbuckle tenant context inside the job (fresh scope + `IMultiTenantContextSetter`) before touching a tenant-filtered DbContext.\n- The DbMigrator registers `NoOpJobService` whose methods **throw** — surfaces any accidental enqueue during migration. Don't enqueue from migration/seed paths.\n- A job class is a normal DI-resolved type (scope-per-job via `FshJobActivator`); inject what you need.\n",".agents/rules/localization.md":"# Localization (i18n)\n\n`src/BuildingBlocks/Core/Localization/` + per-module `Localization/` folders. Read before adding any user-facing message (exception, validation, API error). **The client's culture decides which words the client reads; it never decides how the API formats numbers or dates.**\n\n## Culture negotiation (already wired — don't re-add)\n\n`AddHeroLocalization()` / `UseHeroLocalization()` (`BuildingBlocks/Web/Localization/`) negotiate the request **UI** culture in this order: `?culture=` query → `locale` JWT claim (`UserLocaleRequestCultureProvider`) → `Accept-Language` → configured default → `en-US`. Supported tags live in `SupportedCultures.Tags`. The culture is set before endpoints and the exception handler run, so any `IStringLocalizer` resolved downstream picks up the request culture automatically.\n\n**`CultureInfo.CurrentUICulture` only — `CurrentCulture` stays invariant.** An API that emits JSON must not shift `ToString()`, `Parse()` or interpolation per request; both React apps format at the presentation layer. `RequestLocalizationMiddleware` assigns both cultures unconditionally, so the culture half is pinned rather than left alone: `DefaultRequestCulture` carries `(InvariantCulture, configured default)` and `SupportedCultures` is `null` so the middleware skips culture filtering. Do **not** \"fix\" this by adding `AddSupportedCultures(...)`; `Formatting_culture_stays_invariant_while_ui_culture_negotiates` fails if you do.\n\n`SupportedCultures.Tags` is **specific tags only**, no neutrals. A request asking for a bare `pt`, or for an unsupported variant like `pt-PT`, resolves to the configured default rather than being served a language it was not translated into. The React apps canonicalize variants onto supported tags (`CANON` in `clients/*/src/i18n.ts`) before calling the API, so app traffic is unaffected; a hand-rolled client sending bare `pt` gets the default. Adding a language means: add its specific tag to `Tags`, add a `*.{tag}.resx` per catalog, add its JSON catalogs to both apps, and remove it from `CANON` if it was being folded into another tag.\n\n## Catalogs — hybrid, one marker per catalog\n\n- **Core (`SharedResources`)** — generic / cross-cutting messages: ProblemDetails titles (`Error.*`), cross-module errors (`Error.TenantContextRequired`, `Error.NoCurrentUser`, …), and shared validation (`Validation.*`).\n- **Per module (`<Module>Resources`)** — domain-specific messages owned by the module: `src/Modules/<Module>/Modules.<Module>/Localization/<Module>Resources.cs` (marker `public sealed class <Module>Resources;`) + co-located `<Module>Resources.resx` (neutral / en-US) + an optional `<Module>Resources.pt-BR.resx`. `ResourcesPath = \"\"` (co-located), so the resx manifest name must equal the marker's full type name.\n\nCatalogs are named for **specific** cultures (`.pt-BR`, never a neutral `.pt`), matching the front-end catalog folders. The neutral, un-suffixed `.resx` is the en-US / ultimate-fallback catalog.\n\nKey naming: `Error.<Module>.<Case>` for domain messages (`Catalog.ProductNotFound`), `Error.<CrossCutting>` / `Validation.<Case>` for Core. PascalCase. Placeholders are `{0}`, `{1}` (`string.Format` via the localizer) — **not** the frontend's `{{name}}`.\n\n**Placeholder arguments must be culture-insensitive.** The localizer's indexer calls `string.Format` under `CurrentCulture`, which is invariant (above). Pass `int`/`long`/`string`/enum — never a `double`, `decimal`, `DateTime` or `TimeSpan.TotalX`, which would render with an invariant separator instead of the reader's. Where a count is conceptually whole, expose it as an `int` at the source rather than converting at the call site (see `GetAuditsQueryHandler.MaxWindowDays` next to `MaxWindow`). Money and dates belong in structured response fields formatted by the client, not interpolated into a message.\n\n## Exceptions — localize at the boundary, log stays English\n\nThrow with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site.\n\n```csharp\n// domain message -> module catalog\nthrow new NotFoundException($\"Product {id} not found.\")\n{\n    MessageKey = \"Catalog.ProductNotFound\",\n    MessageArgs = [id],\n    ResourceSource = typeof(CatalogResources),\n};\n\n// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources)\nthrow new UnauthorizedException(\"Tenant context is required.\")\n{\n    MessageKey = \"Error.TenantContextRequired\",\n};\n```\n\n`GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException(\"...\")` still renders its English literal.\n\n**Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body.\n\n## Validators — inject the localizer, defer resolution\n\n```csharp\npublic sealed class XCommandValidator : AbstractValidator<XCommand>\n{\n    public XCommandValidator(IStringLocalizer<SharedResources> localizer)\n    {\n        RuleFor(x => x.Name)\n            .NotEmpty().WithMessage(_ => localizer[\"Validation.NameRequired\"]);\n    }\n}\n```\n\nAlways the `.WithMessage(_ => localizer[\"Key\"])` lambda (resolution is deferred to `Validate()`, under the request culture) — never `.WithMessage(localizer[\"Key\"])`. **Catalog choice:** inject `IStringLocalizer<SharedResources>` for genuinely shared/generic validation (`Validation.*` already in Core, reuse them), or `IStringLocalizer<<Module>Resources>` for module-specific validation messages kept in the module's own catalog. DI provides the localizer automatically (`AddValidatorsFromAssembly` + `AddHeroLocalization` + the module's own `AddLocalization`); nested validators (`Include(new PagedQueryValidator<T>(localizer))`) receive it from the parent.\n\n## Known behaviour (documented, not bugs)\n\n- **The `locale` claim lags a language switch by one token.** The culture provider reads the JWT `locale` claim, so a switch does not reach the API until the next token issue. The front-end persists the choice to the profile and re-mints, so it converges; in the window between, the shell can be in the new language while an API error still comes back in the old one. Deliberate: the alternative is a per-request DB read on every authenticated call.\n- **Impersonation carries the operator's language, not the target's.** `StartImpersonationCommandHandler` strips the target's `locale` claim so the operator keeps reading in their own language, and the cross-app handoff URL carries `locale` because the dashboard is normally on a different origin and cannot read admin's `fsh.admin.lng`. During impersonation the switcher is client-side only — it must not PUT onto the impersonated user's profile.\n- **SignalR does not carry the app locale.** The hub client builds its own requests instead of going through `apiFetch`, so `Accept-Language` on the negotiate is the browser's. Applies to every session. `handoff-locale.spec.ts` names the exception explicitly so any *other* channel that stops carrying the locale fails the test.\n\n## Tests (required with every catalog change)\n\n- **Parity** — add every new key to the neutral (English) catalog; the `pt-BR` entry is optional, and a key it lacks falls back to English at runtime, so nobody has to write Portuguese to get a green build. The build fails only on a `pt-BR` key the neutral catalog does not have (an orphan, usually a renamed or deleted key) or on a `pt-BR` value whose `{n}` placeholders differ from the English one. Applies to Core and every `<Module>Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test.\n- **Code → resx guard** — every referenced key (`MessageKey`, `localizer[\"…\"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently).\n- Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub.\n\n## Emails / background handlers\n\nIntegration-event handlers run without an HTTP request, so there is no negotiated culture. Localizing outbound emails needs the recipient's stored locale propagated to the handler — **not yet implemented** (tracked for a future PR); email bodies stay English for now.\n",".agents/rules/logging.md":"# Logging & observability\n\n`src/BuildingBlocks/Web/Observability/`. Read before adding logs, traces, or metrics.\n\n## Structured logging only\n\n**No string interpolation in log messages.** Use message templates with named placeholders, or `[LoggerMessage]` source-gen for hot paths.\n\n```csharp\n// good\n_logger.LogInformation(\"Cleaned up {Count} expired sessions for tenant {TenantId}\", count, tenantId);\n// also good (hot path) — see OutboxDispatcher, InMemoryEventBus, AppHub\n[LoggerMessage(Level = LogLevel.Warning, Message = \"Outbox message {MessageId} dead-lettered\")]\nprivate partial void LogDeadLettered(Guid messageId);\n// NEVER\n_logger.LogInformation($\"Cleaned up {count} sessions\");   // breaks structured logging + analyzers\n```\n\nBuild runs with `TreatWarningsAsErrors` — interpolated log calls won't even compile clean under analysis.\n\n## Serilog\n\n`AddHeroLogging()` reads the `Serilog` config section (Console sink by default), attaches `HttpRequestContextEnricher` (adds `RequestMethod`/`RequestPath`/`UserAgent` + `UserId`/`Tenant`/`UserEmail` when authenticated), overrides Microsoft/EF/Hangfire/Finbuckle to higher levels, and excludes the `ExceptionHandlerMiddleware` source (the global handler logs exceptions itself — don't double-log).\n\n## Correlation\n\n`X-Correlation-ID` request header (falls back to `HttpContext.TraceIdentifier`), surfaced in every ProblemDetails and pushed to the Serilog `LogContext`. `CurrentUserMiddleware` tags the current `Activity` with `fsh.user_id` / `fsh.tenant_id` / `fsh.correlation_id`.\n\n## OpenTelemetry\n\n`AddHeroOpenTelemetry()` no-ops unless `OpenTelemetryOptions.Enabled`. Metrics + traces for AspNetCore/HttpClient/Npgsql/EFCore/Redis/Runtime, plus caching + auditing meters and Mediator pipeline spans (`MediatorTracingBehavior`). Add a new meter/source name to `OpenTelemetryOptions` config, not by editing the extension.\n\n**OTLP export is auto-detected.** It turns on when **either** `Exporter.Otlp.Enabled=true` **or** the `OTEL_EXPORTER_OTLP_ENDPOINT` env var is present. Under .NET Aspire that env var is injected automatically, so traces/metrics flow to the Aspire dashboard with no config change (the SDK reads endpoint + protocol from the standard `OTEL_EXPORTER_OTLP_*` env vars; the config `Endpoint`/`Protocol` are only used when no env var is set — e.g. the docker-compose collector at `http://localhost:4317`). Plain `dotnet run` with no collector and `Enabled=false` exports nothing.\n\n**Logs ride the same OTLP detection.** Serilog owns the logging pipeline and does not forward to other `ILogger` providers, so the OTel SDK's log exporter can't see Serilog events — instead `AddHeroLogging` adds a `Serilog.Sinks.OpenTelemetry` sink under the same auto-detect rule (global `Enabled` + env-var-or-config endpoint). It also parses `OTEL_EXPORTER_OTLP_HEADERS` (the Aspire dashboard's OTLP receiver requires its `x-otlp-api-key`; the SDK reads this for traces/metrics, the Serilog sink does not) and stamps `service.name` = `OTEL_SERVICE_NAME ?? ApplicationName` so logs group under the same dashboard resource as the spans. Both `AddHeroOpenTelemetry` and the Serilog sink resolve the name that way: under an orchestrator that injects `OTEL_SERVICE_NAME` (Aspire sets it to the resource name, e.g. `fsh-starter-api`) the process adopts that identity; hardcoding the entry-assembly name (`FSH.Starter.Api`) instead would de-correlate the telemetry and list the process twice in the dashboard. Plain `dotnet run` with no env var falls back to `ApplicationName`.\n",".agents/rules/modules/auditing.md":"# Module: Auditing\n\nAppend-only audit trail (entity changes, security events, exceptions, HTTP activity) with async channel-buffered persistence + DLQ. Module `Order = 300`.\n\n**Entities / DbContext:** `AuditRecord`, `AuditDbContext`. `AuditEnvelope` is the in-flight event. Rich Contracts surface: `IAuditClient`, `ISecurityAudit`, `IAuditPublisher`, `IAuditSink`, `IAuditDlqSink`, `IAuditEnricher`, `NoAuditAttribute`, payload records.\n**Areas:** read-only query side — GetAudits / ByCorrelation / ByTrace / Summary / Exception / Security. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Static `Audit` fluent API** — `Audit.ForSecurity(...).WithUser(...).WriteAsync(ct)` (also `ForEntityChange`/`ForActivity`/`ForException`). Configured once at startup via `Audit.Configure(publisher, serializer, enrichers)`. Enrichers are held in a **volatile immutable array swapped atomically** — never mutate a live enricher list (it'd race the enrich loop).\n- **Two interceptors, don't confuse them:** `AuditingSaveChangesInterceptor` (this module) captures EF entity diffs → EntityChange events and **skips `AuditDbContext`** (no recursive self-audit). `AuditableEntitySaveChangesInterceptor` (BuildingBlocks) stamps audit/soft-delete fields — different file, different job.\n- **Channel-buffered, never blocks the request** — `ChannelAuditPublisher` has two lanes: default (`DropOldest` under pressure) and a **security lane that back-pressures and never drops** (login/permission/impersonation ride here). `AuditBackgroundWorker` drains both (security first), batches, writes via `IAuditSink`; on sink failure it retries then spills to `IAuditDlqSink` (file) so events survive a Postgres outage.\n- `SqlAuditSink` groups a batch by `TenantId` and sets tenant context per group in a fresh scope (null → Root) — background writer has no ambient tenant.\n- **JSON masking** redacts fields by keyword (password/secret/token/apiKey/connectionString…) → `****`. Add sensitive keys there.\n- Exclude an endpoint from activity auditing with `[NoAudit]` / the `NoAudit` endpoint extension.\n- **Entity diffs mask sensitive values** in `EntityDiffBuilder` (property name contains password/secret/token/apikey/connectionstring/securitystamp → `****`, null stays null). `[NoAudit]` does **not** affect entity diffs — to keep an entity's values out of `AuditRecords` entirely, implement `IAuditExempt` (Contracts) on the entity.\n",".agents/rules/modules/billing.md":"# Module: Billing\n\nPlans, subscriptions, usage metering, monthly invoicing. **Manual payment marking — no payment provider.** Module `Order = 500`.\n\n**Entities / DbContext:** `BillingPlan`, `Subscription`, `Invoice` (+ `InvoiceLineItem`), `UsageSnapshot`. **`BillingDbContext : DbContext`** (NOT `BaseDbContext`) — billing lives in the main DB with an explicit `TenantId` column for cross-tenant admin visibility, filtered in query services. Contracts = DTOs; `IBillingService`/`IUsageReporter` are internal.\n**Areas:** Plans, Subscriptions, Invoices (generate/issue/mark-paid/void), Usage (capture/get). Monthly invoice job (`5 0 1 * *`). Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **`BillingPlan` is `IGlobalEntity`** — platform-wide catalogue rows, **not tenant-scoped** (opts out of tenant isolation). A plan's `Key` matches the quota config key (e.g. `\"pro\"`): limits come from `QuotaOptions`, prices/overage from the plan.\n- **`BillingDbContext` is a plain `DbContext`** — tenant filtering is done explicitly in query services, not by the `BaseDbContext` auto-filter. Don't assume the global tenant filter applies here.\n- **Invoice state machine** — `Draft → Issued → Paid | Void`. Line items only addable in Draft; a Paid invoice can't be voided; totals recompute on add; Issue defaults due = +14 days.\n- **Usage metering is idempotent** — `IUsageReporter.CaptureForPeriodAsync` reads `IQuotaService` and persists one `UsageSnapshot` per `QuotaResource` per (tenant, period), so invoicing math is reproducible even after a mid-period plan change.\n",".agents/rules/modules/catalog.md":"# Module: Catalog\n\nProduct catalog — products, categories (tree), brands — with soft-delete/restore/trash and search. Module `Order = 600`. This is the **reference module** for the soft-delete + image patterns; copy from here.\n\n**Entities / DbContext:** `Product` (aggregate, soft-deletable) + `ProductImage`, `Brand`, `Category` (self-referencing tree), `Money` (owned value object). `CatalogDbContext`. Domain events (`ProductCreated`/`PriceChanged`/`StockAdjusted`) are **internal**, not integration events.\n**Areas:** Products (+ price/stock/images), Categories (+ tree), Brands — each with Create/Update/Delete/Search/ListTrashed/Restore. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas / patterns to copy\n\n- **Soft-delete + restore + trashed-listing** is the standard pattern. Unique indexes are **filtered on `\"IsDeleted\" = FALSE`** so SKU/Slug stay unique-per-tenant among *live* rows only (a deleted SKU can be reused). Replicate this on any soft-deletable unique field.\n- **EF value-generation for nav children** — `ProductImageConfiguration` sets `Id.ValueGeneratedNever()` (same nav-child footgun as Chat — see `database.md`).\n- **Single-thumbnail invariant** is enforced by the **aggregate**, not a partial unique index (Postgres non-deferrable partial unique indexes can't handle the demote/promote ordering in one transaction). Enforce such invariants in the domain.\n- Registers `ProductFileAccessPolicy` (OwnerType `\"Product\"`) for product images via the Files module.\n- Route ordering: literal segments (`/trash`, `/tree`, `/restore`) are registered **before** `/{id:guid}` catch-alls.\n",".agents/rules/modules/chat.md":"# Module: Chat\n\nSlack-style messaging: 1:1 DMs, group DMs, named channels, threads, reactions, mentions, pins. Module `Order = 800` (after Notifications 750, so Notifications' handlers register first).\n\n**Entities / DbContext:** `ChatChannel` (aggregate, soft-deletable) + `ChannelMember`; `Message` (aggregate) + `MessageAttachment`/`MessageMention`/`MessageReaction`. `ChatDbContext : BaseDbContext`, schema `chat`. Publishes `MentionedInChannelIntegrationEvent`.\n**Areas:** Channels, Messages (incl. pin/edit/delete/threads), Reactions, Search. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **EF value-generation for nav children** — `MessageConfiguration` sets `Property(x => x.Id).ValueGeneratedNever()` for child collections (attachments/mentions/reactions). The domain assigns `Guid.CreateVersion7()` in factories; without this EF treats nav-collection children as `Modified` → 0-row UPDATE instead of INSERT. See `database.md`.\n- `ChatDbContext` calls **`base.OnModelCreating` LAST** so tenant auto-apply sees the configured child types.\n- **`ChannelAuthorization`** (`Features/v1/Internal/`): `RequireMember` throws **NotFound (404)** (not 403) so non-members can't probe channel existence; `RequireAdmin` throws `ForbiddenException`. Use these in every channel/message handler.\n- **SignalR via `IHubContext<AppHub>`** (the shared hub in BuildingBlocks), groups `channel:{id}`. The hub reads the user via `Context.User`, not `ICurrentUser` (see `realtime.md`). Chat registers `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups.\n- SendMessage publishes `MentionedInChannelIntegrationEvent` **per distinct mentioned user**; Notifications consumes it.\n- DMs use a sorted `DirectKey` (`\"{lo}:{hi}\"`) for find-or-create; **threads are single-level only**.\n- Chat attachments register `ChatChannelFileAccessPolicy` (OwnerType `\"ChatChannel\"`): attach/read require membership, delete is uploader-only (see `modules/files.md`).\n",".agents/rules/modules/files.md":"# Module: Files\n\nPresigned-URL file lifecycle (upload → finalize → serve → delete) shared by Catalog images, Chat attachments, avatars. Module `Order = 350` (loads before consumer modules).\n\n**Entities / DbContext:** `FileAsset` (aggregate, soft-deletable): status `PendingUpload → Available | Quarantined`, `Visibility` (Public/Private), `ScanStatus`. `FilesDbContext`. Publishes `FileFinalizedIntegrationEvent`.\n**Areas:** RequestUploadUrl, FinalizeUpload, GetFileDownloadUrl/Metadata, ChangeVisibility, Delete/Restore, ListMy/Shared/Trashed. Purge jobs (orphaned hourly, deleted daily). Full list: `Features/v1/` or `/scalar`. Storage mechanics: `storage.md`.\n\n## Gotchas\n\n- **Presigned flow** — never stream uploads through the API. RequestUploadUrl validates category/extension/size + quota **pre-check** and persists a `PendingUpload`; client uploads directly to storage; **FinalizeUpload debits the quota** (not at request time) and flips to Available/Quarantined.\n- **`FileAccessPolicyRegistry`** resolves `IFileAccessPolicy` by **OwnerType** — case-insensitive, **closed by default** (unknown OwnerType → forbidden), **last-write-wins** on duplicates (intentional, for test substitution). Each owning module registers its own policy in its `ConfigureServices` (Catalog/Tickets load after Files). Files ships `DefaultUploaderOnlyPolicy` for built-in OwnerTypes `\"MyFiles\"`/`\"User\"`.\n- **Visibility is in the storage key** (`public/…` vs `private/…`, built only by `StorageKeyBuilder`). Changing visibility moves the object via `FileStorageRelocator` and publishes `FileStorageKeyChangedIntegrationEvent`; a module that stores a FileAsset's public URL must handle it. `MigrateLegacyPublicFileKeysJob` (enqueued on each start) moves pre-#1410 public files. See `storage.md`.\n- `CanChangeVisibilityAsync` defaults to the delete rule (uploader-only); domain-bound files (e.g. product images) may override to forbid visibility flips.\n- Tenant scoping is implicit via `BaseDbContext` (no explicit `TenantId` on `FileAsset`).\n\nTo support uploads for a new owner type: implement `IFileAccessPolicy`, register it in the owning module, and use that OwnerType in RequestUploadUrl.\n",".agents/rules/modules/identity.md":"# Module: Identity\n\nAuth (JWT + ASP.NET Identity), users, roles, permissions, sessions, impersonation, 2FA.\n\n## Service shape\n\n`IUserService` is a **facade** that delegates to focused single-responsibility services — change behavior in the specific service, not the facade:\n\n| Interface | Concern |\n|---|---|\n| `IUserRegistrationService` | register, external-principal create, email/phone confirm |\n| `IUserProfileService` | get/list/count, update profile, image, existence checks |\n| `IUserStatusService` | activate/deactivate (`DeleteAsync` == deactivate), audited toggles |\n| `IUserRoleService` | role assignment, admin-role guards |\n| `IUserPasswordService` | forgot/reset/change password, history + expiry |\n| `IUserPermissionService` | effective permissions, cache invalidation |\n\n`ChangePassword`/`Update`/`Delete` etc. flow facade → service → EF/UserManager. `CancellationToken` is `= default` on these interfaces and propagated into EF sinks (note: `UserManager`/`RoleManager` have no CT overloads, so private helpers that only call them don't take one).\n\n## Permission gating footgun\n\n`RequiredPermissionAttribute` implements `FSH.Framework.Shared.Identity.Authorization.IRequiredPermissionMetadata`. **Never let a second/duplicate `IRequiredPermissionMetadata` appear** — it silently disables **all** `.RequirePermission()` gates across the app. Permission constants live in `Shared/Identity/*Permissions.cs`.\n\n## Hosted services (background)\n\n- `RolePermissionSyncHostedService` — best-effort sync of the permission catalog; loops, catches `Exception` *with* an `OperationCanceledException` filter, logs and continues.\n- `SessionCleanupHostedService` — hourly expired-session purge; OCE handled by a preceding catch.\n\nThese are the model for background loops: stay alive, log with context, never swallow cancellation. See `api-conventions.md`.\n\n## Tokens / sessions\n\nLogin `POST /api/v1/identity/token/issue` (header `X-FSH-App` enforces the operator/tenant app boundary). Refresh `POST /api/v1/identity/token/refresh` cross-checks subject. Session rows are written best-effort during login — failures log a warning and login still succeeds. Admin can't demote/deactivate the last admin or the root-tenant seed admin (guards in `UserRoleService`/`UserStatusService`).\n\n## Tests\n\n`Identity.Tests` is the largest unit suite. When asserting a forwarded `CancellationToken`, assert the specific token (see `testing.md`).\n",".agents/rules/modules/multitenancy.md":"# Module: Multitenancy\n\nTenant catalog, provisioning, activation/upgrade, per-tenant theming (Finbuckle.MultiTenant). Foundational — registered early.\n\n**Entities / DbContext:** `AppTenantInfo` (catalog), `TenantProvisioning` + `TenantProvisioningStep`, `TenantTheme`. `TenantDbContext` holds the tenant catalog in the main DB.\n**Areas:** CreateTenant, ChangeTenantActivation, UpgradeTenant, Get(Tenants/Status/Migrations), TenantProvisioning (status/retry), TenantTheme (get/update/reset). Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Finbuckle pipeline ordering** — strategy chain Claim → Header → `?tenant=` → DistributedCache → EFCoreStore. `UseMultiTenant()` runs **before `UseAuthentication()`**, but Finbuckle's `ClaimStrategy` runs the default auth scheme's handler itself, so an authenticated caller resolves to its **tenant claim** and a `tenant` header cannot override it. The header (`MultitenancyConstants.Identifier`) decides only for anonymous requests (login, refresh); the root-operator override is post-auth middleware in `MultitenancyModule.ConfigureMiddleware`.\n- **Root-operator cross-tenant override is a post-auth middleware** in `ConfigureMiddleware` (not a Finbuckle strategy). Gate: caller's JWT tenant claim == `MultitenancyConstants.Root.Id` **and** a `tenant` header != root; it re-resolves via `IMultiTenantContextSetter`. Claim-aware tenant logic must go here, never in a strategy.\n- **`ITenantInitialPasswordBuffer`** (singleton) — the tenant admin password is **operator-supplied**, not a constant. `CreateTenantCommandHandler` calls `Store(tenantId, password)` **before** kicking off provisioning; the background seed step `TryConsume`s it (`ConcurrentDictionary`, consume = remove).\n- **Provisioning** runs 4 steps (Database → Migrations → Seeding → CacheWarm) via a Hangfire `TenantProvisioningJob`, falling back to inline execution if Hangfire storage is unavailable. **Activation is gated on `Status == Completed`.**\n- `ITenantService.MigrateTenantAsync`/`SeedTenantAsync` create a fresh scope and set `IMultiTenantContext` **first**, then run the `IDbInitializer`s.\n\nTenant **isolation** mechanics (default-on filter, `IGlobalEntity` opt-out, `base.OnModelCreating` last) live in `database.md`.\n",".agents/rules/modules/notifications.md":"# Module: Notifications\n\nPer-user in-app inbox (bell icon) driven by cross-module integration events, with live SignalR push. Module `Order = 750` (**before Chat 800** so its handlers are registered before Chat publishes).\n\n**Entities / DbContext:** `Notification` (aggregate: `UserId`, `Type`, `Title`/`Body`/`Link`, `Source`, `MetadataJson`, `ReadAtUtc`). `NotificationsDbContext`. Consumes integration events from other modules (e.g. Chat's `MentionedInChannelIntegrationEvent`).\n**Areas:** List, GetUnreadCount, MarkRead, MarkAllRead. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **It's a consumer.** New notification types come from **handling another module's integration event** (`AddIntegrationEventHandlers`), not from new endpoints. The handler writes an inbox row **and** pushes `\"NotificationCreated\"` to SignalR group `user:{userId}` via `IHubContext<AppHub>`.\n- **Order matters** — Notifications (750) must load before any publisher whose events it consumes (Chat 800). If a new module publishes events Notifications should react to, mind the `Order`.\n- In-memory bus runs handlers **synchronously in the publisher's request scope** — keep the handler minimal; an exception surfaces to the originating request. See `eventing.md`.\n- Inbox rows are **denormalized** (Title/Body/Link/MetadataJson copied in) so rendering never calls back into the source module. `MarkRead` is idempotent (`ReadAtUtc ??= now`).\n",".agents/rules/modules/tickets.md":"# Module: Tickets\n\nSupport ticket lifecycle with comments. Module `Order = 700`.\n\n**Entities / DbContext:** `Ticket` (aggregate, soft-deletable, state machine) + `TicketComment`. `TicketsDbContext`. `TicketStatus`/`TicketPriority` enums in Contracts; domain events internal.\n**Areas:** Create, Assign, Resolve, Reopen, Restore, AddComment, ListComments, GetById, Search, ListTrashed. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **State machine** (`Domain/Ticket.cs`): `Open → InProgress → Resolved → Closed`. Illegal transitions throw **`CustomException` with `HttpStatusCode.Conflict` (409)** — not a generic 400. Assigning auto-starts (Open→InProgress); unassigning an InProgress ticket reverts to Open; creating with an assignee starts at InProgress. Closed tickets reject comments/resolve until reopened. Keep all transition guards in the aggregate.\n- Soft-delete/restore/trash pattern is identical to Catalog (filtered unique indexes — see `modules/catalog.md`).\n- Endpoints are mapped on the bare `api/v{version}` group (no `/tickets` sub-path); literal routes precede `{ticketId:guid}`.\n",".agents/rules/modules/webhooks.md":"# Module: Webhooks\n\nTenant-scoped outbound webhook subscriptions with HMAC-signed delivery and retries. Module `Order = 400`.\n\n**Entities / DbContext:** `WebhookSubscription` (`Url`, `EventsCsv`, `SecretHash`, `IsActive`), `WebhookDelivery` (per-attempt log). `WebhookDbContext` (tenant-filtered). Contracts expose **DTOs only** — `IWebhookDispatcher`/`IWebhookDeliveryService` are internal.\n**Areas:** Create/Delete/Get subscriptions, GetDeliveries, Test. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Fan-out is an open-generic handler** — `WebhookFanoutHandler<TEvent>` is registered as an open generic, so it handles **every** `IIntegrationEvent` with no per-event wiring. It skips events with null `TenantId` (subscriptions are tenant-only) and matches event-type name against each subscription's `EventsCsv` (`*` wildcard supported).\n- **Restore tenant context in the background** — both the fan-out handler and `WebhookDispatchJob` set `IMultiTenantContext` in a fresh scope before reading the tenant-filtered DbContext (background pumps/Hangfire carry no HTTP context). This is the canonical pattern for any background reader of tenant data — see `eventing.md`, `jobs.md`.\n- **HMAC signing** — `X-Webhook-Signature: sha256=<hex HMACSHA256>` (`WebhookPayloadSigner.Sign`), plus `X-Webhook-Event` and `X-Webhook-Delivery-Id` headers.\n- **Delivery** — `WebhookDispatcher.EnqueueAsync` enqueues a Hangfire `WebhookDispatchJob` per subscription; `[AutomaticRetry(Attempts=4, DelaysInSeconds={30,120,600,3600})]`. Transient (5xx/408/429) throws to reschedule; permanent 4xx completes silently. Each attempt persists a `WebhookDelivery` row. The `\"Webhooks\"` HttpClient uses `AddHeroResilience` (see `resilience.md`).\n",".agents/rules/realtime.md":"# Realtime — SignalR & SSE (backend)\n\n`src/BuildingBlocks/Web/Realtime/` + `Sse/`. For the frontend side see `frontend/shared.md` + `frontend/dashboard.md`.\n\n## SignalR (`AppHub`)\n\n`[Authorize] AppHub` mapped at **`/api/v1/realtime/hub`**. Groups: `user:{userId}`, `tenant:{tenantId}`, `channel:{channelId}`.\n\n- **Channel-group join is connect-time + on-demand.** `OnConnectedAsync` auto-joins `user:{id}`, `tenant:{id}`, and every `channel:{id}` the user is *already* a member of. A channel that becomes relevant **after** the socket is live (a new DM, or being added to a channel) is **not** auto-joined — the client must call the membership-gated **`JoinChannel(channelId)`** hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs `OnConnectedAsync`. New-DM creation pushes `ChatChannelAdded` to each other participant's `user:{id}` group so their channel list refreshes.\n- **⚠️ Read the user from `Context.User`, NOT `ICurrentUser`.** `ICurrentUser` flows through `IHttpContextAccessor`, but the negotiate `HttpContext` isn't pinned to subsequent hub invocations → `ICurrentUser` returns nulls inside the hub. Use `Context.User` (the hub's `GetUserId()`/`GetTenantId()` helpers).\n- Broadcasts are **scoped to groups** (`tenant:{id}`, `user:{id}`, `channel:{id}`), never `Clients.All`. `PresenceChanged` goes to the tenant group.\n- Redis backplane is added automatically when `CachingOptions:Redis` is set (channel prefix `fsh-signalr`) — required for multi-replica.\n- Push to a user from a module via `IHubContext<AppHub>` to group `user:{userId}` (e.g. Notifications' `\"NotificationCreated\"`).\n- `IPresenceTracker` (in-memory, **per-host** — single-replica only for presence). Modules supply `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups without depending on Chat.\n\n## SSE (`Web/Sse/`) — two-step token\n\nEventSource can't send `Authorization`, so SSE uses a token handshake:\n1. `POST /api/v1/sse/token` (authorized) → opaque Guid, **single-use, 30s TTL** in `IDistributedCache`.\n2. `GET /api/v1/sse/stream?token={guid}` (anonymous, consumes the token) → `text/event-stream`, `X-Accel-Buffering: no`, 15s heartbeat.\n\n`SseConnectionManager` (singleton, `ConcurrentDictionary`, bounded channel cap 100 `DropOldest`): `TrySend(userId)` (all tabs), `Broadcast(tenantId)`, `BroadcastAll()`.\n\n## Tests\n\nSignalR hub tests force **long-polling** (TestServer has no WebSocket). See `integration-testing.md`.\n",".agents/rules/resilience.md":"# HTTP resilience\n\n`src/BuildingBlocks/Web/HttpResilience/`. Uses `Microsoft.Extensions.Http.Resilience` (Polly v8).\n\n## Pattern — opt-in per HttpClient\n\n`AddHeroResilience(config)` is an `IHttpClientBuilder` extension that adds `AddStandardResilienceHandler` configured from `HttpResilienceOptions` (retry, total-request timeout, attempt timeout, circuit breaker). It is **NOT global** — chain it onto the specific outbound client that needs it:\n\n```csharp\nbuilder.Services.AddHttpClient(\"Webhooks\", ...)\n    .AddHeroResilience(builder.Configuration);\n```\n\nDefaults (`HttpResilienceOptions`): 3 retries, 30s total, 10s per attempt, 50% failure ratio, throughput 10. No-ops when `Enabled=false`.\n\n## Notes\n\n- Only outbound integrations need this. The only current caller is the Webhooks delivery client (`WebhooksModule.cs`). Add it to any new typed/named `HttpClient` that calls a flaky external service.\n- For internal ret/timeout of *background* work, prefer Hangfire's `[AutomaticRetry]` on the job (see `modules/webhooks.md`) — that's durable across restarts; the resilience handler only covers the in-flight HTTP call.\n",".agents/rules/security.md":"# Web security & request governance\n\nCORS, security headers, rate limiting, idempotency, quota enforcement. `src/BuildingBlocks/Web/` + `Quota/`.\nFor auth/JWT/permissions see `modules/identity.md`; for the global exception handler see `api-conventions.md`.\n\n## CORS (`Web/Cors/`) — the SignalR gotcha\n\nPolicy `FSHCorsPolicy`. When `CorsOptions.AllowAll=true` it uses **`SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod().AllowCredentials()`** — deliberately **NOT `AllowAnyOrigin()`**. `Access-Control-Allow-Origin: *` is illegal with credentialed requests, and **SignalR's negotiate always runs credentialed**, so `AllowAnyOrigin()` silently breaks SignalR while REST keeps working. Never \"simplify\" it to `AllowAnyOrigin()`. `UseHeroCors()` runs **before** `UseHttpsRedirection()` so OPTIONS preflight isn't 307-redirected.\n\n## Front-end origin for outbound links (`Web/Frontend/`)\n\n`IFrontendOriginResolver` builds the origin of user-facing links sent in e-mails (password reset, e-mail confirmation). Backed by **`FrontendOptions`**, deliberately separate from `CorsOptions`: CORS governs which browsers may *call* the API, this governs which origins may appear *inside an outbound link*. Never merge the two lists — coupling them breaks same-origin/reverse-proxy topologies and overloads a security boundary.\n\n- **`ResolveForCurrentRequest()`** — self-service flows only (forgot-password, self-register), where the caller *is* the recipient. Validates the request `Origin` against `FrontendOptions:AllowedOrigins` and returns the **canonical configured entry**, never the client's string. Present-but-unlisted against a non-empty list → `CustomException(HttpStatusCode.BadRequest)`: these endpoints are anonymous, so a forged header must never reach an e-mail. No `Origin` header, or an empty/all-unparseable list, falls through to the default.\n- **`ResolveDefault()`** — operator-driven flows (admin register, resend-confirmation) and background jobs, where the caller is **not** the recipient. Chain: `FrontendOptions:DefaultOrigin` → throw. Never the caller's `Origin`, or an operator would send a tenant user a link into the admin console; and never the request's `Host` either, which is the same string under a different name. There is deliberately no fallback to `OriginOptions:OriginUrl`: these links address SPA routes (`/confirm-email`, `/reset-password`), so the API's own origin would 404 them.\n\nPicking the wrong method is a silent bug — both compile and both return a plausible origin. Match the method to **who receives the link**, not to who sent the request.\n\nNo `ValidateOnStart` on `FrontendOptions`, on purpose: a deployment that never sends such a link must not be taken down by the setting. `UseHeroPlatform` logs at startup instead — `Warning` for an empty or all-typo allow-list (counted after normalization, so a list of nothing but typos reports as the empty list it effectively is), `Error` for a missing `DefaultOrigin`, since without it those flows return 500 rather than degrading. `OriginOptions:OriginUrl` keeps its own job — the API's own public base for back-end-served assets (avatars), read through `IRequestContext.Origin`.\n\n## Security headers (`Web/Security/`)\n\n`UseHeroSecurityHeaders()` sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, HSTS (HTTPS), and a CSP. `SecurityHeadersOptions.ExcludedPaths` defaults to `[\"/scalar\",\"/openapi\"]` (they manage their own scripts) — keep those excluded.\n\n## Rate limiting (`Web/RateLimiting/`)\n\nChained partitioned fixed-window limiter: **tenant → user → IP** (defaults 1000 / 200 / 300 per 60s) + a stricter named `\"auth\"` policy (10/60s). Health paths are unlimited. Rejection → 429 + ProblemDetails + `Retry-After`. `RateLimitingOptions.Enabled` is read **eagerly** — when false the middleware is skipped entirely (tests set it via env var before host build).\n\n## Idempotency (`Web/Idempotency/`)\n\nOpt-in per endpoint with **`.WithIdempotency()`** on **authenticated** POST/PUTs that must be replay-safe (e.g. CreateTenant). Reads `Idempotency-Key` (max 128 chars, `DefaultTtl` 24h); a replay returns the cached status + body + `Location`/`ETag` and `Idempotency-Replayed: true`.\n\n- **Key scope:** resolved tenant (never the raw `tenant` header) + operation (method + route pattern) + route values + caller (`GetUserId()`, else `\"anon\"`), via `CacheKeys.IdempotencyEntry`.\n- **Only 2xx is stored**, before the body is sent, on `CancellationToken.None`. Probe and write must share one `IDistributedCache` + key + serializer, or replay silently never engages.\n- **Concurrent duplicates:** in-flight reservation under a `lock:` prefix (Redis `SET NX`, else in-process, `ReservationTtl` default 1m); cache re-probed after the lock; duplicate in flight → **409** + `Retry-After: 1`. Probe, reserve and release all fail open.\n- **`RequestAborted` is detached** while the handler runs, so a client disconnect can't cancel a committed side effect. Keep these handlers short; **never** on streaming or large-file endpoints (the response is buffered).\n- **Never on `AllowAnonymous()`** endpoints — all anonymous callers share `\"anon\"`. `IdempotencyWiringTests` fails the build if one appears.\n\n## Quota enforcement (`Quota/`)\n\n`QuotaEnforcementMiddleware` charges 1 `ApiCalls` unit per request via `CheckAndRecordAsync`; over-limit → 429 + ProblemDetails + `Retry-After`, and sets `HttpContext.Items[QuotaRejected]` so auditing can tag it. Resources: `ApiCalls` (counter), `StorageBytes`, `Users`, `ActiveFeatureFlags` (gauges). Skips health/metrics, unresolved tenants, and the root tenant. **Pipeline:** runs after auth (needs tenant) and after the rate limiter. Inject `TimeProvider` (not `DateTimeOffset.UtcNow`) for any time math here — the subsystem is `TimeProvider`-based.\n\n`IQuotaService`: `CheckAsync` (no mutation), `RecordAsync` (increment), `CheckAndRecordAsync` (atomic — won't increment past the limit). Store: Redis (`RedisQuotaService`) or per-process `InMemoryQuotaService` (dev/test). `NoopQuotaService` when disabled.\n",".agents/rules/storage.md":"# Storage & file uploads\n\n`src/BuildingBlocks/Storage/`. Read before working with files/blobs.\n\n## `IStorageService`\n\n`UploadAsync<T>(FileUploadRequest, FileType, ct)`, `RemoveAsync(path, ct)`, `DownloadAsync`, `ExistsAsync`, `GetSizeAsync` (0 if absent), `GenerateUploadUrlAsync`/`GenerateDownloadUrlAsync` (presigned), `HeadObjectAsync`, `BuildPublicUrl(key)→string` (string, not Uri — local storage returns a server-relative path).\n\n`CopyAsync(source, destination, ct)` is a server-side copy that **throws** on failure (unlike `RemoveAsync`) — callers move objects as copy → update the reference → delete. The quota decorator charges the copy, so a move is net-zero.\n\n## Visibility lives in the key\n\nBucket policies in every stack (Aspire bootstrap, compose `rustfs-init`, Terraform `app_s3_public_read_prefix` + the CloudFront read statement) grant anonymous `s3:GetObject` on **`public/*` only** (`StorageVisibilityRoot`). So:\n- Files keys are `{public|private}/tenants/{tenant}/{owner}/{yyyy}/{MM}/{id:N}/{name}` — `StorageKeyBuilder` (Files module) is the **one** place that decides; never hand-build a key.\n- `S3StorageService.UploadAsync<T>` always returns a public URL, so it writes under `public/uploads/…`.\n- Moves go through `FileStorageRelocator.ApplyAsync`, never by hand: it takes the file's row lock (`SELECT … FOR UPDATE`, PostgreSQL), re-reads the row, then copy → commit row + `FileStorageKeyChangedIntegrationEvent` → delete old. The old object is deleted only when no row references it; the first attempt runs right after the commit, and Files' own `DeleteSupersededObjectHandler` retries it via the outbox until it is really gone (so a Public → Private flip can't leave a public copy behind).\n- Modules that persist a public URL must handle that event and use `ResolveUrl` (it returns the API's current `NewPublicUrl` for public files) — Catalog (product images) and Identity (avatars) do.\n- Keys from before #1410 (`tenants/…`, `uploads/…`) have no root: `MigrateLegacyPublicFileKeysJob` (enqueued via `IJobService` on every API start; keyset-paginated; retried by Hangfire) moves public FileAssets under `public/`; legacy private ones stay put. It reads the partial index `IX_FileAsset_LegacyKey`, which is empty once done, so repeat runs are nearly free. `FinalizeUpload` relocates uploads that were presigned before the upgrade. Legacy `UploadAsync<T>` objects (`uploads/…`) are **not** migrated.\n- `QuotaMeteredStorageService.RemoveAsync` refunds only when the object is gone afterwards (providers swallow delete errors).\n- With `Storage:S3:Prefix` set, keys become `{Prefix}/public/…` — the policy must match that, and the prefix must **not** be `public` or `private`.\n- The **Local** provider serves everything under wwwroot: it does not enforce `private/` at all. Use it for dev only.\n\n`FileType`: `Image` (5MB), `Document`, `Pdf` (10MB) — `FileTypeMetadata.GetRules` enforces extension + size. **Always propagate `CancellationToken`.**\n\n## Providers\n\n`AddHeroStorage(config)` reads `Storage:Provider` **eagerly at registration**: `\"s3\"` → `S3StorageService` (supports any S3-compatible store, e.g. RustFS, via `ServiceUrl` + `ForcePathStyle`), else `LocalStorageService`. When quotas are enabled the service is wrapped in `QuotaMeteredStorageService` (debits `StorageBytes`).\n\n`Storage:S3:PresignServiceUrl` (optional, validated at startup as an absolute http(s) URL): presigned PUT/GET URLs are signed for this host through a second, keyed `IAmazonS3` (`S3StorageService.PresignClientKey`); all real I/O stays on `ServiceUrl`. Needed when the API reaches the store on an internal address browsers can't resolve (compose: `http://rustfs:9000`). A test factory that re-registers the S3 stack must register that keyed client too (see `FshWebApplicationFactory`).\n\n## Presigned upload flow (preferred for user uploads)\n\nDon't stream large files through the API. The pattern (see Files module):\n1. `RequestUploadUrl` — server validates category/extension/size + quota pre-check, returns a presigned PUT URL, persists a `PendingUpload` record.\n2. Client uploads **directly** to storage.\n3. `FinalizeUpload` — flips to `Available`, **debits the quota here** (not at request time), publishes `FileFinalizedIntegrationEvent`.\n\nLocal/dev without an S3 store uses `LocalPresignTokenStore` (in-memory one-shot tokens).\n\n## Test gotcha\n\n`AddHeroStorage` reads `Storage:Provider` **before** a test factory's in-memory config overlay applies, so it wires `LocalStorageService`. Integration tests that need object storage must **remove the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-register the S3 stack** pointed at the RustFS container (see `FshWebApplicationFactory`). See `integration-testing.md`.\n",".agents/rules/testing.md":"# Testing conventions\n\nRead before writing or changing tests.\n\n## Stack\n\nxUnit · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For<IService>()`) · AutoFixture (`_fixture.Create<T>()`) · NetArchTest (architecture rules) · Testcontainers (integration).\n\n## Naming & shape\n\n- Method name: `MethodName_Should_ExpectedBehavior_When_Condition`.\n- Arrange-Act-Assert, grouped with `#region` (Happy Path / Exception / Edge Cases).\n- Assert on observable behavior. When verifying a forwarded `CancellationToken`, assert the **specific** token (`Received(1).XAsync(arg, ct)`), not the implicit default — NSubstitute fills optional params with `default`, so `Received(1).XAsync(arg)` silently asserts `CancellationToken.None`.\n\n## Test projects (`src/Tests/`)\n\n| Project | Scope | Docker? |\n|---|---|---|\n| `{Module}.Tests` | Unit: handlers, services, domain | no |\n| `Framework.Tests`, `Generic.Tests`, `Caching.Tests` | BuildingBlocks units | no |\n| `Architecture.Tests` | NetArchTest: module boundaries + tenant-isolation rules + handler↔validator pairing | no |\n| `Integration.Tests` | `WebApplicationFactory` over real PostgreSQL/Redis/RustFS | **yes** |\n| `Integration.Middleware.Tests` | Real middleware wiring | **yes** |\n\n```bash\ndotnet test src/FSH.Starter.slnx                 # all (integration needs Docker)\ndotnet test src/Tests/{Module}.Tests             # one project\ndotnet test --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\n\nIf Docker is down, integration tests fail fast with `DockerUnavailableException` — that is environmental, not a code regression. Run the unit projects to validate logic.\n\n## Architecture tests (must stay green)\n\n- Modules reference other modules only via `.Contracts`.\n- Tenant-isolation rules on entities.\n- Every command handler + paginated query handler has a `{Name}Validator` (`HandlerValidatorPairingTests`). Validator names accepted: `{Cmd}Validator`, `{Name}CommandValidator`, `{Name}Validator`.\n\n## Integration-test gotchas\n\n- Set the Finbuckle tenant context **inline** in the test method (AsyncLocal — an awaited helper loses it → NRE in the tenant filter).\n- `AddHeroStorage` reads config eagerly; rewire `IStorageService` **after** registration in the factory.\n- SignalR hub tests force **long-polling** (TestServer has no WebSocket).\n\n## Frontend tests\n\nPlaywright, route-mocked (no real backend) — see `frontend/shared.md`. `cd clients/{app} && npm run test:e2e`.\n",".agents/skills/add-entity/SKILL.md":"---\nname: add-entity\ndescription: Add a domain entity/aggregate with EF configuration and a migration to an existing FSH module. Use when adding a new database-backed entity. Pairs with add-feature and create-migration.\nargument-hint: \"[ModuleName] [EntityName]\"\n---\n\n# Add Entity\n\nRich domain model: `sealed` aggregate, private EF ctor, static factory, behavior via methods, domain\nevents. DB conventions: `.agents/rules/database.md`.\n\n## Entity — `AggregateRoot<Guid>` (or `BaseEntity<Guid>`)\n\n`BaseEntity<TId>` gives only `Id` + domain-event machinery. Audit/tenant/soft-delete are **opt-in via\nmarker interfaces** (the base does NOT carry those fields). New ids use **`Guid.CreateVersion7()`**.\n\n```csharp\npublic sealed class {Entity} : AggregateRoot<Guid>, IHasTenant, IAuditableEntity, ISoftDeletable\n{\n    public string Name { get; private set; } = default!;\n    public Money Price { get; private set; } = default!;\n\n    // IHasTenant\n    public string TenantId { get; private set; } = default!;\n    // IAuditableEntity\n    public DateTimeOffset CreatedOnUtc { get; set; }\n    public string? CreatedBy { get; set; }\n    public DateTimeOffset? LastModifiedOnUtc { get; set; }\n    public string? LastModifiedBy { get; set; }\n    // ISoftDeletable\n    public bool IsDeleted { get; set; }\n    public DateTimeOffset? DeletedOnUtc { get; set; }\n    public string? DeletedBy { get; set; }\n\n    private {Entity}() { }   // EF\n\n    public static {Entity} Create(string name, Money price)\n    {\n        ArgumentException.ThrowIfNullOrWhiteSpace(name);\n        ArgumentNullException.ThrowIfNull(price);\n\n        var entity = new {Entity} { Id = Guid.CreateVersion7(), Name = name.Trim(), Price = price };\n        entity.AddDomainEvent(DomainEvent.Create((id, ts) =>\n            new {Entity}CreatedDomainEvent(entity.Id, entity.Name, id, ts)));\n        return entity;\n    }\n\n    public void Rename(string name)\n    {\n        ArgumentException.ThrowIfNullOrWhiteSpace(name);\n        Name = name.Trim();\n    }\n}\n```\n\nNotes: setters are `private set`; `TenantId`/audit/soft-delete members are settable by the framework\n(interceptor + Finbuckle) so they aren't `private set`. Use `Guid.CreateVersion7()`, never `Guid.NewGuid()`.\n\n## Domain event — inherit `DomainEvent` (abstract record)\n\n```csharp\npublic sealed record {Entity}CreatedDomainEvent(\n    Guid {Entity}Id, string Name, Guid EventId, DateTimeOffset OccurredOnUtc)\n    : DomainEvent(EventId, OccurredOnUtc);\n```\n\nRaise with the `DomainEvent.Create((id, ts) => …)` helper + `AddDomainEvent(...)` (not `QueueDomainEvent`).\n\n## EF configuration\n\n```csharp\npublic sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>\n{\n    public void Configure(EntityTypeBuilder<{Entity}> builder)\n    {\n        ArgumentNullException.ThrowIfNull(builder);\n        builder.ToTable(\"{Entities}\");                       // schema is set once on the DbContext\n        builder.HasKey(x => x.Id);\n        builder.Property(x => x.Name).IsRequired().HasMaxLength(200);\n\n        // soft-deletable unique field → filter on live rows only\n        builder.HasIndex(x => x.Name).IsUnique().HasFilter(\"\\\"IsDeleted\\\" = FALSE\");\n\n        // owned value object\n        builder.OwnsOne(x => x.Price, m =>\n        {\n            m.Property(p => p.Amount).HasColumnName(\"PriceAmount\").HasPrecision(18, 4);\n            m.Property(p => p.Currency).HasColumnName(\"PriceCurrency\").HasMaxLength(3);\n        });\n\n        builder.Ignore(x => x.DomainEvents);\n    }\n}\n```\n\n- **Do NOT add a manual `HasQueryFilter` for soft-delete or tenant** — `BaseDbContext` applies both automatically.\n- A child entity reached only via a parent nav-collection needs `builder.Property(x => x.Id).ValueGeneratedNever()` in **its** config, or EF inserts it as `Modified` → 0-row UPDATE. See `database.md`.\n\n## Register in the module DbContext\n\nAdd a `DbSet`; configurations are picked up by `ApplyConfigurationsFromAssembly`:\n\n```csharp\npublic DbSet<{Entity}> {Entities} => Set<{Entity}>();\n```\n\nThe DbContext already extends `BaseDbContext` and calls `base.OnModelCreating` **last** — don't change that.\n\n## Migration\n\nUse the **create-migration** skill (build first, correct `--context`):\n\n```bash\ndotnet ef migrations add Add{Entity} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {X}DbContext\n```\n\n## Checklist\n\n- [ ] `sealed`, `AggregateRoot<Guid>` (+ `IHasTenant`/`IAuditableEntity`/`ISoftDeletable` as needed), private ctor, static `Create` using `Guid.CreateVersion7()`\n- [ ] Domain event inherits `DomainEvent`; raised via `DomainEvent.Create` + `AddDomainEvent`\n- [ ] EF config: no manual soft-delete/tenant filter; `ValueGeneratedNever()` on nav-collection children\n- [ ] `DbSet` added; build green; migration created with `--context {X}DbContext`\n",".agents/skills/add-feature/SKILL.md":"---\nname: add-feature\ndescription: Add a vertical-slice feature (command/query + handler + validator + endpoint) to an existing FSH module. Use when adding an API endpoint or business operation to a module that already exists.\nargument-hint: \"[ModuleName] [Area] [FeatureName]\"\n---\n\n# Add Feature\n\nA feature is a vertical slice **split across two projects**: the request/response types live in the\nmodule's `.Contracts` project (public API); the handler, validator, and endpoint live in the runtime\nproject. Full conventions: `.agents/rules/api-conventions.md`.\n\n## Layout (real)\n\n```\nsrc/Modules/{X}/Modules.{X}.Contracts/v1/{Area}/{Feature}Command.cs   # ICommand<T>/IQuery<T>\nsrc/Modules/{X}/Modules.{X}.Contracts/Dtos/{Entity}Dto.cs             # response DTOs (if any)\nsrc/Modules/{X}/Modules.{X}/Features/v1/{Area}/{Feature}/\n├── {Feature}CommandHandler.cs    # public sealed, injects the DbContext directly\n├── {Feature}CommandValidator.cs  # required for commands + paginated queries\n└── {Feature}Endpoint.cs          # internal static extension\n```\n\n## Step 1 — Command/Query (Contracts project)\n\n`Mediator` interfaces (`using Mediator;`). Records. A create command can return the raw `Guid`.\n\n```csharp\nnamespace FSH.Modules.{X}.Contracts.v1.{Area};\n\npublic sealed record Create{Entity}Command(string Name, decimal PriceAmount, string PriceCurrency)\n    : ICommand<Guid>;\n```\n\nRead/list DTOs go in `Modules.{X}.Contracts/Dtos/`. Paginated queries return `PagedResponse<T>`\n(`FSH.Framework.Shared.Persistence`) — see `query-patterns`.\n\n## Step 2 — Handler (runtime `Features/`) — inject the DbContext, NOT a repository\n\nThere is **no generic `IRepository<T>`**. Inject the module's `{X}DbContext`. `public sealed`, primary\nctor, `ValueTask<T>`, `.ConfigureAwait(false)`, guard first. Tenant/audit fields are auto-stamped — only\ninject `ICurrentUser` if you need the acting user (`GetUserId()` / `GetTenant()`).\n\n```csharp\npublic sealed class Create{Entity}CommandHandler(CatalogDbContext dbContext)\n    : ICommandHandler<Create{Entity}Command, Guid>\n{\n    public async ValueTask<Guid> Handle(Create{Entity}Command command, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(command);\n\n        var entity = {Entity}.Create(command.Name, new Money(command.PriceAmount, command.PriceCurrency));\n        dbContext.{Entities}.Add(entity);\n        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);\n        return entity.Id;\n    }\n}\n```\n\nThrow `NotFoundException` / `CustomException(msg, errors, HttpStatusCode)` (`FSH.Framework.Core.Exceptions`) — the global handler maps them to ProblemDetails.\n\n## Step 3 — Validator (required; same folder)\n\n```csharp\npublic sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command>\n{\n    public Create{Entity}CommandValidator()\n    {\n        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);\n        RuleFor(x => x.PriceCurrency).NotEmpty().Length(3);\n    }\n}\n```\n\n`Architecture.Tests` fails the build if a command/paginated-query handler has no `{Name}Validator`.\n\n## Step 4 — Endpoint (same folder)\n\n```csharp\npublic static class Create{Entity}Endpoint\n{\n    internal static RouteHandlerBuilder MapCreate{Entity}Endpoint(this IEndpointRouteBuilder endpoints) =>\n        endpoints.MapPost(\"/{entities}\",\n                async (Create{Entity}Command command, IMediator mediator, CancellationToken ct) =>\n                    Results.Ok(await mediator.Send(command, ct)))\n            .WithName(\"Create{Entity}\")\n            .WithSummary(\"Create a {entity}\")\n            .RequirePermission({X}Permissions.{Entities}.Create)\n            .WithIdempotency();   // on replay-safe POSTs\n}\n```\n\n## Step 5 — Wire it in `{X}Module.MapEndpoints`\n\n```csharp\ngroup.MapCreate{Entity}Endpoint();   // group = endpoints.MapGroup(\"api/v{version:apiVersion}/{x}\") …\n```\n\n## Step 6 — Verify\n\n```bash\ndotnet build src/FSH.Starter.slnx          # 0 warnings (TreatWarningsAsErrors)\ndotnet test src/Tests/{X}.Tests            # + add a handler/validator test (see testing-guide)\n```\n\n## Checklist\n\n- [ ] Command/Query in the **Contracts** project (`using Mediator;`), DTOs in `Contracts/Dtos/`\n- [ ] Handler `public sealed`, injects `{X}DbContext` (no repository), `ValueTask<T>` + `.ConfigureAwait(false)`\n- [ ] `{Name}Validator` exists\n- [ ] Endpoint `internal static …Map{Feature}Endpoint`, `.RequirePermission(...)`, `.WithName/.WithSummary`\n- [ ] Wired in `{X}Module.MapEndpoints`\n- [ ] Build 0 warnings; test added\n",".agents/skills/add-full-slice/SKILL.md":"---\nname: add-full-slice\ndescription: Build a capability end-to-end — backend vertical slice (Contracts→handler→validator→endpoint) AND the React page wired to it. Use when delivering a user-facing feature across API + UI. Composes add-feature + add-react-page.\nargument-hint: \"[ModuleName] [admin|dashboard] [FeatureName]\"\n---\n\n# Add Full Slice (backend → frontend)\n\nThe kit's accelerator: deliver a feature from database to UI in one pass. This skill **composes**\n`add-feature` (backend) and `add-react-page` (frontend) — follow each for the detailed code; this file is\nthe order of operations and the **contract** that keeps the two halves in sync.\n\n## Order of operations\n\n1. **Backend slice** — `add-feature` (and `add-entity` first if a new entity is needed):\n   - Command/Query + response DTO in `Modules.{X}.Contracts/v1/{Area}/` (+ `Contracts/Dtos/`).\n   - Handler (`public sealed`, injects `{X}DbContext`), Validator, Endpoint (`internal static Map…Endpoint`, `.RequirePermission(...)`).\n   - Wire in `{X}Module.MapEndpoints`. Build + test backend green.\n2. **Lock the contract** — note the final **route path**, HTTP method, request shape, and response DTO field names/casing. The React side must match these exactly.\n3. **Frontend page** — `add-react-page` in the chosen app:\n   - API module calls the **same path**; hand-write TS types mirroring the **response DTO** (the API serializes C# records as camelCase JSON — TS fields are camelCase even though admin *query params* are PascalCase).\n   - Page (`useQuery`/`useMutation`), route (`RouteGuard` for admin / `withSuspense` for dashboard).\n4. **Permission** — if the endpoint is gated, mirror the constant into admin's `lib/permissions.ts` and gate the route (`add-permission`). Dashboard relies on the server 403.\n5. **Tests both sides** — backend handler/validator test (xUnit/Shouldly/NSubstitute) + frontend Playwright spec (route-mocked).\n\n## The contract (the thing that breaks if you're sloppy)\n\n| Backend | Frontend must match |\n|---|---|\n| Endpoint route `api/v{n}/{module}/{resources}` | `apiFetch` path |\n| Request: the `Command`/`Query` record fields | request body / query-param keys (admin params PascalCase; **body JSON camelCase**) |\n| Response: the DTO record (camelCase JSON) | the hand-written TS `type` |\n| `.RequirePermission({X}Permissions...)` | (admin) `RouteGuard perms` + the mirrored constant |\n| Paginated → `PagedResponse<T>` | `PagedResponse<T>` (admin: `@/lib/api-types`; dashboard: inline) |\n\n## Verify end-to-end\n\n```bash\ndotnet build src/FSH.Starter.slnx && dotnet test src/Tests/{X}.Tests\ncd clients/{app} && npm run lint && npm run test:e2e\n# optional manual check: dotnet run --project src/Host/FSH.Starter.AppHost  (brings up API + both apps)\n```\n\n## Checklist\n\n- [ ] Backend slice complete + green (`add-feature`)\n- [ ] Contract locked: route, request shape, response DTO field names\n- [ ] Frontend api module path + TS types match the contract (body JSON camelCase)\n- [ ] Page + route added (`add-react-page`); admin permission mirrored + gated (`add-permission`)\n- [ ] Backend test + Playwright test added; both suites green\n",".agents/skills/add-integration-event/SKILL.md":"---\nname: add-integration-event\ndescription: Publish a cross-module integration event via the Outbox and handle it idempotently in another module. Use when one module must react to something that happened in another. See .agents/rules/eventing.md.\nargument-hint: \"[SourceModule] [EventName] [ConsumerModule]\"\n---\n\n# Add Integration Event\n\nCross-module communication goes through **integration events + the Outbox** (transactional, crash-safe) —\nnever a direct in-process call into another module's runtime, and never `IEventBus.PublishAsync` from a\nhandler. Full model: `.agents/rules/eventing.md`.\n\n## Step 1 — Define the event (source module's Contracts)\n\n`Modules.{Source}.Contracts/Events/{Event}IntegrationEvent.cs` — implement `IIntegrationEvent`:\n\n```csharp\npublic sealed record {Event}IntegrationEvent(\n    Guid Id,\n    DateTime OccurredOnUtc,\n    string? TenantId,\n    string CorrelationId,\n    string Source,\n    Guid {Entity}Id,\n    string SomePayload) : IIntegrationEvent;\n```\n\n⚠️ Don't rename/move this type later — the outbox stores its assembly-qualified name; a rename makes\n`Type.GetType()` return null and the message dead-letters. Keep the type name + namespace stable.\n\n## Step 2 — Publish via the Outbox (source handler)\n\nPublishing needs **no module registration** — the outbox is framework-owned and the host wires it once. Inject `IOutboxWriter` (from `FSH.Framework.Eventing.Abstractions`) and add the event in the same unit of work:\n\n```csharp\npublic sealed class Do{Thing}CommandHandler({Source}DbContext db, IOutboxWriter outbox)\n    : ICommandHandler<Do{Thing}Command, Unit>\n{\n    public async ValueTask<Unit> Handle(Do{Thing}Command command, CancellationToken cancellationToken)\n    {\n        // … mutate entities, db.SaveChangesAsync …\n        var evt = new {Event}IntegrationEvent(\n            Id: Guid.CreateVersion7(),\n            OccurredOnUtc: DateTime.UtcNow,\n            TenantId: /* current tenant */,\n            CorrelationId: Guid.NewGuid().ToString(),\n            Source: \"{Source}\",\n            {Entity}Id: entity.Id,\n            SomePayload: \"…\");\n        await outbox.AddAsync(evt, cancellationToken).ConfigureAwait(false);\n        return Unit.Value;\n    }\n}\n```\n\nThe `OutboxDispatcherHostedService` later publishes it via `IEventBus`.\n\n## Step 3 — Handle it (consumer module)\n\n`Modules.{Consumer}/IntegrationEventHandlers/{Event}IntegrationEventHandler.cs` — `sealed`, implement `IIntegrationEventHandler<T>`:\n\n```csharp\npublic sealed class {Event}IntegrationEventHandler({Consumer}DbContext db /*, IHubContext<AppHub> hub */)\n    : IIntegrationEventHandler<{Event}IntegrationEvent>\n{\n    public async Task HandleAsync({Event}IntegrationEvent @event, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(@event);\n        // … write to the consumer's tables / push a notification …\n        await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);\n    }\n}\n```\n\nRegister the consumer's handlers in its `ConfigureServices`:\n\n```csharp\nbuilder.Services.AddIntegrationEventHandlers(typeof({Consumer}Module).Assembly);\n```\n\n## Gotchas\n\n- **Idempotency is free** with the in-memory bus (the Inbox dedups by `{eventId, handlerName}`) — don't hand-roll it.\n- The in-memory bus runs handlers **synchronously in the publisher's scope** — keep the handler lean; a throw surfaces to the originating request. Published via the outbox, that scope belongs to the dispatcher, so the consumer runs on the next cycle and its failures never reach the caller. Don't let a caller (or a test) assume the side effect already happened; integration tests drain with `OutboxDrain.DrainAsync`.\n- If the handler reads a **tenant-filtered** DbContext from a background path (open-generic handler, Hangfire job), restore Finbuckle context first via `IMultiTenantContextSetter` (see `WebhookFanoutHandler`).\n- **Module load order:** the consumer must load before the publisher if it must react (`Order` in `[assembly: FshModule]`) — e.g. Notifications (750) before Chat (800).\n\n## Checklist\n\n- [ ] Event in source Contracts, implements `IIntegrationEvent`, stable type name\n- [ ] Published via `IOutboxWriter.AddAsync` (not the bus); no per-module eventing registration needed\n- [ ] Consumer handler `sealed : IIntegrationEventHandler<T>`; `AddIntegrationEventHandlers(assembly)` registered\n- [ ] Background readers restore tenant context; module `Order` lets the consumer load first\n- [ ] Build + tests green\n",".agents/skills/add-module/SKILL.md":"---\nname: add-module\ndescription: Create a new module (bounded context) — runtime + Contracts projects, IModule, DbContext, permissions, migrations, and the four registration sites. Use when adding a distinct business domain. For a feature in an existing module, use add-feature.\nargument-hint: \"[ModuleName]\"\n---\n\n# Add Module\n\nHigh-ceremony. The part people get wrong is **registration — a module must be wired in FOUR places**\n(see Step 6). Architecture rules: `.agents/rules/architecture.md`.\n\n## Projects\n\n```\nsrc/Modules/{Name}/\n├── Modules.{Name}/            ← runtime (internal): Domain/, Data/, Features/v1/, {Name}Module.cs\n└── Modules.{Name}.Contracts/  ← public API: v1/ (commands/queries), Dtos/, Authorization/, Events/\n```\n\n**Copy an existing module's two `.csproj` files** (e.g. `Modules.Catalog`) and rename — don't hand-write\nproject references. The runtime project references its Contracts project + the BuildingBlocks it needs;\nthe Contracts project references `Mediator` + shared contracts.\n\n## Step 1 — `[FshModule]` is an ASSEMBLY attribute (not class-level)\n\nIn `{Name}Module.cs`, above the namespace:\n\n```csharp\n[assembly: FshModule(typeof(FSH.Modules.{Name}.{Name}Module), 900)]   // (Type, order)\n\nnamespace FSH.Modules.{Name};\n\npublic sealed class {Name}Module : IModule\n{\n    public void ConfigureServices(IHostApplicationBuilder builder)\n    {\n        ArgumentNullException.ThrowIfNull(builder);\n        PermissionConstants.Register({Name}Permissions.All);\n        builder.Services.AddHeroDbContext<{Name}DbContext>();\n        builder.Services.AddScoped<IDbInitializer, {Name}DbInitializer>();\n\n        // Only if the module HANDLES integration events:\n        // builder.Services.AddIntegrationEventHandlers(typeof({Name}Module).Assembly);\n        //\n        // Publishing needs no registration at all — the outbox is framework-owned\n        // (host calls AddEventingCore once). Inject IOutboxWriter and publish.\n        // Never register a per-module outbox store; see .agents/rules/eventing.md.\n\n        builder.Services.AddHealthChecks()\n            .AddDbContextCheck<{Name}DbContext>(name: \"db:{name}\");\n    }\n\n    public void ConfigureMiddleware(IApplicationBuilder app) { }   // optional, runs after auth\n\n    public void MapEndpoints(IEndpointRouteBuilder endpoints)\n    {\n        ArgumentNullException.ThrowIfNull(endpoints);\n        var versionSet = endpoints.NewApiVersionSet().HasApiVersion(new ApiVersion(1)).ReportApiVersions().Build();\n        var group = endpoints.MapGroup(\"api/v{version:apiVersion}/{name}\")\n            .WithTags(\"{Name}\").WithApiVersionSet(versionSet).RequireAuthorization();\n        // group.MapCreate{Entity}Endpoint();  …\n    }\n}\n```\n\n`Order` controls load sequence (Auditing 300, Files 350, Webhooks 400, Billing 500, Catalog 600, Tickets 700, Notifications 750, Chat 800). If your module consumes another's events, load after it.\n\n## Step 2 — Permissions (Contracts/Authorization)\n\n`{Name}Permissions` with nested resource classes and an `All` collection registered via `PermissionConstants.Register({Name}Permissions.All)`. Mirror the shape of `CatalogPermissions`.\n\n## Step 3 — DbContext (extends `BaseDbContext`)\n\n```csharp\npublic sealed class {Name}DbContext : BaseDbContext\n{\n    public const string Schema = \"{name}\";\n\n    public {Name}DbContext(\n        IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor,\n        DbContextOptions<{Name}DbContext> options,\n        IOptions<DatabaseOptions> settings,\n        IHostEnvironment environment)\n        : base(multiTenantContextAccessor, options, settings, environment) { }\n\n    public DbSet<{Entity}> {Entities} => Set<{Entity}>();\n\n    protected override void OnModelCreating(ModelBuilder modelBuilder)\n    {\n        ArgumentNullException.ThrowIfNull(modelBuilder);\n        modelBuilder.HasDefaultSchema(Schema);\n        modelBuilder.ApplyConfigurationsFromAssembly(typeof({Name}DbContext).Assembly);\n        base.OnModelCreating(modelBuilder);   // MUST be last — applies tenant + soft-delete filters\n    }\n}\n```\n\n## Step 4 — Solution + project references\n\n```bash\ndotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}/Modules.{Name}.csproj\ndotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}.Contracts/Modules.{Name}.Contracts.csproj\n```\n\nAdd a `<ProjectReference>` to the runtime module from **both** `FSH.Starter.Api` and `FSH.Starter.DbMigrator`, and reference the runtime project from `FSH.Starter.Migrations.PostgreSQL`.\n\n## Step 5 — Migrations folder\n\nAdd a `{Name}/` folder in `src/Host/FSH.Starter.Migrations.PostgreSQL`, then create the initial migration (see **create-migration**) with `--context {Name}DbContext`.\n\n## Step 6 — ⚠️ Register in ALL FOUR places (the footgun)\n\nIdentical edits in **both** `FSH.Starter.Api/Program.cs` **and** `FSH.Starter.DbMigrator/Program.cs`:\n\n1. Mediator `o.Assemblies` — add **two** markers: a Contracts type (e.g. `typeof(FSH.Modules.{Name}.Contracts.{Name}ContractsMarker)`) **and** the module type (`typeof({Name}Module)`).\n2. `moduleAssemblies` array — add `typeof({Name}Module).Assembly`.\n\nMiss the Mediator marker → handlers silently undiscovered. Miss the assembly entry → module never loads. Miss the DbMigrator pair → migrate/seed skips the module.\n\n## Step 7 — Verify\n\n```bash\ndotnet build src/FSH.Starter.slnx                  # 0 warnings\ndotnet test src/Tests/Architecture.Tests           # boundary + tenant-isolation rules must pass\ndotnet test src/FSH.Starter.slnx\n```\n\n## Checklist\n\n- [ ] Two projects (copied csproj), added to `.slnx`, referenced from Api + DbMigrator (+ Migrations)\n- [ ] `[assembly: FshModule(typeof({Name}Module), order)]` (assembly-level, positional)\n- [ ] `IModule`: `AddHeroDbContext<T>()`, `PermissionConstants.Register`, version-set group, eventing trio if needed\n- [ ] `{Name}DbContext : BaseDbContext`, 4-arg ctor, `base.OnModelCreating` last\n- [ ] `{Name}Permissions` in Contracts/Authorization\n- [ ] Migrations folder + initial migration (`--context {Name}DbContext`)\n- [ ] **Registered in all four places** (Api + DbMigrator × Mediator + moduleAssemblies)\n- [ ] Build + Architecture.Tests green\n",".agents/skills/add-permission/SKILL.md":"---\nname: add-permission\ndescription: Add a new permission end-to-end — server constant + endpoint gate, and (admin app) mirror it into the permissions catalog + route guard. Use when a new endpoint needs authorization. See modules/identity.md + frontend/admin.md.\nargument-hint: \"[ModuleName] [Resource] [Action]\"\n---\n\n# Add Permission\n\nA permission spans server + the admin app. The dashboard app does **not** mirror permissions — it reads\nthem from the JWT and relies on the server's 403.\n\n## Step 1 — Server constant (`Modules.{X}.Contracts/Authorization/{X}Permissions.cs`)\n\nAdd the constant to the resource group and ensure it's in the module's `All` collection. Convention:\n`Permissions.{Resource}.{Action}`.\n\n```csharp\npublic static class {X}Permissions\n{\n    public static class {Resources}\n    {\n        public const string View   = \"Permissions.{Resources}.View\";\n        public const string Create = \"Permissions.{Resources}.Create\";   // ← new\n    }\n    public static IReadOnlyList<FshPermission> All { get; } = [ /* … include the new one … */ ];\n}\n```\n\nThe module already calls `PermissionConstants.Register({X}Permissions.All)` in `ConfigureServices`, so a new entry in `All` is picked up automatically.\n\n## Step 2 — Gate the endpoint\n\n```csharp\n.RequirePermission({X}Permissions.{Resources}.Create);\n```\n\n⚠️ `RequiredPermissionAttribute` implements `IRequiredPermissionMetadata`. **Never let a second/duplicate of that interface exist** — it silently disables **all** `.RequirePermission()` gates app-wide. (See `.agents/rules/modules/identity.md`.)\n\n## Step 3 — (admin only) mirror it\n\n`clients/admin/src/lib/permissions.ts` — add the matching string to the frozen tree (no runtime catalog endpoint exists; mirror by hand):\n\n```ts\nexport const {Module}Permissions = Object.freeze({\n  {Resources}: { View: \"Permissions.{Resources}.View\", Create: \"Permissions.{Resources}.Create\" },\n} as const);\n```\n\nIf it should appear in the Role editor UI, add a `PERMISSION_CATALOG` entry (`{ name, description, root?, basic? }` under the right category group).\n\n## Step 4 — (admin only) gate the route\n\n```tsx\n{ path: \"{resources}/new\",\n  element: <RouteGuard perms={[{Module}Permissions.{Resources}.Create]}><Create{Resource}Page /></RouteGuard> },\n```\n\n## Step 5 — (admin only) seed it in tests\n\nSo `RouteGuard` passes on first paint, add the new permission to the test seed set (`ADMIN_PERMS` in `clients/admin/tests/helpers/shell-mocks.ts`, used by `seedAuthedSession`).\n\n## Dashboard\n\nNo mirror, no `RouteGuard`. The JWT carries only role names — the app fetches the permission list from `GET /api/v1/identity/permissions` at hydration and the server enforces access; a missing permission yields a 403 the UI surfaces. Routes aren't permission-gated; to hide a nav entry, set `perm`/`anyPerm` on the item in `src/components/layout/nav-data.ts`. Permission-gated specs mock `GET /identity/permissions` with the grants they need (shell mocks stub it to `[]`).\n\n## Checklist\n\n- [ ] Server constant added to `{X}Permissions` **and** its `All` collection\n- [ ] Endpoint gated with `.RequirePermission(...)`; no duplicate `IRequiredPermissionMetadata`\n- [ ] (admin) mirrored in `lib/permissions.ts` (+ `PERMISSION_CATALOG` if role-editor-visible)\n- [ ] (admin) route wrapped in `<RouteGuard perms={[…]}>`; permission added to `ADMIN_PERMS` test seed\n- [ ] Build green; admin `test:e2e` green\n",".agents/skills/add-react-page/SKILL.md":"---\nname: add-react-page\ndescription: Add a list+create page to a React app (clients/admin or clients/dashboard) — API module, page, lazy route, (admin) permission gate, Playwright test. Use when adding any frontend screen. See .agents/rules/frontend/.\nargument-hint: \"[admin|dashboard] [Area] [Resource]\"\n---\n\n# Add React Page\n\nThe frontend slice. Read `.agents/rules/frontend/shared.md` plus the app file (`frontend/admin.md` /\n`frontend/dashboard.md`) — the two apps **deliberately diverge**:\n\n| | **admin** (operator) | **dashboard** (tenant) |\n|---|---|---|\n| Query params | PascalCase (`PageNumber`, `Search`) | camelCase (`pageNumber`, `search`) |\n| `PagedResponse<T>` | import from `@/lib/api-types` | re-declare inline in the api module |\n| Path constant | `const BASE = \"/api/v1/...\"` | inline the full path per call |\n| Forms | **react-hook-form + zod** | **hand-rolled** controlled inputs (no RHF/zod) |\n| List + create | separate routed pages (`list.tsx`, `create.tsx`) | one file with `<Dialog>` editors |\n| Route wrapper | `<RouteGuard perms={[…]}>` | `withSuspense(<X/>)` (no permission gate) |\n| Permissions | mirror in `src/lib/permissions.ts` | fetched from `GET /identity/permissions` (not JWT); nav gating via `perm`/`anyPerm` in `nav-data.ts`; no route guard — server 403 backstops |\n\nShared everywhere: types are **hand-written** (no codegen); `apiFetch<T>` from `@/lib/api-client`; `cn()` from `@/lib/cn`; `env.apiBase` from runtime `/config.json`; CVA `components/ui` + `components/list` primitives; Tailwind v4 CSS-first (tokens in `src/styles/globals.css`); `toast` from `sonner`; pages are **named exports**; `placeholderData: keepPreviousData` (v5).\n\n## Step 1 — API module (`src/api/{resource}.ts`)\n\nHand-write the DTO/param/input types and thin `apiFetch` functions.\n\n```ts\n// admin\nimport { apiFetch } from \"@/lib/api-client\";\nimport type { PagedResponse } from \"@/lib/api-types\";\nconst BASE = \"/api/v1/{module}/{resources}\";\n\nexport type {Resource}Dto = { id: string; name: string; /* … */ };\n\nexport async function search{Resources}(p: { pageNumber?: number; search?: string } = {}) {\n  const q = new URLSearchParams();\n  q.set(\"PageNumber\", String(p.pageNumber ?? 1));\n  q.set(\"PageSize\", \"10\");\n  if (p.search?.trim()) q.set(\"Search\", p.search.trim());\n  return apiFetch<PagedResponse<{Resource}Dto>>(`${BASE}/search?${q}`);\n}\nexport async function create{Resource}(input: Create{Resource}Input) {\n  return apiFetch<{ id: string }>(BASE, { method: \"POST\", body: JSON.stringify(input) });\n}\n```\n\n(dashboard: inline `type PagedResponse<T> = …`, inline the path, camelCase params, mutations often return `Promise<string>`.)\n\n## Step 2 — Page (`src/pages/{area}/...tsx`, named export)\n\n```tsx\nexport function {Resource}ListPage() {\n  const [search, setSearch] = useState(\"\");           // debounce → reset page to 1 on change\n  const [pageNumber, setPage] = useState(1);\n  const query = useQuery({\n    queryKey: [\"{resources}\", { pageNumber, search }],   // hierarchical; params object last\n    queryFn: () => search{Resources}({ pageNumber, search: search || undefined }),\n    placeholderData: keepPreviousData,\n  });\n  // render with components/ui/* + components/list/* (admin: PageHeader/Field…; dashboard: Entity* family)\n}\n```\n\n## Step 3 — Mutation (race-safe `mutate(arg)`)\n\nPass per-call data through `mutate(arg)`; read it from the callback `variables` — never from a closed-over render variable.\n\n```tsx\nconst qc = useQueryClient();\nconst createMut = useMutation({\n  mutationFn: (input: Create{Resource}Input) => create{Resource}(input),\n  onSuccess: () => { toast.success(\"Created\"); qc.invalidateQueries({ queryKey: [\"{resources}\"] }); },\n  onError: (e) => toast.error(e instanceof ApiRequestError ? e.message : \"Failed\"),\n});\n// admin: const form = useForm({ resolver: zodResolver(schema) });  form.handleSubmit(v => createMut.mutate(v))\n// dashboard: controlled useState fields; onSubmit(e){ e.preventDefault(); createMut.mutate(payload); }\n```\n\nIf you need to track the in-flight item (e.g. a per-row busy state), use `onMutate: (arg) => setBusyId(arg)` reading the `mutate(arg)` value (pattern: `admin/src/pages/settings/sessions.tsx`).\n\n## Step 4 — Register the route (`routes.tsx`)\n\n```tsx\nconst {Resource}ListPage = lazyNamed(() => import(\"@/pages/{area}/list\"), \"{Resource}ListPage\");\n// admin — under AppShell.children, gated:\n{ path: \"{resources}\", element: <RouteGuard perms={[{Module}Permissions.{Resources}.View]}><{Resource}ListPage /></RouteGuard> },\n// dashboard — under AppShell.children, suspense only:\n{ path: \"{area}/{resources}\", element: withSuspense(<{Resource}ListPage />) },\n```\n\n## Step 5 — (admin only) mirror the permission\n\nAdd the constant to `src/lib/permissions.ts` (`{Module}Permissions.{Resources}.View` = `\"Permissions.{Resources}.View\"`), and a `PERMISSION_CATALOG` entry if it belongs in the Role editor. See `add-permission`.\n\n## Step 6 — Playwright test (`tests/{area}/{resource}.spec.ts`)\n\n```ts\ntest.beforeEach(async ({ page }) => {\n  // admin: seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); await installAdminShellMocks(page);\n  // dashboard: await seedAuthedSession(page, TEST_USER); await installShellMocks(page);\n  await mockJsonResponse(page, \"**/api/v1/{module}/{resources}**\", paged([SAMPLE]));   // page mocks AFTER shell mocks\n});\n```\n\nUse `mockProblemDetails(...)` for error states. Dashboard: scope row assertions with `.last()` / dialog scoping (lists render mobile + desktop copies → strict-mode double match).\n\n## Step 7 — Verify\n\n```bash\ncd clients/{app} && npm run lint && npm run test:e2e\n```\n\n## Checklist\n\n- [ ] API module: hand-written types, `apiFetch`, correct param casing per app (Pascal=admin, camel=dashboard)\n- [ ] Page is a **named export**; `useQuery` key hierarchical + `placeholderData: keepPreviousData`\n- [ ] Mutation passes data via `mutate(arg)`, invalidates in `onSuccess`\n- [ ] Route via `lazyNamed`; admin wraps in `<RouteGuard perms>`, dashboard in `withSuspense`\n- [ ] (admin) permission mirrored in `lib/permissions.ts`\n- [ ] Playwright test: seed + shell mocks + page mocks; `lint` + `test:e2e` green\n",".agents/skills/create-migration/SKILL.md":"---\nname: create-migration\ndescription: Create and apply an EF Core migration for a module's DbContext the FSH way (central Migrations project, per-module folder, correct --context). Use after changing entities/EF config. See .agents/rules/database.md.\nargument-hint: \"[ModuleName] [MigrationName]\"\n---\n\n# Create Migration\n\nAll migrations live in **one** project — `src/Host/FSH.Starter.Migrations.PostgreSQL` — but are foldered\n**per module/context** (`Catalog/`, `Identity/`, …), each with its own `{X}DbContextModelSnapshot`. The DB\nis **not** migrated at API startup; the `DbMigrator` host applies it.\n\n## Step 0 — restore the pinned tool (first time)\n\n```bash\ndotnet tool restore     # dotnet-ef is pinned in .config/dotnet-tools.json\n```\n\n## Step 1 — BUILD FIRST (snapshot footgun)\n\n`dotnet ef migrations add` reads the **current snapshot**, which is regenerated from a build. If you skip\nthe build after editing entities/config, you can generate against a stale snapshot and lose changes. Also,\n`migrations remove` rewrites the snapshot — only remove the latest, and rebuild after.\n\n```bash\ndotnet build src/FSH.Starter.slnx\n```\n\n## Step 2 — add the migration\n\nSpecify **all three** of `--project` (the Migrations project), `--startup-project` (the API host), and\n`--context {X}DbContext`. Use `--output-dir {X}` so it lands in that module's folder (match the existing\nfolder for the context).\n\n```bash\ndotnet ef migrations add {MigrationName} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {X}DbContext \\\n  --output-dir {X}\n```\n\n## Step 3 — review the generated SQL before applying\n\n```bash\ndotnet ef migrations script --idempotent \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {X}DbContext\n```\n\nCheck for: unintended table/column drops, non-nullable columns added without a default to an existing\ntable, and renames surfacing as drop+add (data loss). Adjust the model or hand-edit the migration if needed.\n\n## Step 4 — apply\n\nPreferred (the canonical path — migrates the tenant catalog then each tenant's per-module schema):\n\n```bash\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- apply\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending   # to preview first\n```\n\n(Or, single-context local dev, `dotnet ef database update --context {X}DbContext --project … --startup-project …`.)\n\n## Notes\n\n- A **new module** also needs a `{X}/` folder in the Migrations project and the runtime project referenced from it — see `add-module`.\n- `dotnet ef` against a `BaseDbContext` works because the 4-arg ctor is satisfied by the startup host's DI.\n\n## Checklist\n\n- [ ] `dotnet tool restore` done (first time)\n- [ ] Built **before** `migrations add`\n- [ ] `--context {X}DbContext` + `--output-dir {X}` (lands in the right folder)\n- [ ] Reviewed the generated SQL for data loss\n- [ ] Applied via DbMigrator `apply` (or `ef database update` for one context locally)\n",".agents/skills/mediator-reference/SKILL.md":"---\nname: mediator-reference\ndescription: CQRS interface reference for FSH. This project uses the Mediator source generator, NOT MediatR. Reference when implementing commands, queries, and handlers.\nuser-invocable: false\n---\n\n# Mediator Reference\n\n⚠️ **FSH uses the `Mediator` source-generator package (`using Mediator;`), NOT `MediatR`.** Different\ninterfaces — MediatR types won't compile. The CQRS interfaces below are the library's own (no FSH wrapper).\n\n## Interfaces\n\n| Purpose | ✅ Mediator (use) | ❌ MediatR (don't) |\n|---|---|---|\n| Command | `ICommand<TResponse>` | `IRequest<TResponse>` |\n| Query | `IQuery<TResponse>` | `IRequest<TResponse>` |\n| Command handler | `ICommandHandler<TCommand, TResponse>` | `IRequestHandler<…>` |\n| Query handler | `IQueryHandler<TQuery, TResponse>` | `IRequestHandler<…>` |\n| Notification / domain event | `INotification` (`IDomainEvent : INotification`) | `INotification` |\n\n## Pattern\n\n```csharp\n// Command/Query → the Contracts project (Modules.{X}.Contracts/v1/{Area}/)\npublic sealed record Create{Entity}Command(string Name) : ICommand<Guid>;\n\n// Handler → the runtime project (Modules.{X}/Features/v1/{Area}/{Feature}/), public sealed\npublic sealed class Create{Entity}CommandHandler({X}DbContext db)\n    : ICommandHandler<Create{Entity}Command, Guid>\n{\n    public async ValueTask<Guid> Handle(Create{Entity}Command command, CancellationToken cancellationToken)\n    {\n        // …\n    }\n}\n```\n\nRules: handlers return **`ValueTask<T>`** (not `Task<T>`); parameter named `command`/`query` (not `request`);\n`public sealed`; `.ConfigureAwait(false)` on awaits. Send via `mediator.Send(command, ct)` (the `IMediator`\ninterface name matches MediatR's — that part is fine).\n\n## Registration — the four places\n\nThe source generator only scans assemblies listed in `o.Assemblies`, and that list exists in **two host\nfiles**. A new module needs **two markers** (a Contracts type **and** the module type) added to the Mediator\nlist **plus** an entry in the `moduleAssemblies` array — in **both** `FSH.Starter.Api/Program.cs` **and**\n`FSH.Starter.DbMigrator/Program.cs`:\n\n```csharp\nbuilder.Services.AddMediator(o =>\n{\n    o.ServiceLifetime = ServiceLifetime.Scoped;\n    o.Assemblies = [\n        /* … */\n        typeof(FSH.Modules.{X}.Contracts.{X}ContractsMarker),   // Contracts assembly\n        typeof(FSH.Modules.{X}.{X}Module)];                     // runtime assembly\n});\n```\n\nSee `add-module` for the full procedure.\n\n## Common errors\n\n| Symptom | Cause → fix |\n|---|---|\n| `IRequest<T>` / `IRequestHandler<,>` not found | MediatR interface → use `ICommand`/`IQuery` + `ICommandHandler`/`IQueryHandler` |\n| `Task<T>` vs `ValueTask<T>` mismatch | Handler must return `ValueTask<T>` |\n| Handler not invoked at runtime | Assembly missing from `o.Assemblies` (in one or both Program.cs files) |\n",".agents/skills/query-patterns/SKILL.md":"---\nname: query-patterns\ndescription: Implement read queries — paginated lists, search/filter/sort, and single-entity fetches — the FSH way (DbContext LINQ + PagedResponse). Use when adding GET endpoints. See also add-feature.\n---\n\n# Query Patterns\n\nThe dominant pattern is **raw `IQueryable` on the module DbContext** (`AsNoTracking`) with manual\npagination. There is **no generic repository** and **no `PaginatedListAsync`/`EntitiesByPaginationFilterSpec`/\n`PaginationFilter`**. Paged results are `PagedResponse<T>` (`FSH.Framework.Shared.Persistence`) — there is no `PagedList<T>`.\n\n## Paginated search query\n\n```csharp\n// Contracts/v1/{Area}/\npublic sealed record Search{Entities}Query(\n    string? Search = null,\n    bool? IsActive = null,\n    int PageNumber = 1,\n    int PageSize = 20,\n    string? SortBy = null,\n    string? SortDir = null) : IQuery<PagedResponse<{Entity}Dto>>;\n```\n\n```csharp\n// Features/v1/{Area}/Search{Entities}/\npublic sealed class Search{Entities}QueryHandler({X}DbContext dbContext)\n    : IQueryHandler<Search{Entities}Query, PagedResponse<{Entity}Dto>>\n{\n    public async ValueTask<PagedResponse<{Entity}Dto>> Handle(\n        Search{Entities}Query query, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(query);\n        int page = query.PageNumber < 1 ? 1 : query.PageNumber;\n        int size = Math.Clamp(query.PageSize, 1, 100);\n\n        var q = dbContext.{Entities}.AsNoTracking().AsQueryable();\n        if (!string.IsNullOrWhiteSpace(query.Search))\n            q = q.Where(x => EF.Functions.ILike(x.Name, $\"%{query.Search}%\"));\n        if (query.IsActive is { } active)\n            q = q.Where(x => x.IsActive == active);\n\n        q = ApplySort(q, query.SortBy, query.SortDir);\n\n        long total = await q.LongCountAsync(cancellationToken).ConfigureAwait(false);\n        var items = await q.Skip(PaginationExtensions.GetOffset(page, size)).Take(size) // overflow-safe offset\n            .ToListAsync(cancellationToken).ConfigureAwait(false);\n\n        return new PagedResponse<{Entity}Dto>\n        {\n            Items = items.Select(x => x.ToDto()).ToList(),\n            PageNumber = page, PageSize = size,\n            TotalCount = total,\n            TotalPages = (int)Math.Ceiling(total / (double)size)\n        };\n    }\n\n    private static IQueryable<{Entity}> ApplySort(IQueryable<{Entity}> q, string? by, string? dir)\n    {\n        bool desc = string.Equals(dir, \"desc\", StringComparison.OrdinalIgnoreCase);\n        return by?.ToLowerInvariant() switch\n        {\n            \"name\" => desc ? q.OrderByDescending(x => x.Name) : q.OrderBy(x => x.Name),\n            _      => q.OrderByDescending(x => x.CreatedOnUtc)\n        };\n    }\n}\n```\n\nTenant + soft-delete filters apply automatically — don't re-filter them. Project to a DTO (`.ToDto()` mapper); never return entities.\n\n## Single-entity query\n\n```csharp\npublic sealed record Get{Entity}Query(Guid Id) : IQuery<{Entity}Dto>;\n\npublic sealed class Get{Entity}QueryHandler({X}DbContext dbContext)\n    : IQueryHandler<Get{Entity}Query, {Entity}Dto>\n{\n    public async ValueTask<{Entity}Dto> Handle(Get{Entity}Query query, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(query);\n        var entity = await dbContext.{Entities}.AsNoTracking()\n            .FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken).ConfigureAwait(false)\n            ?? throw new NotFoundException($\"{Entity} {query.Id} not found\");\n        return entity.ToDto();\n    }\n}\n```\n\n## Endpoints\n\n```csharp\n// list — bind the query with [AsParameters]\nendpoints.MapGet(\"/{entities}\", async ([AsParameters] Search{Entities}Query query,\n        IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(query, ct)))\n    .WithName(\"Search{Entities}\").RequirePermission({X}Permissions.{Entities}.View);\n\n// single\nendpoints.MapGet(\"/{entities}/{id:guid}\", async (Guid id,\n        IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(new Get{Entity}Query(id), ct)))\n    .WithName(\"Get{Entity}\").RequirePermission({X}Permissions.{Entities}.View);\n```\n\nA paginated query **needs a validator** (`Search{Entities}QueryValidator`: `PageNumber >= 1`, `PageSize` in `[1,100]`) — enforced by `Architecture.Tests`.\n\n## When to use a Specification instead\n\n`Specification<T>` (`src/BuildingBlocks/Persistence/Specifications/`) is for **composing reusable query\nshapes** (`protected Where(...)`/`Include(...)`/`OrderBy(...)` in a derived spec's ctor; `AsNoTracking`\ndefaults true; specs never paginate). Reach for it when the same filter/include set is shared across\nhandlers; otherwise inline LINQ is the norm here.\n",".agents/skills/testing-guide/SKILL.md":"---\nname: testing-guide\ndescription: Write tests for an FSH feature — xUnit + Shouldly + NSubstitute + AutoFixture, with naming and AAA conventions. Use when adding unit/handler/validator/entity tests. Full rules in .agents/rules/testing.md + integration-testing.md.\n---\n\n# Testing Guide\n\nStack: **xUnit** + **Shouldly** (`.ShouldBe`) + **NSubstitute** (`Substitute.For<>`) + **AutoFixture**\n(`new Fixture()`). **Not** Moq, **not** FluentAssertions. Detailed conventions + integration-test gotchas\nlive in `.agents/rules/testing.md` and `.agents/rules/integration-testing.md`.\n\n## Conventions\n\n- Test class: `public sealed class {Sut}Tests`; SUT field named `_sut`.\n- Method name: **`MethodName_Should_ExpectedBehavior[_When_Condition]`**.\n- Arrange-Act-Assert with `// Arrange` / `// Act` / `// Assert`; group with `#region` (Happy Path / Guards / Edge Cases).\n- Mocks via `Substitute.For<IService>()`; assert calls with `.Received(1).X(arg, Arg.Any<CancellationToken>())`.\n- When asserting a forwarded `CancellationToken`, assert the **specific** token, not the default (NSubstitute fills optional params with `default`).\n\n## Handler test\n\n```csharp\npublic sealed class Create{Entity}CommandHandlerTests\n{\n    private readonly {X}DbContext _db;            // or Substitute.For<IService>() for service deps\n    private readonly Create{Entity}CommandHandler _sut;\n    private readonly IFixture _fixture = new Fixture();\n\n    public Create{Entity}CommandHandlerTests()\n    {\n        _db = /* in-memory or test DbContext */;\n        _sut = new Create{Entity}CommandHandler(_db);\n    }\n\n    [Fact]\n    public async Task Handle_Should_PersistEntity_And_ReturnId()\n    {\n        // Arrange\n        var command = new Create{Entity}Command(_fixture.Create<string>(), 9.99m, \"USD\");\n\n        // Act\n        var id = await _sut.Handle(command, CancellationToken.None);\n\n        // Assert\n        id.ShouldNotBe(Guid.Empty);\n    }\n}\n```\n\nService-dependency example (NSubstitute):\n\n```csharp\n_userService = Substitute.For<IUserService>();\n// Act … then:\nawait _userService.Received(1).ToggleStatusAsync(true, command.UserId, Arg.Any<CancellationToken>());\n```\n\n## Validator test\n\n```csharp\npublic sealed class Create{Entity}CommandValidatorTests\n{\n    private readonly Create{Entity}CommandValidator _sut = new();\n\n    [Theory]\n    [InlineData(\"\")]\n    public void Validate_Should_Fail_When_NameInvalid(string name)\n    {\n        var result = _sut.Validate(new Create{Entity}Command(name, 1m, \"USD\"));\n        result.IsValid.ShouldBeFalse();\n        result.Errors.ShouldContain(e => e.PropertyName == nameof(Create{Entity}Command.Name));\n    }\n}\n```\n\n## Entity / domain test (no mocks)\n\n```csharp\n[Fact]\npublic void Create_Should_RaiseCreatedEvent()\n{\n    var entity = {Entity}.Create(\"Test\", Money.Zero());\n    entity.Id.ShouldNotBe(Guid.Empty);\n    entity.DomainEvents.ShouldContain(e => e is {Entity}CreatedDomainEvent);\n}\n```\n\n## Architecture tests (guardrails — keep green)\n\n`Architecture.Tests` (NetArchTest) enforce: module boundaries (cross-module refs only via `.Contracts`),\ntenant-isolation rules, handlers `sealed`, and **every command/paginated-query handler has a validator**.\nDon't weaken these to make a change pass — fix the code.\n\n## Integration tests\n\n`Integration.Tests` runs over real Postgres/Redis/RustFS via Testcontainers — **Docker required**. Set the\nFinbuckle tenant context inline, rewire `IStorageService` post-registration for RustFS (S3), force long-polling\nfor SignalR. All detailed in `.agents/rules/integration-testing.md`.\n\n## Run\n\n```bash\ndotnet test src/Tests/{X}.Tests\ndotnet test src/Tests/Architecture.Tests\ndotnet test src/FSH.Starter.slnx --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\n","AGENTS.md":"# FullStackHero .NET Starter Kit\n\n> A production-ready modular .NET 10 monolith + two React 19 apps, built for enterprise SaaS.\n\nThis file is the canonical guide for **all** AI coding tools (Claude Code, Gemini CLI, Cursor, Codex, …).\n`CLAUDE.md` and `GEMINI.md` are thin bridges that import this file — edit conventions **here**, not there.\n\nThis file is the map. Detailed conventions live in `.agents/rules/` and are read on demand — **read the\nrelevant rule file before working in that area** (see the index below). Keep this file lean.\n\n## What this is\n\nA **modular monolith** (Vertical Slice Architecture) backend that ships with two **React + Vite**\nfront-ends and a CLI. Multitenancy, auth, auditing, billing, files, chat and more are first-class.\n\n- **Backend** — .NET 10, EF Core 10, PostgreSQL, Redis, JWT + ASP.NET Identity, Finbuckle multitenancy,\n  Hangfire, OpenAPI/Scalar, Serilog + OpenTelemetry, .NET Aspire.\n- **Frontends** — `clients/admin` (operator-facing) and `clients/dashboard` (tenant-facing): React 19,\n  Vite 7, TypeScript, TanStack Query v5, React Router 7, Radix + Tailwind v4 (shadcn-style), SignalR/SSE.\n\n## Repo map\n\n| Path | What |\n|------|------|\n| `src/BuildingBlocks/` | Shared framework libraries (Core, Persistence, Web, Caching, Eventing, Storage, Quota…). **Protected — see below.** |\n| `src/Modules/{Name}/` | Bounded contexts. Each has a runtime project + a `.Contracts` project (its only public API). |\n| `src/Host/FSH.Starter.Api` | Composition-root Web API host. |\n| `src/Host/FSH.Starter.AppHost` | .NET Aspire orchestrator (Postgres, Redis, RustFS, migrator, API, **both React apps**). |\n| `src/Host/FSH.Starter.DbMigrator` | One-shot migrate/seed runner. DB is **not** migrated at API startup. |\n| `src/Host/FSH.Starter.Migrations.PostgreSQL` | All EF migrations, organized per-module by folder. |\n| `src/Tests/` | Per-module tests, `Architecture.Tests` (NetArchTest), `Integration.Tests` (Testcontainers). |\n| `clients/admin`, `clients/dashboard` | The two React apps. |\n| `deploy/` | Infra (docker, terraform, dokploy). |\n\n<!--#if (includeTools) -->\nThe `fsh` CLI (Spectre.Console) lives in `src/Tools/CLI`.\n\n<!--#endif -->\n## Tech stack\n\n| Backend | | Frontend | |\n|---|---|---|---|\n| Framework | .NET 10 / C# latest | Framework | React 19 + Vite 7 + TS 5.x |\n| CQRS | Mediator 3.x (source-gen) | Data | TanStack Query v5 |\n| Validation | FluentValidation 12.x | Routing | React Router 7 |\n| ORM / DB | EF Core 10 / PostgreSQL (Npgsql) | UI | Radix + Tailwind v4 + CVA (shadcn) |\n| Auth | JWT Bearer + ASP.NET Identity | Forms | react-hook-form + zod (**admin only**) |\n| Multitenancy | Finbuckle 10.x | Realtime | `@microsoft/signalr`, SSE (dashboard) |\n| Cache / Jobs | Redis, Hangfire | Tests | Playwright (route-mocked) |\n| Docs | OpenAPI + Scalar | API client | hand-written `apiFetch` (no codegen) |\n| Hosting | .NET Aspire | Env | runtime `/config.json` (not `VITE_*`) |\n| Testing | xUnit, Shouldly, NSubstitute, AutoFixture, NetArchTest, Testcontainers | | |\n\n## Build & run\n\n```bash\n# Whole stack (Postgres + pgAdmin + Redis + RustFS + migrator + API + both React apps)\ndotnet run --project src/Host/FSH.Starter.AppHost   # one-time: npm install in clients/admin & clients/dashboard\n\ndotnet build src/FSH.Starter.slnx                   # build backend\ndotnet run --project src/Host/FSH.Starter.Api       # API only → https://localhost:7030 (/scalar)\ndotnet test src/FSH.Starter.slnx                    # tests — integration tests REQUIRE Docker\n\ncd clients/admin && npm install && npm run dev       # → http://localhost:5173\ncd clients/dashboard && npm install && npm run dev   # → http://localhost:5174\n```\n\nMigrations / seed (DbMigrator, separate step):\n```bash\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- apply [--seed]\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending\n```\n\n**Ports:** API 7030 (https)/5030 (http) · admin 5173 · dashboard 5174 · Postgres 5432 · pgAdmin 5050 · Valkey 6379 · RustFS 9000/9001 (S3 API/console).\n\n<!--#if (includeTools) -->\n## Branching & PRs\n\nSingle long-lived branch: **`main`** (the default) — there is **no `develop`**. Branch from and target `main`; stable releases are cut from `v*` tags. CI is split into path-scoped **Backend CI** (`src/**`) and **Frontend CI** (`clients/**`) workflows; branch protection requires only those two gate checks — never the individual jobs, which are skipped on the other side's PRs.\n\n<!--#endif -->\n## Golden rules (do not break)\n\n1. **Module boundaries** — a module references another module only through its `.Contracts` project, never its runtime project. Enforced by `Architecture.Tests`.\n2. **Registering a module touches FOUR places** — `Program.cs` Mediator `o.Assemblies` (two markers each) + `moduleAssemblies` array, **and the identical pair in `DbMigrator/Program.cs`**. A missing Mediator marker = handlers silently undiscovered. See `architecture.md`.\n3. **Tenant isolation is default-ON** via `BaseDbContext`. Opt out only via `IGlobalEntity`. Subclass DbContexts call `base.OnModelCreating` **last**. See `database.md`.\n4. **Do NOT modify `src/BuildingBlocks`** without explicit approval — shared by every module, wide blast radius.\n5. **Mediator handlers must be `public sealed`**, return `ValueTask<T>`, and `.ConfigureAwait(false)` every await.\n6. **Structured logging only** — no string interpolation in log messages; use message templates / `[LoggerMessage]`.\n7. **Propagate `CancellationToken`** into every EF/IO call; add as `= default` on public service methods.\n8. **Every command handler + paginated query handler needs a validator** (`{Name}Validator`). Enforced by `Architecture.Tests`.\n9. **Frontend: pass per-call data through `mutate(arg)`**, never via state the mutation callbacks close over (execute-time race). See `frontend/shared.md`.\n<!--#if (includeTools) -->\n10. **Docs + changelog travel with the change** — a user-facing change (feature, endpoint, config, infra, breaking change) isn't done until the **separate docs repo** (`github.com/fullstackhero/docs`, the Astro site) is updated to match **and** a changelog entry is added (`src/content/docs/changelog/`). Don't let the docs drift from the code.\n<!--#endif -->\n\n## Rules index — read the relevant file before you work\n\n**Backend / cross-cutting** (`.agents/rules/`)\n\n| Working on… | Read |\n|---|---|\n| Module structure, boundaries, registration, DI, middleware order, config | `architecture.md` |\n| Endpoints, CQRS, validation, exceptions, permissions, versioning | `api-conventions.md` |\n| EF Core, entities, migrations, tenant isolation, query filters | `database.md` |\n| Cross-module events, Outbox/Inbox, idempotent handlers | `eventing.md` |\n| Caching (HybridCache/Redis), keys, invalidation | `caching.md` |\n| Background jobs (Hangfire), recurring jobs | `jobs.md` |\n| Outbound HTTP resilience (Polly) | `resilience.md` |\n| Files/blobs, presigned uploads, providers | `storage.md` |\n| CORS, security headers, rate limiting, idempotency, quotas, front-end link origins | `security.md` |\n| SignalR / SSE backend | `realtime.md` |\n| Logging, correlation, OpenTelemetry | `logging.md` |\n| Localization (i18n), request culture, resource catalogs, localized exceptions | `localization.md` |\n| Unit test conventions, NetArchTest | `testing.md` |\n| Integration tests (Testcontainers harness + gotchas) | `integration-testing.md` |\n| **Modifying `src/BuildingBlocks`** (read first — it's protected) | `buildingblocks-protection.md` |\n| A specific module's quirks | `modules/{module}.md` (identity, multitenancy, chat, files, webhooks, auditing, billing, catalog, tickets, notifications) |\n\n**Frontend** (`.agents/rules/frontend/`)\n\n| Working on… | Read |\n|---|---|\n| Any React work (shared stack, API client, Query, Tailwind, design language) | `frontend/shared.md` |\n| The operator app (`clients/admin`) | `frontend/admin.md` |\n| The tenant app (`clients/dashboard`) | `frontend/dashboard.md` |\n\n## Coding style (backend)\n\nFile-scoped namespaces · 4-space indent · explicit types (`var` only when RHS-obvious) · `is null` /\n`is not null` · pattern matching + switch expressions · `ArgumentNullException.ThrowIfNull` guards ·\nrecords for DTOs/events/value objects · `default!` for required non-nullable strings. Build runs with\n`TreatWarningsAsErrors` — warnings fail the build.\n\n## Adding things (quick pointers)\n\n- **Feature** — Contracts command/query → handler → validator → endpoint → wire in module `MapEndpoints()` → tests. Details: `api-conventions.md`.\n- **Module** — new `Modules.{Name}` + `.Contracts`, implement `IModule` w/ assembly-level `[assembly: FshModule(typeof(XModule), order)]`, register in **all four places**, add migration folder + tests. Details: `architecture.md`.\n- **React page** — API module (`src/api/`) → page → register lazy route → (admin) mirror permission + RouteGuard → Playwright test. Details: `frontend/shared.md`.\n\n## AI tooling resources\n\n- **Rules** — `.agents/rules/*.md` (indexed above). Read on demand.\n- **Skills** — `.agents/skills/*/SKILL.md`: step-by-step task recipes. Scaffolders: `add-feature`, `add-entity`, `add-module`, `add-react-page`, `add-full-slice`. Ops: `create-migration`, `add-integration-event`, `add-permission`. Reference: `query-patterns`, `testing-guide`, `mediator-reference`.\n- **Workflows** — `.agents/workflows/*.md`: task playbooks (`code-reviewer`, `feature-scaffolder`, `module-creator`, `architecture-guard`, `migration-helper`).\n","CLAUDE.md":"# Claude Code\n\nThe canonical project guide is **`AGENTS.md`** (tool-neutral). It is imported below; edit conventions there, not here.\n\n@AGENTS.md\n"},"files":{".agents/rules/api-conventions.md":"# API conventions\n\nRead before adding endpoints, commands/queries, validators, or error handling.\n\n## Endpoints\n\nStatic extension methods on `IEndpointRouteBuilder`, returning `RouteHandlerBuilder`. The handler delegates to Mediator. Gate with `.RequirePermission(...)`.\n\n```csharp\npublic static class RegisterUserEndpoint\n{\n    internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) =>\n        endpoints.MapPost(\"/register\", (RegisterUserCommand command,\n                IMediator mediator, CancellationToken cancellationToken) =>\n                mediator.Send(command, cancellationToken))\n            .WithName(\"RegisterUser\")\n            .WithSummary(\"Register user\")\n            .RequirePermission(IdentityPermissionConstants.Users.Create);\n}\n```\n\n- **Always accept and forward `CancellationToken`** to `mediator.Send`. ASP.NET injects it.\n- Wire each endpoint in the module's `MapEndpoints()`. Endpoints group under `api/v{version:apiVersion}/{module}`.\n- Use `TypedResults` / `.Produces<T>(...)` for accurate OpenAPI. Add `.WithIdempotency()` on POSTs that must be replay-safe.\n\n## CQRS\n\n- **Commands/Queries** live in `Modules.{Name}.Contracts` — implement `ICommand<TResponse>` / `IQuery<TResponse>`. Records preferred.\n- **Handlers** live in `Modules.{Name}/Features/` — `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on awaits.\n- Paginated queries implement `IPagedQuery` (`PageNumber`, `PageSize`, `Sort`) and return `PagedResponse<T>`.\n\n## Validation\n\nFluentValidation, auto-registered by `ModuleLoader`. Name `{Command}Validator`. Live in the same feature folder.\n\n- **Every command handler needs a validator; every paginated query handler needs one too.** Enforced by `Architecture.Tests` (`HandlerValidatorPairingTests`). A handler legitimately without rules can be added to that test's known-missing allowlist, but prefer writing the validator.\n- Validators run via the `ValidationBehavior<,>` Mediator pipeline before the handler.\n\n## Exceptions → ProblemDetails\n\nThrow framework exception types; the global handler converts to RFC 9457 `ProblemDetails`:\n\n| Throw | HTTP |\n|---|---|\n| `NotFoundException` | 404 |\n| `ForbiddenException` | 403 |\n| `UnauthorizedException` | 401 |\n| `CustomException(msg, errors?, HttpStatusCode)` | as specified (default 400) |\n\nDon't catch broadly to swallow. Background loops may `catch (Exception)` to stay alive, but must **log with context** and exclude `OperationCanceledException` (filtered catch or a preceding `catch (OperationCanceledException)`).\n\n## Permissions\n\nConstants in `Shared/Identity/*Permissions.cs` (e.g. `IdentityPermissionConstants`). Apply with `.RequirePermission(...)` on the endpoint. `RequiredPermissionAttribute` implements `IRequiredPermissionMetadata` — never let a duplicate of that interface appear; it silently disables **all** `.RequirePermission()` gates.\n\n## Specifications\n\nUse `Specification<T>` (`src/BuildingBlocks/Persistence/Specifications/`) for query composition. Default `AsNoTracking = true` — see `database.md` for when tracking is required instead.\n\n## Adding a feature (checklist)\n\n1. Command/query + response in `Modules.{Name}.Contracts/v1/{Area}/{Feature}/`.\n2. Handler in `Modules.{Name}/Features/v1/{Area}/{Feature}/`.\n3. Validator in the same folder.\n4. Endpoint in the same folder; wire in module `MapEndpoints()`.\n5. Tests in `Tests/{Name}.Tests/` (+ integration test if it touches DB/IO).\n",".agents/rules/architecture.md":"# Architecture rules\n\nModular Monolith + Vertical Slice Architecture (VSA). Read this before adding/moving modules or touching wiring.\n\n## Layers & dependency direction\n\n```\nHost (composition root)  →  Modules.{Name} (runtime)  →  Modules.{Name}.Contracts (public API)\n                         →  BuildingBlocks (shared framework)\n```\n\n- **BuildingBlocks** (`src/BuildingBlocks/`) — Core, Persistence, Web, Caching, Eventing, Storage, Quota, Jobs, Mailing, Shared. Consumed by all modules. **Do not modify without explicit approval.**\n- **Modules** (`src/Modules/{Name}/`) — bounded contexts. Each = a runtime project (internal) + a `.Contracts` project (public API: commands, queries, events, DTOs, service interfaces).\n- A module **MUST NOT** reference another module's runtime project — only its `.Contracts`. Enforced by `Architecture.Tests` (NetArchTest).\n\n## Module = runtime + Contracts\n\n```\nModules.Identity/            ← runtime (internal): handlers, services, domain, data\nModules.Identity.Contracts/  ← public: ICommand/IQuery types, DTOs, events, service interfaces\n```\n\nCross-module communication: through Contracts service interfaces or integration events only.\n\n## Feature folder layout (VSA)\n\nEach feature is a vertical slice in `Features/v{version}/{Area}/{Feature}/`:\n\n```\nFeatures/v1/Users/RegisterUser/\n├── RegisterUserEndpoint.cs          # minimal API endpoint\n├── RegisterUserCommandHandler.cs    # CQRS handler (public sealed)\n└── RegisterUserCommandValidator.cs  # FluentValidation\n```\n\nModule support folders: `Domain/`, `Data/`, `Services/`, `Events/`, `Authorization/`.\n\n## IModule registration\n\nEach module implements `IModule`, declared via an **assembly-level** `[FshModule]` attribute (positional `(Type moduleType, int order = 0)`) — **not** a class-level `[FshModule(Order = n)]`:\n\n```csharp\n[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 1)]   // above the namespace\n\nnamespace FSH.Modules.Identity;\n\npublic sealed class IdentityModule : IModule\n{\n    public void ConfigureServices(IHostApplicationBuilder builder) { ... }\n    public void ConfigureMiddleware(IApplicationBuilder app) { ... }   // optional, runs AFTER UseAuthentication\n    public void MapEndpoints(IEndpointRouteBuilder endpoints) { ... }\n}\n```\n\n`ModuleLoader.AddModules` (`src/BuildingBlocks/Web/Modules/ModuleLoader.cs`) discovers `[FshModule]` attributes, orders by `Order` then name, instantiates each, and calls `ConfigureServices`. Endpoints map under `api/v{version:apiVersion}/{module}`.\n\n## ⚠️ The four-place registration footgun\n\nAdding a module requires editing **four** lists. Miss one and it fails *silently*:\n\n| Place | File | Symptom if missed |\n|---|---|---|\n| Mediator `o.Assemblies` (two markers: Contracts type **and** module type) | `src/Host/FSH.Starter.Api/Program.cs` | Handlers silently undiscovered |\n| `moduleAssemblies` array | `src/Host/FSH.Starter.Api/Program.cs` | Module never loaded |\n| Mediator assemblies (same pair) | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |\n| module assemblies array | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |\n\nAfter wiring, the fastest sanity check is: build, hit the endpoint, confirm the handler runs.\n\n## DI & handler conventions\n\n- Mediator handlers: `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on every await. `ServiceLifetime.Scoped`.\n- Validators auto-register via `ModuleLoader` (`AddValidatorsFromAssemblies`). Name them `{Command}Validator`.\n- Prefer constructor injection / primary constructors. Watch DI lifetimes: stateful singletons must be thread-safe (use `ConcurrentDictionary` / immutable snapshots).\n\n## Middleware ordering (critical)\n\nIn `src/BuildingBlocks/Web/Extensions.cs` (`UseHeroPlatform`):\n\n1. ExceptionHandler → ResponseCompression\n2. **CORS before HTTPS redirect** (so OPTIONS preflight isn't 307-redirected)\n3. HttpsRedirection → SecurityHeaders → static files → Routing\n4. **`UseAuthentication`**\n5. **`UseHeroLocalization`** — request localization, sits **between `UseAuthentication` and `UseAuthorization`** so the user-`locale`-claim culture provider can read `HttpContext.User`\n6. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth\n7. RateLimiting → Quotas → `UseAuthorization` → `MapModules`\n\n`app.UseHeroMultiTenantDatabases()` (Finbuckle `UseMultiTenant()`) runs in `Program.cs` **before** `UseHeroPlatform`, i.e. **before `UseAuthentication`**. Finbuckle's `ClaimStrategy` authenticates on its own, so authenticated requests resolve by tenant claim and anonymous ones by the `tenant` header. See `modules/multitenancy.md`.\n\n## Static/global state\n\nNo global mutable static collections enumerated under concurrency. `Audit` (Auditing module) swaps an immutable `IAuditEnricher[]` atomically; `ModuleLoader` guards with a lock. Follow that pattern if you must hold process-global state.\n\n## Configuration & options\n\n- `appsettings.json` (+ `.Development`/`.Production`) live in `src/Host/FSH.Starter.Api/`. DbMigrator links the same files.\n- Bind config with the Options pattern: `AddOptions<T>().BindConfiguration(nameof(T))`, section name == type name (e.g. `JwtOptions`, `DatabaseOptions`, `CachingOptions`, `CorsOptions`, `QuotaOptions`, `RateLimitingOptions`; **storage section is `Storage`**, not `StorageOptions`). Add `.ValidateDataAnnotations().ValidateOnStart()` for fail-fast.\n- Validate critical options via `IValidatableObject` — `JwtOptions` requires `SigningKey` ≥32 chars and **rejects placeholder strings containing `\"replace-with\"`**; `DatabaseOptions` rejects empty connection strings.\n- **Production fail-fast** (`Program.cs`, before service registration): missing `DatabaseOptions:ConnectionString`, `CachingOptions:Redis`, or `JwtOptions:SigningKey` throws. Dev secrets via `dotnet user-secrets` (AppHost has a `UserSecretsId`); RustFS (S3) creds are Aspire secret parameters.\n- Platform composition is one call each: `builder.AddHeroPlatform(o => { o.Enable... })` (DI) and `app.UseHeroPlatform(...)` (middleware). Feature flags toggle Caching/Jobs/Mailing/Quotas/Sse/Realtime/OpenTelemetry/CORS/Idempotency.\n",".agents/rules/buildingblocks-protection.md":"---\npaths:\n  - \"src/BuildingBlocks/**/*\"\n---\n\n# ⚠️ BuildingBlocks Protection\n\n**STOP. You are modifying BuildingBlocks.**\n\nChanges to BuildingBlocks affect ALL modules across the entire framework. These are core abstractions that many projects depend on.\n\n## Before Proceeding\n\n1. **Confirm explicit approval** - Has the user specifically approved this change?\n2. **Consider alternatives** - Can this be done in the module instead?\n3. **Assess impact** - What modules will this affect?\n\n## If Approved\n\n- Make minimal, focused changes\n- Ensure backward compatibility\n- Update all affected modules\n- Run full test suite: `dotnet test src/FSH.Starter.slnx`\n- Document the change\n\n## Alternatives to Consider\n\n| Instead of... | Consider... |\n|---------------|-------------|\n| Modifying Core | Extension method in module |\n| Changing Persistence | Custom repository in module |\n| Updating Web | Module-specific middleware |\n\n## If Not Approved\n\nDo not proceed. Suggest alternatives that don't require BuildingBlocks modifications.\n",".agents/rules/caching.md":"# Caching\n\n`src/BuildingBlocks/Caching/`. Read before adding cached reads or invalidation.\n\n## What's registered\n\n`AddHeroCaching(config)` always registers **`HybridCache`** (L1 in-memory + optional L2 Redis). Inject `HybridCache`, not `IDistributedCache`.\n\n- `CachingOptions.Redis` empty → in-memory only (dev fallback). Set → a **single shared `ConnectionMultiplexer`** (singleton `IConnectionMultiplexer`) backs both the L2 cache and the DataProtection key ring.\n- Defaults: total expiration 1h, L1 (local) expiration 2min (`CachingOptions`).\n- `ObservableHybridCache` transparently decorates `HybridCache` to emit OpenTelemetry (hits/misses/factory-duration/invalidations). You don't reference it — just inject `HybridCache`.\n\n## Pattern\n\n```csharp\nvar perms = await cache.GetOrCreateAsync(\n    CacheKeys.UserPermissions(userId),\n    async ct => await LoadPermissionsAsync(userId, ct),\n    tags: [CacheKeys.Tags.Permissions, CacheKeys.Tags.User(userId)],\n    cancellationToken: ct);\n```\n\n- **Keys & tags live in `CacheKeys.cs`** — add new keys/tags there, don't inline strings. Existing: `UserPermissions(userId)`, `TenantTheme(tenantId)`, `IdempotencyEntry(tenantId,key)`, `ImpersonationGrantStatus(jti)`; tags `Permissions`, `Themes`, `Idempotency`, `Tenant(id)`, `User(id)`.\n- Invalidate with `RemoveAsync(key)` or `RemoveByTagAsync(tag)` in the relevant mutation handler.\n- `GetOrCreateAsync` gives **stampede protection** for free (factory runs once per key).\n\n## Gotchas\n\n- **No L1 backplane.** `RemoveByTagAsync` on one node does **not** evict L1 on peer nodes — cross-node staleness is bounded only by the 2-min local expiration. Don't rely on instant cross-node invalidation; keep local expiration short for hot, mutable data.\n- Don't reach for `IDistributedCache` directly except where the framework already does so deliberately (idempotency probe-read) — prefer `HybridCache`.\n",".agents/rules/database.md":"# Database & EF Core conventions\n\nRead before touching entities, DbContexts, migrations, or query filters.\n\n## Entities\n\n- `BaseEntity` — `Id`, `CreatedAt`, `UpdatedAt`, `TenantId`.\n- `AggregateRoot` — `BaseEntity` + domain events (`IHasDomainEvents`, `_domainEvents` list).\n- Marker interfaces: `IHasTenant`, `IAuditableEntity`, `ISoftDeletable`, `IGlobalEntity`.\n- Domain events inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Integration events implement `IIntegrationEvent`; handlers `IIntegrationEventHandler<T>`.\n\n## Tenant isolation (default-ON)\n\n- `BaseDbContext` auto-applies a tenant query filter to every entity. **Isolation is on by default.**\n- Opt out **only** via `IGlobalEntity` (e.g. `BillingPlan`, `ImpersonationGrant`, `Outbox`/`InboxMessage`).\n- A subclass DbContext that overrides `OnModelCreating` **must call `base.OnModelCreating(modelBuilder)` LAST**, or the auto-applied filters are lost.\n- Cross-tenant reads use `IgnoreQueryFilters()` **plus an explicit re-filter** — never rely on the absence of the filter.\n- **Query-filter naming:** SoftDelete filter is *named*; the tenant filter stays *anonymous* (Finbuckle owns it). Don't rename the tenant filter.\n\n## AsNoTracking — and when NOT to\n\n- Read-only queries: add `.AsNoTracking()` (Specifications default to it).\n- **Do NOT add `AsNoTracking()` to a read-then-mutate-then-`SaveChanges` query** — the entity must stay tracked or your changes won't persist. The analyzer (AP010) flags these as a smell, but for mutate-and-save flows it is a false positive — leave them tracked.\n- `AnyAsync(...)` materializes no entity, so `AsNoTracking()` there is a no-op — skip it.\n\n## Value generation for nav-collection children\n\nA child entity reached **only** through a parent's navigation collection needs `Property(x => x.Id).ValueGeneratedNever()` in its EF config — otherwise EF treats it as `Modified` instead of `Added` and the insert silently misbehaves.\n\n## Migrations\n\nAll migrations live in **one** project, `src/Host/FSH.Starter.Migrations.PostgreSQL`, organized **per-module by folder** (`Identity/`, `Catalog/`, `Chat/`, …), each with its own `{Module}DbContextModelSnapshot`.\n\n```bash\ndotnet ef migrations add {Name} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {Module}DbContext\n```\n\n- **`migrations remove` operates on the snapshot** — run a full build *before* `migrations add` so the snapshot is current, or you can lose the previous migration.\n- The DB is **not** migrated at API startup. The `DbMigrator` host is a separate step: `apply` (default), `seed`, `seed-demo` (dev only), `list-pending`; flags `--tenant <id>`, `--catalog-only`, `--seed`. It migrates the tenant catalog first, then each tenant's per-module schema, serialized by a Postgres advisory lock.\n- `dotnet-ef` is pinned in `.config/dotnet-tools.json` — run `dotnet tool restore` first.\n\n## Tests + EF\n\n- Integration tests use Testcontainers (real PostgreSQL) — **Docker must be running**.\n- In integration tests, set the Finbuckle tenant context **inline in the same method** as the `UserManager`/`DbContext` call; an awaited-helper set is lost (AsyncLocal) and the tenant query filter NREs.\n",".agents/rules/eventing.md":"# Eventing — domain events, integration events, Outbox/Inbox\n\nRead before publishing/handling cross-module events. `src/BuildingBlocks/Eventing/`.\n\n## Two tiers\n\n- **Domain events** (in-process, pre-commit) — inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Raised on aggregates (`IHasDomainEvents`).\n- **Integration events** (cross-module, async) — implement `IIntegrationEvent` (`Id`, `OccurredOnUtc`, `TenantId`, `CorrelationId`, `Source`). Handlers implement `IIntegrationEventHandler<T>` (single `HandleAsync(T, ct)`), are `sealed`, live in `Events/` or `IntegrationEventHandlers/`.\n\n## The Outbox is the only way to publish\n\n**Do not call `IEventBus` directly from a handler.** Publish via the outbox so the event commits with the business write and survives a crash:\n\n```csharp\nawait _outbox.AddAsync(integrationEvent, ct).ConfigureAwait(false);   // IOutboxWriter\n```\n\nInject **`IOutboxWriter`** (`FSH.Framework.Eventing.Abstractions`) — the publish-side contract, and all a module ever needs. `IOutboxStore` is the full dispatcher-side surface and lives in the eventing runtime, which modules don't reference.\n\n`EfCoreOutboxStore.AddAsync` serializes + `SaveChanges` immediately, joining the caller's transaction when there is one. `OutboxDispatcherHostedService` polls every `OutboxDispatchIntervalSeconds` (default 10), `OutboxDispatcher` **claims** a batch (`OutboxBatchSize`, default 100), publishes via `IEventBus`, and dead-letters after `OutboxMaxRetries` (default 5) → `IsDead`. Failures back off exponentially (`NextRetryAt`); `RedriveDeadLettersAsync` recovers dead rows. `OutboxMessage`/`InboxMessage` are `IGlobalEntity` (no tenant filter — the dispatcher has no tenant context; `TenantId` is an explicit column).\n\n**Publishing is asynchronous.** The consumer runs on the next dispatch cycle, not inside the request. Don't write a caller — or a test — that assumes the side effect already happened. Integration tests drain explicitly via `OutboxDrain.DrainAsync`.\n\n**The one documented exception:** Chat `SendMessageCommandHandler` still publishes mentions on the bus, because the Notifications handler pushes over SignalR and a delayed mention badge reads as broken. Adding a second exception needs the same kind of reason, in a comment at the call site.\n\n## One store, owned by the framework\n\n`OutboxMessages`/`InboxMessages` live in schema `framework`, owned by `EventingDbContext` (`src/BuildingBlocks/Eventing/Persistence/`) — **not** by any module's context. That is what keeps `IOutboxStore`/`IInboxStore` to a single, non-keyed DI registration: registering them per module DbContext made .NET DI resolve whichever module registered last for the whole application, so a second module publishing broke every module's outbox (issue #1349). `EventingRegistrationTests` guards the registration count; don't add a second one.\n\n`EventingDbContext` derives from `BaseDbContext`, so a tenant with a dedicated database gets its outbox rows in that database, next to the business data they accompany.\n\n## Dispatch across tenant databases\n\nBecause rows follow the tenant connection, the dispatcher can't just poll one database. Each cycle it asks `IEventingDrainTargetProvider` for the drain targets and runs one pass per target inside `IEventingDrainScope`, which installs the tenant context **before** the scope's `EventingDbContext` is built (it captures `TenantInfo`, and with it the connection string, at construction).\n\nDefaults in BuildingBlocks are single-database (`SingleDatabaseDrainTargetProvider`, `NullEventingDrainScope`); the multitenancy module replaces them with `TenantStoreDrainTargetProvider` (default DB + one target per distinct **active** per-tenant connection string; tenants sharing a database collapse to one target) and `FinbuckleEventingDrainScope`. One unreachable tenant database is logged and skipped, not fatal to the cycle.\n\n## Multi-instance safety\n\n`ClaimBatchAsync` leases rows with `FOR UPDATE SKIP LOCKED` in a single `UPDATE … RETURNING`, so several API instances partition a batch instead of all publishing the same message. `ClaimedUntilUtc` is an expiry (`OutboxClaimLeaseSeconds`, default 300), so a dispatcher that dies mid-batch has its rows recovered rather than stranded — raise it if a batch can take longer than the lease, or a second instance re-claims rows still in flight. Completing or failing a message releases the lease. Non-Postgres providers have no portable `SKIP LOCKED`: they fall back to an unclaimed read and log a warning that only one instance is safe.\n\n## Atomicity\n\n`IScopedDbConnectionProvider` gives every DbContext in a DI scope the same `DbConnection`, which is the only way EF Core can enlist a second context in a transaction another one opened (Npgsql has no distributed-transaction promotion). `AmbientDbTransactionRegistry` — an `IDbTransactionInterceptor` on every Hero context — records open transactions, since `DbConnection` can't be asked. `AddAsync` joins the ambient transaction when there is one, so the outbox row commits or rolls back with the business data; with none, it commits on its own exactly as before.\n\n## Idempotency is free (in-memory bus)\n\n`InMemoryEventBus` resolves handlers in a fresh DI scope and applies the **Inbox**: skips if `IInboxStore.HasProcessedAsync(eventId, handlerName)`, marks processed after success. Composite key `{Id, HandlerName}`; concurrent-insert race is swallowed. Don't hand-roll dedup.\n\n## Wiring\n\nThe **host** bootstraps eventing once (`FSH.Starter.Api/Program.cs` and `FSH.Starter.DbMigrator/Program.cs`, before `AddModules` so `EventingDbInitializer` migrates the `framework` schema first):\n\n```csharp\nbuilder.Services.AddEventingCore(builder.Configuration);   // serializer + bus + dispatcher + EventingDbContext + stores\n```\n\nA **module** only registers its handlers:\n\n```csharp\nservices.AddIntegrationEventHandlers(typeof(MyModule).Assembly);        // scans IIntegrationEventHandler<>\n```\n\nThere is no per-module store registration — `AddEventingForDbContext<T>` was removed in #1349. A module publishes by injecting `IOutboxWriter`; nothing else is needed.\n\nBus = `EventingOptions.Provider`: `\"RabbitMQ\"` → `RabbitMqEventBus` (durable topic exchange); else `InMemoryEventBus` (default).\n\n## Gotchas\n\n- **Renaming/moving an integration event type breaks deserialization** — the outbox stores the assembly-qualified type name; `Type.GetType()` returns null → the message dead-letters. Keep event type names/namespaces stable, or migrate dead rows.\n- **Background handlers carry no HTTP/tenant context.** An open-generic or background handler that reads a tenant-filtered DbContext must restore Finbuckle context first via `IMultiTenantContextSetter` (see `WebhookFanoutHandler`, `modules/webhooks.md`).\n- In-memory bus runs handlers **synchronously in the publisher's scope** — keep handler work minimal; exceptions surface to the originating request (relevant for Notifications consuming Chat events). Via the outbox that scope is the dispatcher's, not the request's.\n- Set `UseHostedServiceDispatcher=false` to drive the outbox via Hangfire instead of the hosted service.\n- A background publisher must set the tenant context **before** `AddAsync` — otherwise, with per-tenant databases, the row lands in the wrong one (see `TenantExpiryScanJob`).\n",".agents/rules/frontend/admin.md":"# Frontend — admin app (`clients/admin`)\n\nOperator/SuperAdmin-facing console. Read `frontend/shared.md` first; this file is only the divergences.\n\n- **Port** 5173 · dev proxy target `http://localhost:5030` (HTTP) · localStorage prefix `fsh.admin.*` · login header `X-FSH-App: admin`.\n- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, dashboardUrl }`. `dashboardUrl` is used for the one-way impersonation handoff into the dashboard app.\n\n## Forms — react-hook-form + zod\n\nAdmin is the **only** app with `react-hook-form` + `zod` + `@hookform/resolvers`. Use them:\n\n```ts\nconst form = useForm<Schema>({ resolver: zodResolver(schema) });\n```\n\nForm layout primitives live in `src/components/list/` (`PageHeader`, `Field`, `FormShell`, `FormSection`, `FormActions`, `Pagination`, `ErrorBand`).\n\n## Permissions — fetched, hydrated, gated\n\n- The JWT carries only role names. Admin fetches the permission set separately: `GET /api/v1/identity/permissions` (`getMyPermissions`), cached under `fsh.admin.permissions`.\n- `AuthProvider` hydrates them in an effect keyed on subject change and exposes `permissionsHydrated` to avoid a 403 flash on first paint.\n- **Route gating:** wrap gated route elements in `<RouteGuard perms={[IdentityPermissions.Users.View]}>…</RouteGuard>`. It renders a \"Resolving permissions\" state while `!permissionsHydrated`, else `<ForbiddenView missing={…}/>`. (`ProtectedRoute` also accepts a `permissions?` prop.)\n- **Mirror server permissions by hand** in `src/lib/permissions.ts` (`IdentityPermissions`, `MultitenancyPermissions`, … frozen objects + `PERMISSION_CATALOG` driving the role editor). There is intentionally **no** runtime catalog fetch — when the server adds a permission, mirror the constant here.\n\n## Routing & realtime\n\n- Routes wrap elements in `<RouteGuard perms={…}>` (no per-route Suspense wrapper).\n- `RealtimeProvider` is mounted in `App.tsx` and wires only `[\"NotificationCreated\"]`.\n\n## Theme\n\nCool-cast neutrals (hue 240, small non-zero chroma — **not** chroma 0), a single fixed chartreuse \"signal\" accent (`--accent-signal` / `--signal-500`), Geist / Geist Mono fonts. Defined in `src/styles/globals.css`.\n\n## Add-a-page deltas (on top of shared steps)\n\n- Use RHF + zod for any form.\n- If the endpoint requires a permission, mirror the constant in `src/lib/permissions.ts` (and `PERMISSION_CATALOG` if it belongs in the role editor) and wrap the route in `<RouteGuard perms={[…]}>`.\n- Playwright: `seedAuthedSession` here also pre-seeds `fsh.admin.permissions` so `RouteGuard` passes on first paint.\n",".agents/rules/frontend/dashboard.md":"# Frontend — dashboard app (`clients/dashboard`)\n\nTenant-facing application. Read `frontend/shared.md` first; this file is only the divergences.\n\n- **Port** 5174 · dev proxy target `https://localhost:7030` (HTTPS, with `ws: true` for the SignalR hub) · localStorage prefix `fsh.dashboard.*` · login header `X-FSH-App: dashboard`.\n- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, demoMode }`.\n- Dev-proxy is HTTPS on purpose: routing the bearer token through an HTTP→HTTPS 307 redirect stripped the `Authorization` header.\n\n## No RHF/zod — hand-rolled forms\n\nThe dashboard does **not** depend on react-hook-form or zod. Use controlled inputs + local state. Don't add those deps to match admin.\n\n## Permissions — fetched, not in the JWT\n\nThe JWT carries **only role names**. `auth-context.tsx` fetches the effective permission list from `GET /api/v1/identity/permissions` (`getMyPermissions()` in `src/api/identity.ts`), caches it in `tokenStore` under `fsh.dashboard.permissions`, and exposes a `permissionsHydrated` flag so gated UI doesn't flash while the fetch is in flight (re-fetched on login/impersonation swaps; `refreshPermissions()` for role changes). Nav items are gated via `perm`/`anyPerm` in `src/components/layout/nav-data.ts`. `ProtectedRoute` is still **auth-only** (no per-route permission gating) — don't add `RouteGuard`-style gating here.\n\n## Routing & realtime/SSE\n\n- Every route element is wrapped in `withSuspense(node)` (per-route skeleton fallback). No per-route permission guards.\n- `RealtimeProvider` **and** `SseProvider` are mounted inside `AppShell` (authenticated routes only), under a `CommandPaletteProvider` (cmdk).\n- SignalR provider pre-wires ~11 chat/notification events.\n- **SSE** (`src/sse/`, dashboard-only): two-step token — `POST /api/v1/sse/token`, then `GET /api/v1/sse/stream?token=<guid>` consumed via fetch streaming (`parseSseStream` async generator; EventSource can't send auth headers). **Two split contexts:** `useSseStatus()` (stable, for status dots) vs `useSseEvents()` (mutates per event) to avoid cascading re-renders; `useSse()` is the composite.\n\n## Impersonation\n\n`token-store.ts` has `beginImpersonation` / `endImpersonationWithFreshTokens` / `restoreStashedActor` that stash the operator's tokens under `fsh.dashboard.impersonation.*`. `AuthProvider` exposes `beginImpersonation`/`stopImpersonation` and derives `ImpersonationInfo` from `act_sub` / `act_tenant` / `act_name` claims. Admin triggers the handoff one-way via its `dashboardUrl`.\n\n## Performance\n\n- `@tanstack/react-virtual` for long lists — use it for any large collection (chat history, big tables).\n- `cmdk` powers the command palette.\n\n## Theme\n\n**Chroma-0 neutrals** (`--neutral-*: oklch(L 0 0)` — untinted; the warm-paper tint was deliberately removed). Rose default brand with **swappable accent themes** via `.accent-{rose,indigo,violet,sky,emerald,amber}` classes that override the `--brand-*` oklch stops; saffron secondary; Figtree font. Defined in `src/styles/globals.css`. Keep neutrals at chroma 0.\n\n## Add-a-page deltas (on top of shared steps)\n\n- Hand-roll forms (no RHF/zod).\n- Wrap the route element in `withSuspense(<X/>)`; no permission guard.\n- If it consumes pushes: `useRealtimeEvent(\"EventName\", handler)` (register the name in `realtime-context.tsx`) or `useSseEvents()` for SSE.\n- Use `react-virtual` for long lists; keep neutrals chroma 0.\n",".agents/rules/frontend/shared.md":"# Frontend — shared conventions\n\nApplies to **both** `clients/admin` and `clients/dashboard`. Read this for any React work, then read\nthe app-specific file (`admin.md` / `dashboard.md`) for divergences.\n\nStack: React 19 · Vite 7 · TypeScript · TanStack Query v5 · React Router 7 · Radix UI · Tailwind v4 ·\nclass-variance-authority (shadcn-style) · `@microsoft/signalr`. Path alias `@` → `src` (`vite.config.ts`).\n\n## API client (`src/lib/api-client.ts`, `src/api/*`)\n\n- One fetch wrapper: `apiFetch<T>(path, init)`. No axios.\n- **Types are hand-written**, not generated. (`openapi-typescript` is declared in admin's devDeps but unused — there is no codegen step.) Define DTO `type`s and a `const BASE = \"/api/v1/...\"` in each `src/api/{feature}.ts`, with thin async functions calling `apiFetch`.\n- Auth header `Authorization: Bearer <access>` from `tokenStore.getAccessToken()` (unless `skipAuth:true`).\n- Tenant header (lowercase `tenant`) = `tokenStore.getTenant() ?? env.defaultTenant`, unless overridden per request.\n- Errors: non-OK parses RFC 9457 `application/problem+json` and throws `ApiRequestError(status, message, problem)`. 204/empty → `undefined`.\n- **Single-flight refresh:** on 401 with a refresh token, `POST /api/v1/identity/token/refresh` with `{token, refreshToken}`; a module-level `refreshPromise` dedupes concurrent refreshes; rotated token returns on `token` (not `accessToken`); the original request retries once.\n- Search endpoints: build `URLSearchParams` with **PascalCase** keys (`PageNumber`, `PageSize`, …) to match the API.\n\n## Env (`src/env.ts`) — runtime, not build-time\n\n`loadRuntimeConfig()` fetches `/config.json` once at boot (awaited in `main.tsx` before React mounts); `env` is a getter that throws if read too early. One built image promotes across environments (operator writes `config.json`). The only `VITE_*` var, `VITE_API_BASE_URL`, configures the **Vite dev proxy target only** — it is not the runtime apiBase (`config.json` ships `apiBase: \"\"`, relative).\n\n## Data fetching (TanStack Query v5)\n\n- Shared `queryClient` (`src/lib/query-client.ts`): `staleTime: 30_000`, `refetchOnWindowFocus:false`, no retry on 401/403 else `failureCount < 2`.\n- **Query keys are inline literal arrays**, hierarchical, params object last: `[\"users\", {pageNumber, searchTerm}]`, `[\"user\", id]`, `[\"user\", id, \"roles\"]`. No central key factory.\n- `useQuery`/`useMutation` live inline in page components. Invalidate in `onSuccess`: `queryClient.invalidateQueries({ queryKey: [\"users\"] })`. Pagination: `placeholderData: keepPreviousData`.\n\n### ⚠️ The `mutate(arg)` race-safe pattern (golden rule #9)\n\n`useMutation` reads its options at execute time, so values produced at call time (e.g. a fresh\n`crypto.randomUUID()` client id) must ride **through `mutate(arg)`** and be read from the `variables`\nargument of `onMutate`/`onSuccess`/`onError` — never from component state the callbacks close over,\nor two rapid calls collide.\n\n```ts\nmutation.mutate({ text, clientId: crypto.randomUUID() });\n// onMutate: ({ clientId }) => insert optimistic `temp:${clientId}`\n// onSuccess: (real, { clientId }) => swap temp → real\n// onError:   (_e, { clientId }) => rollback\n```\n\n## Routing (`routes.tsx`, `App.tsx`)\n\n- `createBrowserRouter`, flat config. Pages are **named exports** loaded via a `lazyNamed(importer, name)` helper (adapts named → `React.lazy`'s default contract). No default exports.\n- Nesting: public auth routes → `<ProtectedRoute/>` → `<AppShell/>` → page children. `errorElement: <RouteError/>`.\n- Provider tree: `ThemeProvider > QueryClientProvider > AuthProvider > … > RouterProvider` + `sonner` `<Toaster/>`.\n\n## Auth (`src/auth/`)\n\n`token-store.ts` (localStorage + pub/sub), `jwt.ts` (`decodeJwt`), `AuthProvider`/`useAuth()`, `ProtectedRoute`.\nLogin `POST /api/v1/identity/token/issue` with header `X-FSH-App: \"admin\"|\"dashboard\"`. **localStorage keys are namespaced per app** (`fsh.admin.*` / `fsh.dashboard.*`) so both run side-by-side. Permission *source* differs per app — see the app files.\n\n## Design system (Tailwind v4, shadcn-style)\n\n- **`cn()` is at `src/lib/cn.ts`** (`twMerge(clsx(...))`) — not `lib/utils.ts`. `components.json`: `style:new-york`, `baseColor:slate`, `cssVariables:true`, `iconLibrary:lucide`.\n- UI primitives in `src/components/ui/` are cva-based: `cva(base, { variants, defaultVariants })` + Radix `Slot`/`asChild` + `cn(buttonVariants({...}))`. Layout primitives in `src/components/list/` (admin) / similar (dashboard), re-exported from `index.ts`.\n- **Tailwind v4 is CSS-first — there is NO `tailwind.config`.** Configured via the `@tailwindcss/vite` plugin and one entrypoint `src/styles/globals.css` (imported in `main.tsx`). Tokens: `:root` oklch primitives → semantic vars → an `@theme inline { --color-*: var(--…) }` block exposing them as utilities. `@custom-variant dark (&:is(.dark *))`.\n- Add a new token in `globals.css` (primitive → semantic → `@theme inline`), then use the utility. Don't hard-code colors in components.\n\n### Design language (both apps differ on purpose)\n\n| | admin (operator) | dashboard (tenant) |\n|---|---|---|\n| Neutrals | cool-cast, hue 240, small non-zero chroma | **chroma 0** (untinted — the warm tint was removed) |\n| Accent | single fixed chartreuse \"signal\" (`--accent-signal`) | rose brand + **swappable** `.accent-{rose,indigo,violet,sky,emerald,amber}` |\n| Font | Geist / Geist Mono | Figtree (+ saffron secondary) |\n\nSo \"neutrals must be chroma 0\" is a **dashboard** rule. Admin neutrals are intentionally cool — match the file you're editing.\n\n## Realtime (SignalR)\n\n`src/realtime/realtime-context.tsx`: one `HubConnection` to `/api/v1/realtime/hub`. `@microsoft/signalr` is **dynamically imported** (lazy ~37KB) only when an authed session opens the hub. Auth via `accessTokenFactory`; a `tokenEpoch` (bumped by `tokenStore.subscribe`) forces reconnect on login/refresh/impersonation. Consume with `useRealtimeEvent(\"EventName\", handler, deps)` (handler kept in a ref). Pre-register new event names in the provider's event list.\n\n## Testing (Playwright, route-mocked)\n\n- `playwright.config.ts`: `testDir: ./tests`, chromium, auto-boots `npm run dev`, no real backend.\n- Tests in `tests/{area}/{name}.spec.ts`; helpers in `tests/helpers/`.\n- **JWT seeding:** `seedAuthedSession(page, TEST_USER)` builds a fake JWT and `addInitScript`-writes `fsh.{app}.*` to localStorage before React boots (server isn't called, so signature is junk).\n- **Route mocking:** `mockJsonResponse(page, urlGlob, body)` / `mockProblemDetails(...)`. `installShellMocks(page)` stubs every call `AppShell` fires and **aborts** SSE/SignalR. Playwright matches most-recently-registered first → broad shell mocks in `beforeEach`, page-specific mocks after (they win).\n- `beforeEach`: `seedAuthedSession(page, TEST_USER)` → `installShellMocks(page)`.\n\n## Add a page/feature (shared steps)\n\n1. API: extend `src/api/{feature}.ts` — hand-written types + `apiFetch` calls.\n2. Page: `src/pages/{area}/{name}.tsx`, **named** export. `useQuery` with hierarchical key; `useMutation` invalidating in `onSuccess`, passing per-call data via `mutate(arg)`.\n3. Route: add `const X = lazyNamed(() => import(\"@/pages/area/name\"), \"XPage\")` and a child route under `AppShell`.\n4. Test: `tests/{area}/{name}.spec.ts` with seed + shell mocks + page mocks.\n\nThen apply the app-specific steps in `admin.md` / `dashboard.md` (forms, permission gating, suspense, etc.).\n",".agents/rules/integration-testing.md":"# Integration testing\n\n`src/Tests/Integration.Tests/` + `Integration.Middleware.Tests/`. Read before writing tests that touch the DB/HTTP pipeline. See `testing.md` for unit conventions.\n\n## Harness\n\n`WebApplicationFactory` over **real** infra via Testcontainers — PostgreSQL + Redis + RustFS (S3-compatible, generic container). **Docker must be running**; if it isn't, tests fail fast with `DockerUnavailableException` (environmental, not a regression — run the unit projects instead).\n\n`FshWebApplicationFactory` (`Integration.Tests/Infrastructure/`) boots the containers, overlays in-memory config, swaps `IMailService` → `NoOpMailService`, and rewires storage to RustFS.\n\n## Must-know gotchas\n\n- **Tenant context is AsyncLocal — set it inline.** Set the Finbuckle tenant context **in the same method** as the `UserManager`/`DbContext` call. Setting it in an awaited helper loses it across the async boundary → NRE in the tenant query filter.\n- **Storage is wired eagerly.** `AddHeroStorage` reads `Storage:Provider` before the test config overlay, so it picks `LocalStorageService`. The factory **removes the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-registers the S3 stack** at RustFS. Follow that when a test needs real object storage. (See `storage.md`.)\n- **SignalR tests force long-polling** — TestServer has no WebSocket. Configure the client transport accordingly.\n- **Rate limiting is read eagerly** — `Integration.Middleware.Tests` sets `RateLimitingOptions:Enabled` via env var **before** host build, since flipping it after has no effect.\n\n## Coverage\n\n```bash\ndotnet test --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\nCobertura; includes `[FSH.Modules.*]` + `[FSH.Framework.*]`; excludes tests, the Migrations project, and `*HostedService`.\n",".agents/rules/jobs.md":"# Background jobs (Hangfire)\n\n`src/BuildingBlocks/Jobs/`. Read before enqueuing or scheduling work.\n\n## Fire-and-forget / scheduled — `IJobService`\n\nInject `IJobService` (`Jobs/Services/IJobService.cs`) and use it; don't call Hangfire's `BackgroundJob` directly in feature code.\n\n```csharp\njobService.Enqueue(() => mailService.SendAsync(req, CancellationToken.None));   // default queue\njobService.Enqueue(\"email\", () => mailService.SendAsync(req, CancellationToken.None));\njobService.Schedule(() => DoLater(), TimeSpan.FromMinutes(5));\n```\n\nQueues: `default`, `email` (5 workers, 30s poll). Storage auto-selected from `DatabaseOptions.Provider` (Postgres/MSSQL).\n\n## Recurring jobs — `IRecurringJobManager`\n\n`IJobService` has **no** recurring API. Register recurring jobs in the module's `MapEndpoints` with `IRecurringJobManager.AddOrUpdate<T>(...)`, always `TimeZoneInfo.Utc`:\n\n```csharp\nrecurringJobs.AddOrUpdate<PurgeOrphanedFilesJob>(\"files:purge-orphaned\",\n    j => j.RunAsync(CancellationToken.None), Cron.Hourly(), new() { TimeZone = TimeZoneInfo.Utc });\n```\n\nExamples in the tree: `PurgeOrphanedFiles`/`PurgeDeletedFiles` (Files), `MonthlyInvoiceJob` (Billing), `AuditRetentionJob` (Auditing), `WebhookDispatchJob` (Webhooks).\n\n## Dashboard & config\n\n`/jobs` (default), behind `HangfireOptions.UserName`/`Password` basic auth — both `[Required]`, password `[MinLength(12)]`, so **startup fails in non-dev if unset**.\n\n## Gotchas\n\n- Jobs run on the server with **no HTTP/tenant context** — restore Finbuckle tenant context inside the job (fresh scope + `IMultiTenantContextSetter`) before touching a tenant-filtered DbContext.\n- The DbMigrator registers `NoOpJobService` whose methods **throw** — surfaces any accidental enqueue during migration. Don't enqueue from migration/seed paths.\n- A job class is a normal DI-resolved type (scope-per-job via `FshJobActivator`); inject what you need.\n",".agents/rules/localization.md":"# Localization (i18n)\n\n`src/BuildingBlocks/Core/Localization/` + per-module `Localization/` folders. Read before adding any user-facing message (exception, validation, API error). **The client's culture decides which words the client reads; it never decides how the API formats numbers or dates.**\n\n## Culture negotiation (already wired — don't re-add)\n\n`AddHeroLocalization()` / `UseHeroLocalization()` (`BuildingBlocks/Web/Localization/`) negotiate the request **UI** culture in this order: `?culture=` query → `locale` JWT claim (`UserLocaleRequestCultureProvider`) → `Accept-Language` → configured default → `en-US`. Supported tags live in `SupportedCultures.Tags`. The culture is set before endpoints and the exception handler run, so any `IStringLocalizer` resolved downstream picks up the request culture automatically.\n\n**`CultureInfo.CurrentUICulture` only — `CurrentCulture` stays invariant.** An API that emits JSON must not shift `ToString()`, `Parse()` or interpolation per request; both React apps format at the presentation layer. `RequestLocalizationMiddleware` assigns both cultures unconditionally, so the culture half is pinned rather than left alone: `DefaultRequestCulture` carries `(InvariantCulture, configured default)` and `SupportedCultures` is `null` so the middleware skips culture filtering. Do **not** \"fix\" this by adding `AddSupportedCultures(...)`; `Formatting_culture_stays_invariant_while_ui_culture_negotiates` fails if you do.\n\n`SupportedCultures.Tags` is **specific tags only**, no neutrals. A request asking for a bare `pt`, or for an unsupported variant like `pt-PT`, resolves to the configured default rather than being served a language it was not translated into. The React apps canonicalize variants onto supported tags (`CANON` in `clients/*/src/i18n.ts`) before calling the API, so app traffic is unaffected; a hand-rolled client sending bare `pt` gets the default. Adding a language means: add its specific tag to `Tags`, add a `*.{tag}.resx` per catalog, add its JSON catalogs to both apps, and remove it from `CANON` if it was being folded into another tag.\n\n## Catalogs — hybrid, one marker per catalog\n\n- **Core (`SharedResources`)** — generic / cross-cutting messages: ProblemDetails titles (`Error.*`), cross-module errors (`Error.TenantContextRequired`, `Error.NoCurrentUser`, …), and shared validation (`Validation.*`).\n- **Per module (`<Module>Resources`)** — domain-specific messages owned by the module: `src/Modules/<Module>/Modules.<Module>/Localization/<Module>Resources.cs` (marker `public sealed class <Module>Resources;`) + co-located `<Module>Resources.resx` (neutral / en-US) + an optional `<Module>Resources.pt-BR.resx`. `ResourcesPath = \"\"` (co-located), so the resx manifest name must equal the marker's full type name.\n\nCatalogs are named for **specific** cultures (`.pt-BR`, never a neutral `.pt`), matching the front-end catalog folders. The neutral, un-suffixed `.resx` is the en-US / ultimate-fallback catalog.\n\nKey naming: `Error.<Module>.<Case>` for domain messages (`Catalog.ProductNotFound`), `Error.<CrossCutting>` / `Validation.<Case>` for Core. PascalCase. Placeholders are `{0}`, `{1}` (`string.Format` via the localizer) — **not** the frontend's `{{name}}`.\n\n**Placeholder arguments must be culture-insensitive.** The localizer's indexer calls `string.Format` under `CurrentCulture`, which is invariant (above). Pass `int`/`long`/`string`/enum — never a `double`, `decimal`, `DateTime` or `TimeSpan.TotalX`, which would render with an invariant separator instead of the reader's. Where a count is conceptually whole, expose it as an `int` at the source rather than converting at the call site (see `GetAuditsQueryHandler.MaxWindowDays` next to `MaxWindow`). Money and dates belong in structured response fields formatted by the client, not interpolated into a message.\n\n## Exceptions — localize at the boundary, log stays English\n\nThrow with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site.\n\n```csharp\n// domain message -> module catalog\nthrow new NotFoundException($\"Product {id} not found.\")\n{\n    MessageKey = \"Catalog.ProductNotFound\",\n    MessageArgs = [id],\n    ResourceSource = typeof(CatalogResources),\n};\n\n// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources)\nthrow new UnauthorizedException(\"Tenant context is required.\")\n{\n    MessageKey = \"Error.TenantContextRequired\",\n};\n```\n\n`GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException(\"...\")` still renders its English literal.\n\n**Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body.\n\n## Validators — inject the localizer, defer resolution\n\n```csharp\npublic sealed class XCommandValidator : AbstractValidator<XCommand>\n{\n    public XCommandValidator(IStringLocalizer<SharedResources> localizer)\n    {\n        RuleFor(x => x.Name)\n            .NotEmpty().WithMessage(_ => localizer[\"Validation.NameRequired\"]);\n    }\n}\n```\n\nAlways the `.WithMessage(_ => localizer[\"Key\"])` lambda (resolution is deferred to `Validate()`, under the request culture) — never `.WithMessage(localizer[\"Key\"])`. **Catalog choice:** inject `IStringLocalizer<SharedResources>` for genuinely shared/generic validation (`Validation.*` already in Core, reuse them), or `IStringLocalizer<<Module>Resources>` for module-specific validation messages kept in the module's own catalog. DI provides the localizer automatically (`AddValidatorsFromAssembly` + `AddHeroLocalization` + the module's own `AddLocalization`); nested validators (`Include(new PagedQueryValidator<T>(localizer))`) receive it from the parent.\n\n## Known behaviour (documented, not bugs)\n\n- **The `locale` claim lags a language switch by one token.** The culture provider reads the JWT `locale` claim, so a switch does not reach the API until the next token issue. The front-end persists the choice to the profile and re-mints, so it converges; in the window between, the shell can be in the new language while an API error still comes back in the old one. Deliberate: the alternative is a per-request DB read on every authenticated call.\n- **Impersonation carries the operator's language, not the target's.** `StartImpersonationCommandHandler` strips the target's `locale` claim so the operator keeps reading in their own language, and the cross-app handoff URL carries `locale` because the dashboard is normally on a different origin and cannot read admin's `fsh.admin.lng`. During impersonation the switcher is client-side only — it must not PUT onto the impersonated user's profile.\n- **SignalR does not carry the app locale.** The hub client builds its own requests instead of going through `apiFetch`, so `Accept-Language` on the negotiate is the browser's. Applies to every session. `handoff-locale.spec.ts` names the exception explicitly so any *other* channel that stops carrying the locale fails the test.\n\n## Tests (required with every catalog change)\n\n- **Parity** — add every new key to the neutral (English) catalog; the `pt-BR` entry is optional, and a key it lacks falls back to English at runtime, so nobody has to write Portuguese to get a green build. The build fails only on a `pt-BR` key the neutral catalog does not have (an orphan, usually a renamed or deleted key) or on a `pt-BR` value whose `{n}` placeholders differ from the English one. Applies to Core and every `<Module>Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test.\n- **Code → resx guard** — every referenced key (`MessageKey`, `localizer[\"…\"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently).\n- Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub.\n\n## Emails / background handlers\n\nIntegration-event handlers run without an HTTP request, so there is no negotiated culture. Localizing outbound emails needs the recipient's stored locale propagated to the handler — **not yet implemented** (tracked for a future PR); email bodies stay English for now.\n",".agents/rules/logging.md":"# Logging & observability\n\n`src/BuildingBlocks/Web/Observability/`. Read before adding logs, traces, or metrics.\n\n## Structured logging only\n\n**No string interpolation in log messages.** Use message templates with named placeholders, or `[LoggerMessage]` source-gen for hot paths.\n\n```csharp\n// good\n_logger.LogInformation(\"Cleaned up {Count} expired sessions for tenant {TenantId}\", count, tenantId);\n// also good (hot path) — see OutboxDispatcher, InMemoryEventBus, AppHub\n[LoggerMessage(Level = LogLevel.Warning, Message = \"Outbox message {MessageId} dead-lettered\")]\nprivate partial void LogDeadLettered(Guid messageId);\n// NEVER\n_logger.LogInformation($\"Cleaned up {count} sessions\");   // breaks structured logging + analyzers\n```\n\nBuild runs with `TreatWarningsAsErrors` — interpolated log calls won't even compile clean under analysis.\n\n## Serilog\n\n`AddHeroLogging()` reads the `Serilog` config section (Console sink by default), attaches `HttpRequestContextEnricher` (adds `RequestMethod`/`RequestPath`/`UserAgent` + `UserId`/`Tenant`/`UserEmail` when authenticated), overrides Microsoft/EF/Hangfire/Finbuckle to higher levels, and excludes the `ExceptionHandlerMiddleware` source (the global handler logs exceptions itself — don't double-log).\n\n## Correlation\n\n`X-Correlation-ID` request header (falls back to `HttpContext.TraceIdentifier`), surfaced in every ProblemDetails and pushed to the Serilog `LogContext`. `CurrentUserMiddleware` tags the current `Activity` with `fsh.user_id` / `fsh.tenant_id` / `fsh.correlation_id`.\n\n## OpenTelemetry\n\n`AddHeroOpenTelemetry()` no-ops unless `OpenTelemetryOptions.Enabled`. Metrics + traces for AspNetCore/HttpClient/Npgsql/EFCore/Redis/Runtime, plus caching + auditing meters and Mediator pipeline spans (`MediatorTracingBehavior`). Add a new meter/source name to `OpenTelemetryOptions` config, not by editing the extension.\n\n**OTLP export is auto-detected.** It turns on when **either** `Exporter.Otlp.Enabled=true` **or** the `OTEL_EXPORTER_OTLP_ENDPOINT` env var is present. Under .NET Aspire that env var is injected automatically, so traces/metrics flow to the Aspire dashboard with no config change (the SDK reads endpoint + protocol from the standard `OTEL_EXPORTER_OTLP_*` env vars; the config `Endpoint`/`Protocol` are only used when no env var is set — e.g. the docker-compose collector at `http://localhost:4317`). Plain `dotnet run` with no collector and `Enabled=false` exports nothing.\n\n**Logs ride the same OTLP detection.** Serilog owns the logging pipeline and does not forward to other `ILogger` providers, so the OTel SDK's log exporter can't see Serilog events — instead `AddHeroLogging` adds a `Serilog.Sinks.OpenTelemetry` sink under the same auto-detect rule (global `Enabled` + env-var-or-config endpoint). It also parses `OTEL_EXPORTER_OTLP_HEADERS` (the Aspire dashboard's OTLP receiver requires its `x-otlp-api-key`; the SDK reads this for traces/metrics, the Serilog sink does not) and stamps `service.name` = `OTEL_SERVICE_NAME ?? ApplicationName` so logs group under the same dashboard resource as the spans. Both `AddHeroOpenTelemetry` and the Serilog sink resolve the name that way: under an orchestrator that injects `OTEL_SERVICE_NAME` (Aspire sets it to the resource name, e.g. `fsh-starter-api`) the process adopts that identity; hardcoding the entry-assembly name (`FSH.Starter.Api`) instead would de-correlate the telemetry and list the process twice in the dashboard. Plain `dotnet run` with no env var falls back to `ApplicationName`.\n",".agents/rules/modules/auditing.md":"# Module: Auditing\n\nAppend-only audit trail (entity changes, security events, exceptions, HTTP activity) with async channel-buffered persistence + DLQ. Module `Order = 300`.\n\n**Entities / DbContext:** `AuditRecord`, `AuditDbContext`. `AuditEnvelope` is the in-flight event. Rich Contracts surface: `IAuditClient`, `ISecurityAudit`, `IAuditPublisher`, `IAuditSink`, `IAuditDlqSink`, `IAuditEnricher`, `NoAuditAttribute`, payload records.\n**Areas:** read-only query side — GetAudits / ByCorrelation / ByTrace / Summary / Exception / Security. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Static `Audit` fluent API** — `Audit.ForSecurity(...).WithUser(...).WriteAsync(ct)` (also `ForEntityChange`/`ForActivity`/`ForException`). Configured once at startup via `Audit.Configure(publisher, serializer, enrichers)`. Enrichers are held in a **volatile immutable array swapped atomically** — never mutate a live enricher list (it'd race the enrich loop).\n- **Two interceptors, don't confuse them:** `AuditingSaveChangesInterceptor` (this module) captures EF entity diffs → EntityChange events and **skips `AuditDbContext`** (no recursive self-audit). `AuditableEntitySaveChangesInterceptor` (BuildingBlocks) stamps audit/soft-delete fields — different file, different job.\n- **Channel-buffered, never blocks the request** — `ChannelAuditPublisher` has two lanes: default (`DropOldest` under pressure) and a **security lane that back-pressures and never drops** (login/permission/impersonation ride here). `AuditBackgroundWorker` drains both (security first), batches, writes via `IAuditSink`; on sink failure it retries then spills to `IAuditDlqSink` (file) so events survive a Postgres outage.\n- `SqlAuditSink` groups a batch by `TenantId` and sets tenant context per group in a fresh scope (null → Root) — background writer has no ambient tenant.\n- **JSON masking** redacts fields by keyword (password/secret/token/apiKey/connectionString…) → `****`. Add sensitive keys there.\n- Exclude an endpoint from activity auditing with `[NoAudit]` / the `NoAudit` endpoint extension.\n- **Entity diffs mask sensitive values** in `EntityDiffBuilder` (property name contains password/secret/token/apikey/connectionstring/securitystamp → `****`, null stays null). `[NoAudit]` does **not** affect entity diffs — to keep an entity's values out of `AuditRecords` entirely, implement `IAuditExempt` (Contracts) on the entity.\n",".agents/rules/modules/billing.md":"# Module: Billing\n\nPlans, subscriptions, usage metering, monthly invoicing. **Manual payment marking — no payment provider.** Module `Order = 500`.\n\n**Entities / DbContext:** `BillingPlan`, `Subscription`, `Invoice` (+ `InvoiceLineItem`), `UsageSnapshot`. **`BillingDbContext : DbContext`** (NOT `BaseDbContext`) — billing lives in the main DB with an explicit `TenantId` column for cross-tenant admin visibility, filtered in query services. Contracts = DTOs; `IBillingService`/`IUsageReporter` are internal.\n**Areas:** Plans, Subscriptions, Invoices (generate/issue/mark-paid/void), Usage (capture/get). Monthly invoice job (`5 0 1 * *`). Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **`BillingPlan` is `IGlobalEntity`** — platform-wide catalogue rows, **not tenant-scoped** (opts out of tenant isolation). A plan's `Key` matches the quota config key (e.g. `\"pro\"`): limits come from `QuotaOptions`, prices/overage from the plan.\n- **`BillingDbContext` is a plain `DbContext`** — tenant filtering is done explicitly in query services, not by the `BaseDbContext` auto-filter. Don't assume the global tenant filter applies here.\n- **Invoice state machine** — `Draft → Issued → Paid | Void`. Line items only addable in Draft; a Paid invoice can't be voided; totals recompute on add; Issue defaults due = +14 days.\n- **Usage metering is idempotent** — `IUsageReporter.CaptureForPeriodAsync` reads `IQuotaService` and persists one `UsageSnapshot` per `QuotaResource` per (tenant, period), so invoicing math is reproducible even after a mid-period plan change.\n",".agents/rules/modules/catalog.md":"# Module: Catalog\n\nProduct catalog — products, categories (tree), brands — with soft-delete/restore/trash and search. Module `Order = 600`. This is the **reference module** for the soft-delete + image patterns; copy from here.\n\n**Entities / DbContext:** `Product` (aggregate, soft-deletable) + `ProductImage`, `Brand`, `Category` (self-referencing tree), `Money` (owned value object). `CatalogDbContext`. Domain events (`ProductCreated`/`PriceChanged`/`StockAdjusted`) are **internal**, not integration events.\n**Areas:** Products (+ price/stock/images), Categories (+ tree), Brands — each with Create/Update/Delete/Search/ListTrashed/Restore. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas / patterns to copy\n\n- **Soft-delete + restore + trashed-listing** is the standard pattern. Unique indexes are **filtered on `\"IsDeleted\" = FALSE`** so SKU/Slug stay unique-per-tenant among *live* rows only (a deleted SKU can be reused). Replicate this on any soft-deletable unique field.\n- **EF value-generation for nav children** — `ProductImageConfiguration` sets `Id.ValueGeneratedNever()` (same nav-child footgun as Chat — see `database.md`).\n- **Single-thumbnail invariant** is enforced by the **aggregate**, not a partial unique index (Postgres non-deferrable partial unique indexes can't handle the demote/promote ordering in one transaction). Enforce such invariants in the domain.\n- Registers `ProductFileAccessPolicy` (OwnerType `\"Product\"`) for product images via the Files module.\n- Route ordering: literal segments (`/trash`, `/tree`, `/restore`) are registered **before** `/{id:guid}` catch-alls.\n",".agents/rules/modules/chat.md":"# Module: Chat\n\nSlack-style messaging: 1:1 DMs, group DMs, named channels, threads, reactions, mentions, pins. Module `Order = 800` (after Notifications 750, so Notifications' handlers register first).\n\n**Entities / DbContext:** `ChatChannel` (aggregate, soft-deletable) + `ChannelMember`; `Message` (aggregate) + `MessageAttachment`/`MessageMention`/`MessageReaction`. `ChatDbContext : BaseDbContext`, schema `chat`. Publishes `MentionedInChannelIntegrationEvent`.\n**Areas:** Channels, Messages (incl. pin/edit/delete/threads), Reactions, Search. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **EF value-generation for nav children** — `MessageConfiguration` sets `Property(x => x.Id).ValueGeneratedNever()` for child collections (attachments/mentions/reactions). The domain assigns `Guid.CreateVersion7()` in factories; without this EF treats nav-collection children as `Modified` → 0-row UPDATE instead of INSERT. See `database.md`.\n- `ChatDbContext` calls **`base.OnModelCreating` LAST** so tenant auto-apply sees the configured child types.\n- **`ChannelAuthorization`** (`Features/v1/Internal/`): `RequireMember` throws **NotFound (404)** (not 403) so non-members can't probe channel existence; `RequireAdmin` throws `ForbiddenException`. Use these in every channel/message handler.\n- **SignalR via `IHubContext<AppHub>`** (the shared hub in BuildingBlocks), groups `channel:{id}`. The hub reads the user via `Context.User`, not `ICurrentUser` (see `realtime.md`). Chat registers `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups.\n- SendMessage publishes `MentionedInChannelIntegrationEvent` **per distinct mentioned user**; Notifications consumes it.\n- DMs use a sorted `DirectKey` (`\"{lo}:{hi}\"`) for find-or-create; **threads are single-level only**.\n- Chat attachments register `ChatChannelFileAccessPolicy` (OwnerType `\"ChatChannel\"`): attach/read require membership, delete is uploader-only (see `modules/files.md`).\n",".agents/rules/modules/files.md":"# Module: Files\n\nPresigned-URL file lifecycle (upload → finalize → serve → delete) shared by Catalog images, Chat attachments, avatars. Module `Order = 350` (loads before consumer modules).\n\n**Entities / DbContext:** `FileAsset` (aggregate, soft-deletable): status `PendingUpload → Available | Quarantined`, `Visibility` (Public/Private), `ScanStatus`. `FilesDbContext`. Publishes `FileFinalizedIntegrationEvent`.\n**Areas:** RequestUploadUrl, FinalizeUpload, GetFileDownloadUrl/Metadata, ChangeVisibility, Delete/Restore, ListMy/Shared/Trashed. Purge jobs (orphaned hourly, deleted daily). Full list: `Features/v1/` or `/scalar`. Storage mechanics: `storage.md`.\n\n## Gotchas\n\n- **Presigned flow** — never stream uploads through the API. RequestUploadUrl validates category/extension/size + quota **pre-check** and persists a `PendingUpload`; client uploads directly to storage; **FinalizeUpload debits the quota** (not at request time) and flips to Available/Quarantined.\n- **`FileAccessPolicyRegistry`** resolves `IFileAccessPolicy` by **OwnerType** — case-insensitive, **closed by default** (unknown OwnerType → forbidden), **last-write-wins** on duplicates (intentional, for test substitution). Each owning module registers its own policy in its `ConfigureServices` (Catalog/Tickets load after Files). Files ships `DefaultUploaderOnlyPolicy` for built-in OwnerTypes `\"MyFiles\"`/`\"User\"`.\n- **Visibility is in the storage key** (`public/…` vs `private/…`, built only by `StorageKeyBuilder`). Changing visibility moves the object via `FileStorageRelocator` and publishes `FileStorageKeyChangedIntegrationEvent`; a module that stores a FileAsset's public URL must handle it. `MigrateLegacyPublicFileKeysJob` (enqueued on each start) moves pre-#1410 public files. See `storage.md`.\n- `CanChangeVisibilityAsync` defaults to the delete rule (uploader-only); domain-bound files (e.g. product images) may override to forbid visibility flips.\n- Tenant scoping is implicit via `BaseDbContext` (no explicit `TenantId` on `FileAsset`).\n\nTo support uploads for a new owner type: implement `IFileAccessPolicy`, register it in the owning module, and use that OwnerType in RequestUploadUrl.\n",".agents/rules/modules/identity.md":"# Module: Identity\n\nAuth (JWT + ASP.NET Identity), users, roles, permissions, sessions, impersonation, 2FA.\n\n## Service shape\n\n`IUserService` is a **facade** that delegates to focused single-responsibility services — change behavior in the specific service, not the facade:\n\n| Interface | Concern |\n|---|---|\n| `IUserRegistrationService` | register, external-principal create, email/phone confirm |\n| `IUserProfileService` | get/list/count, update profile, image, existence checks |\n| `IUserStatusService` | activate/deactivate (`DeleteAsync` == deactivate), audited toggles |\n| `IUserRoleService` | role assignment, admin-role guards |\n| `IUserPasswordService` | forgot/reset/change password, history + expiry |\n| `IUserPermissionService` | effective permissions, cache invalidation |\n\n`ChangePassword`/`Update`/`Delete` etc. flow facade → service → EF/UserManager. `CancellationToken` is `= default` on these interfaces and propagated into EF sinks (note: `UserManager`/`RoleManager` have no CT overloads, so private helpers that only call them don't take one).\n\n## Permission gating footgun\n\n`RequiredPermissionAttribute` implements `FSH.Framework.Shared.Identity.Authorization.IRequiredPermissionMetadata`. **Never let a second/duplicate `IRequiredPermissionMetadata` appear** — it silently disables **all** `.RequirePermission()` gates across the app. Permission constants live in `Shared/Identity/*Permissions.cs`.\n\n## Hosted services (background)\n\n- `RolePermissionSyncHostedService` — best-effort sync of the permission catalog; loops, catches `Exception` *with* an `OperationCanceledException` filter, logs and continues.\n- `SessionCleanupHostedService` — hourly expired-session purge; OCE handled by a preceding catch.\n\nThese are the model for background loops: stay alive, log with context, never swallow cancellation. See `api-conventions.md`.\n\n## Tokens / sessions\n\nLogin `POST /api/v1/identity/token/issue` (header `X-FSH-App` enforces the operator/tenant app boundary). Refresh `POST /api/v1/identity/token/refresh` cross-checks subject. Session rows are written best-effort during login — failures log a warning and login still succeeds. Admin can't demote/deactivate the last admin or the root-tenant seed admin (guards in `UserRoleService`/`UserStatusService`).\n\n## Tests\n\n`Identity.Tests` is the largest unit suite. When asserting a forwarded `CancellationToken`, assert the specific token (see `testing.md`).\n",".agents/rules/modules/multitenancy.md":"# Module: Multitenancy\n\nTenant catalog, provisioning, activation/upgrade, per-tenant theming (Finbuckle.MultiTenant). Foundational — registered early.\n\n**Entities / DbContext:** `AppTenantInfo` (catalog), `TenantProvisioning` + `TenantProvisioningStep`, `TenantTheme`. `TenantDbContext` holds the tenant catalog in the main DB.\n**Areas:** CreateTenant, ChangeTenantActivation, UpgradeTenant, Get(Tenants/Status/Migrations), TenantProvisioning (status/retry), TenantTheme (get/update/reset). Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Finbuckle pipeline ordering** — strategy chain Claim → Header → `?tenant=` → DistributedCache → EFCoreStore. `UseMultiTenant()` runs **before `UseAuthentication()`**, but Finbuckle's `ClaimStrategy` runs the default auth scheme's handler itself, so an authenticated caller resolves to its **tenant claim** and a `tenant` header cannot override it. The header (`MultitenancyConstants.Identifier`) decides only for anonymous requests (login, refresh); the root-operator override is post-auth middleware in `MultitenancyModule.ConfigureMiddleware`.\n- **Root-operator cross-tenant override is a post-auth middleware** in `ConfigureMiddleware` (not a Finbuckle strategy). Gate: caller's JWT tenant claim == `MultitenancyConstants.Root.Id` **and** a `tenant` header != root; it re-resolves via `IMultiTenantContextSetter`. Claim-aware tenant logic must go here, never in a strategy.\n- **`ITenantInitialPasswordBuffer`** (singleton) — the tenant admin password is **operator-supplied**, not a constant. `CreateTenantCommandHandler` calls `Store(tenantId, password)` **before** kicking off provisioning; the background seed step `TryConsume`s it (`ConcurrentDictionary`, consume = remove).\n- **Provisioning** runs 4 steps (Database → Migrations → Seeding → CacheWarm) via a Hangfire `TenantProvisioningJob`, falling back to inline execution if Hangfire storage is unavailable. **Activation is gated on `Status == Completed`.**\n- `ITenantService.MigrateTenantAsync`/`SeedTenantAsync` create a fresh scope and set `IMultiTenantContext` **first**, then run the `IDbInitializer`s.\n\nTenant **isolation** mechanics (default-on filter, `IGlobalEntity` opt-out, `base.OnModelCreating` last) live in `database.md`.\n",".agents/rules/modules/notifications.md":"# Module: Notifications\n\nPer-user in-app inbox (bell icon) driven by cross-module integration events, with live SignalR push. Module `Order = 750` (**before Chat 800** so its handlers are registered before Chat publishes).\n\n**Entities / DbContext:** `Notification` (aggregate: `UserId`, `Type`, `Title`/`Body`/`Link`, `Source`, `MetadataJson`, `ReadAtUtc`). `NotificationsDbContext`. Consumes integration events from other modules (e.g. Chat's `MentionedInChannelIntegrationEvent`).\n**Areas:** List, GetUnreadCount, MarkRead, MarkAllRead. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **It's a consumer.** New notification types come from **handling another module's integration event** (`AddIntegrationEventHandlers`), not from new endpoints. The handler writes an inbox row **and** pushes `\"NotificationCreated\"` to SignalR group `user:{userId}` via `IHubContext<AppHub>`.\n- **Order matters** — Notifications (750) must load before any publisher whose events it consumes (Chat 800). If a new module publishes events Notifications should react to, mind the `Order`.\n- In-memory bus runs handlers **synchronously in the publisher's request scope** — keep the handler minimal; an exception surfaces to the originating request. See `eventing.md`.\n- Inbox rows are **denormalized** (Title/Body/Link/MetadataJson copied in) so rendering never calls back into the source module. `MarkRead` is idempotent (`ReadAtUtc ??= now`).\n",".agents/rules/modules/tickets.md":"# Module: Tickets\n\nSupport ticket lifecycle with comments. Module `Order = 700`.\n\n**Entities / DbContext:** `Ticket` (aggregate, soft-deletable, state machine) + `TicketComment`. `TicketsDbContext`. `TicketStatus`/`TicketPriority` enums in Contracts; domain events internal.\n**Areas:** Create, Assign, Resolve, Reopen, Restore, AddComment, ListComments, GetById, Search, ListTrashed. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **State machine** (`Domain/Ticket.cs`): `Open → InProgress → Resolved → Closed`. Illegal transitions throw **`CustomException` with `HttpStatusCode.Conflict` (409)** — not a generic 400. Assigning auto-starts (Open→InProgress); unassigning an InProgress ticket reverts to Open; creating with an assignee starts at InProgress. Closed tickets reject comments/resolve until reopened. Keep all transition guards in the aggregate.\n- Soft-delete/restore/trash pattern is identical to Catalog (filtered unique indexes — see `modules/catalog.md`).\n- Endpoints are mapped on the bare `api/v{version}` group (no `/tickets` sub-path); literal routes precede `{ticketId:guid}`.\n",".agents/rules/modules/webhooks.md":"# Module: Webhooks\n\nTenant-scoped outbound webhook subscriptions with HMAC-signed delivery and retries. Module `Order = 400`.\n\n**Entities / DbContext:** `WebhookSubscription` (`Url`, `EventsCsv`, `SecretHash`, `IsActive`), `WebhookDelivery` (per-attempt log). `WebhookDbContext` (tenant-filtered). Contracts expose **DTOs only** — `IWebhookDispatcher`/`IWebhookDeliveryService` are internal.\n**Areas:** Create/Delete/Get subscriptions, GetDeliveries, Test. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Fan-out is an open-generic handler** — `WebhookFanoutHandler<TEvent>` is registered as an open generic, so it handles **every** `IIntegrationEvent` with no per-event wiring. It skips events with null `TenantId` (subscriptions are tenant-only) and matches event-type name against each subscription's `EventsCsv` (`*` wildcard supported).\n- **Restore tenant context in the background** — both the fan-out handler and `WebhookDispatchJob` set `IMultiTenantContext` in a fresh scope before reading the tenant-filtered DbContext (background pumps/Hangfire carry no HTTP context). This is the canonical pattern for any background reader of tenant data — see `eventing.md`, `jobs.md`.\n- **HMAC signing** — `X-Webhook-Signature: sha256=<hex HMACSHA256>` (`WebhookPayloadSigner.Sign`), plus `X-Webhook-Event` and `X-Webhook-Delivery-Id` headers.\n- **Delivery** — `WebhookDispatcher.EnqueueAsync` enqueues a Hangfire `WebhookDispatchJob` per subscription; `[AutomaticRetry(Attempts=4, DelaysInSeconds={30,120,600,3600})]`. Transient (5xx/408/429) throws to reschedule; permanent 4xx completes silently. Each attempt persists a `WebhookDelivery` row. The `\"Webhooks\"` HttpClient uses `AddHeroResilience` (see `resilience.md`).\n",".agents/rules/realtime.md":"# Realtime — SignalR & SSE (backend)\n\n`src/BuildingBlocks/Web/Realtime/` + `Sse/`. For the frontend side see `frontend/shared.md` + `frontend/dashboard.md`.\n\n## SignalR (`AppHub`)\n\n`[Authorize] AppHub` mapped at **`/api/v1/realtime/hub`**. Groups: `user:{userId}`, `tenant:{tenantId}`, `channel:{channelId}`.\n\n- **Channel-group join is connect-time + on-demand.** `OnConnectedAsync` auto-joins `user:{id}`, `tenant:{id}`, and every `channel:{id}` the user is *already* a member of. A channel that becomes relevant **after** the socket is live (a new DM, or being added to a channel) is **not** auto-joined — the client must call the membership-gated **`JoinChannel(channelId)`** hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs `OnConnectedAsync`. New-DM creation pushes `ChatChannelAdded` to each other participant's `user:{id}` group so their channel list refreshes.\n- **⚠️ Read the user from `Context.User`, NOT `ICurrentUser`.** `ICurrentUser` flows through `IHttpContextAccessor`, but the negotiate `HttpContext` isn't pinned to subsequent hub invocations → `ICurrentUser` returns nulls inside the hub. Use `Context.User` (the hub's `GetUserId()`/`GetTenantId()` helpers).\n- Broadcasts are **scoped to groups** (`tenant:{id}`, `user:{id}`, `channel:{id}`), never `Clients.All`. `PresenceChanged` goes to the tenant group.\n- Redis backplane is added automatically when `CachingOptions:Redis` is set (channel prefix `fsh-signalr`) — required for multi-replica.\n- Push to a user from a module via `IHubContext<AppHub>` to group `user:{userId}` (e.g. Notifications' `\"NotificationCreated\"`).\n- `IPresenceTracker` (in-memory, **per-host** — single-replica only for presence). Modules supply `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups without depending on Chat.\n\n## SSE (`Web/Sse/`) — two-step token\n\nEventSource can't send `Authorization`, so SSE uses a token handshake:\n1. `POST /api/v1/sse/token` (authorized) → opaque Guid, **single-use, 30s TTL** in `IDistributedCache`.\n2. `GET /api/v1/sse/stream?token={guid}` (anonymous, consumes the token) → `text/event-stream`, `X-Accel-Buffering: no`, 15s heartbeat.\n\n`SseConnectionManager` (singleton, `ConcurrentDictionary`, bounded channel cap 100 `DropOldest`): `TrySend(userId)` (all tabs), `Broadcast(tenantId)`, `BroadcastAll()`.\n\n## Tests\n\nSignalR hub tests force **long-polling** (TestServer has no WebSocket). See `integration-testing.md`.\n",".agents/rules/resilience.md":"# HTTP resilience\n\n`src/BuildingBlocks/Web/HttpResilience/`. Uses `Microsoft.Extensions.Http.Resilience` (Polly v8).\n\n## Pattern — opt-in per HttpClient\n\n`AddHeroResilience(config)` is an `IHttpClientBuilder` extension that adds `AddStandardResilienceHandler` configured from `HttpResilienceOptions` (retry, total-request timeout, attempt timeout, circuit breaker). It is **NOT global** — chain it onto the specific outbound client that needs it:\n\n```csharp\nbuilder.Services.AddHttpClient(\"Webhooks\", ...)\n    .AddHeroResilience(builder.Configuration);\n```\n\nDefaults (`HttpResilienceOptions`): 3 retries, 30s total, 10s per attempt, 50% failure ratio, throughput 10. No-ops when `Enabled=false`.\n\n## Notes\n\n- Only outbound integrations need this. The only current caller is the Webhooks delivery client (`WebhooksModule.cs`). Add it to any new typed/named `HttpClient` that calls a flaky external service.\n- For internal ret/timeout of *background* work, prefer Hangfire's `[AutomaticRetry]` on the job (see `modules/webhooks.md`) — that's durable across restarts; the resilience handler only covers the in-flight HTTP call.\n",".agents/rules/security.md":"# Web security & request governance\n\nCORS, security headers, rate limiting, idempotency, quota enforcement. `src/BuildingBlocks/Web/` + `Quota/`.\nFor auth/JWT/permissions see `modules/identity.md`; for the global exception handler see `api-conventions.md`.\n\n## CORS (`Web/Cors/`) — the SignalR gotcha\n\nPolicy `FSHCorsPolicy`. When `CorsOptions.AllowAll=true` it uses **`SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod().AllowCredentials()`** — deliberately **NOT `AllowAnyOrigin()`**. `Access-Control-Allow-Origin: *` is illegal with credentialed requests, and **SignalR's negotiate always runs credentialed**, so `AllowAnyOrigin()` silently breaks SignalR while REST keeps working. Never \"simplify\" it to `AllowAnyOrigin()`. `UseHeroCors()` runs **before** `UseHttpsRedirection()` so OPTIONS preflight isn't 307-redirected.\n\n## Front-end origin for outbound links (`Web/Frontend/`)\n\n`IFrontendOriginResolver` builds the origin of user-facing links sent in e-mails (password reset, e-mail confirmation). Backed by **`FrontendOptions`**, deliberately separate from `CorsOptions`: CORS governs which browsers may *call* the API, this governs which origins may appear *inside an outbound link*. Never merge the two lists — coupling them breaks same-origin/reverse-proxy topologies and overloads a security boundary.\n\n- **`ResolveForCurrentRequest()`** — self-service flows only (forgot-password, self-register), where the caller *is* the recipient. Validates the request `Origin` against `FrontendOptions:AllowedOrigins` and returns the **canonical configured entry**, never the client's string. Present-but-unlisted against a non-empty list → `CustomException(HttpStatusCode.BadRequest)`: these endpoints are anonymous, so a forged header must never reach an e-mail. No `Origin` header, or an empty/all-unparseable list, falls through to the default.\n- **`ResolveDefault()`** — operator-driven flows (admin register, resend-confirmation) and background jobs, where the caller is **not** the recipient. Chain: `FrontendOptions:DefaultOrigin` → throw. Never the caller's `Origin`, or an operator would send a tenant user a link into the admin console; and never the request's `Host` either, which is the same string under a different name. There is deliberately no fallback to `OriginOptions:OriginUrl`: these links address SPA routes (`/confirm-email`, `/reset-password`), so the API's own origin would 404 them.\n\nPicking the wrong method is a silent bug — both compile and both return a plausible origin. Match the method to **who receives the link**, not to who sent the request.\n\nNo `ValidateOnStart` on `FrontendOptions`, on purpose: a deployment that never sends such a link must not be taken down by the setting. `UseHeroPlatform` logs at startup instead — `Warning` for an empty or all-typo allow-list (counted after normalization, so a list of nothing but typos reports as the empty list it effectively is), `Error` for a missing `DefaultOrigin`, since without it those flows return 500 rather than degrading. `OriginOptions:OriginUrl` keeps its own job — the API's own public base for back-end-served assets (avatars), read through `IRequestContext.Origin`.\n\n## Security headers (`Web/Security/`)\n\n`UseHeroSecurityHeaders()` sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, HSTS (HTTPS), and a CSP. `SecurityHeadersOptions.ExcludedPaths` defaults to `[\"/scalar\",\"/openapi\"]` (they manage their own scripts) — keep those excluded.\n\n## Rate limiting (`Web/RateLimiting/`)\n\nChained partitioned fixed-window limiter: **tenant → user → IP** (defaults 1000 / 200 / 300 per 60s) + a stricter named `\"auth\"` policy (10/60s). Health paths are unlimited. Rejection → 429 + ProblemDetails + `Retry-After`. `RateLimitingOptions.Enabled` is read **eagerly** — when false the middleware is skipped entirely (tests set it via env var before host build).\n\n## Idempotency (`Web/Idempotency/`)\n\nOpt-in per endpoint with **`.WithIdempotency()`** on **authenticated** POST/PUTs that must be replay-safe (e.g. CreateTenant). Reads `Idempotency-Key` (max 128 chars, `DefaultTtl` 24h); a replay returns the cached status + body + `Location`/`ETag` and `Idempotency-Replayed: true`.\n\n- **Key scope:** resolved tenant (never the raw `tenant` header) + operation (method + route pattern) + route values + caller (`GetUserId()`, else `\"anon\"`), via `CacheKeys.IdempotencyEntry`.\n- **Only 2xx is stored**, before the body is sent, on `CancellationToken.None`. Probe and write must share one `IDistributedCache` + key + serializer, or replay silently never engages.\n- **Concurrent duplicates:** in-flight reservation under a `lock:` prefix (Redis `SET NX`, else in-process, `ReservationTtl` default 1m); cache re-probed after the lock; duplicate in flight → **409** + `Retry-After: 1`. Probe, reserve and release all fail open.\n- **`RequestAborted` is detached** while the handler runs, so a client disconnect can't cancel a committed side effect. Keep these handlers short; **never** on streaming or large-file endpoints (the response is buffered).\n- **Never on `AllowAnonymous()`** endpoints — all anonymous callers share `\"anon\"`. `IdempotencyWiringTests` fails the build if one appears.\n\n## Quota enforcement (`Quota/`)\n\n`QuotaEnforcementMiddleware` charges 1 `ApiCalls` unit per request via `CheckAndRecordAsync`; over-limit → 429 + ProblemDetails + `Retry-After`, and sets `HttpContext.Items[QuotaRejected]` so auditing can tag it. Resources: `ApiCalls` (counter), `StorageBytes`, `Users`, `ActiveFeatureFlags` (gauges). Skips health/metrics, unresolved tenants, and the root tenant. **Pipeline:** runs after auth (needs tenant) and after the rate limiter. Inject `TimeProvider` (not `DateTimeOffset.UtcNow`) for any time math here — the subsystem is `TimeProvider`-based.\n\n`IQuotaService`: `CheckAsync` (no mutation), `RecordAsync` (increment), `CheckAndRecordAsync` (atomic — won't increment past the limit). Store: Redis (`RedisQuotaService`) or per-process `InMemoryQuotaService` (dev/test). `NoopQuotaService` when disabled.\n",".agents/rules/storage.md":"# Storage & file uploads\n\n`src/BuildingBlocks/Storage/`. Read before working with files/blobs.\n\n## `IStorageService`\n\n`UploadAsync<T>(FileUploadRequest, FileType, ct)`, `RemoveAsync(path, ct)`, `DownloadAsync`, `ExistsAsync`, `GetSizeAsync` (0 if absent), `GenerateUploadUrlAsync`/`GenerateDownloadUrlAsync` (presigned), `HeadObjectAsync`, `BuildPublicUrl(key)→string` (string, not Uri — local storage returns a server-relative path).\n\n`CopyAsync(source, destination, ct)` is a server-side copy that **throws** on failure (unlike `RemoveAsync`) — callers move objects as copy → update the reference → delete. The quota decorator charges the copy, so a move is net-zero.\n\n## Visibility lives in the key\n\nBucket policies in every stack (Aspire bootstrap, compose `rustfs-init`, Terraform `app_s3_public_read_prefix` + the CloudFront read statement) grant anonymous `s3:GetObject` on **`public/*` only** (`StorageVisibilityRoot`). So:\n- Files keys are `{public|private}/tenants/{tenant}/{owner}/{yyyy}/{MM}/{id:N}/{name}` — `StorageKeyBuilder` (Files module) is the **one** place that decides; never hand-build a key.\n- `S3StorageService.UploadAsync<T>` always returns a public URL, so it writes under `public/uploads/…`.\n- Moves go through `FileStorageRelocator.ApplyAsync`, never by hand: it takes the file's row lock (`SELECT … FOR UPDATE`, PostgreSQL), re-reads the row, then copy → commit row + `FileStorageKeyChangedIntegrationEvent` → delete old. The old object is deleted only when no row references it; the first attempt runs right after the commit, and Files' own `DeleteSupersededObjectHandler` retries it via the outbox until it is really gone (so a Public → Private flip can't leave a public copy behind).\n- Modules that persist a public URL must handle that event and use `ResolveUrl` (it returns the API's current `NewPublicUrl` for public files) — Catalog (product images) and Identity (avatars) do.\n- Keys from before #1410 (`tenants/…`, `uploads/…`) have no root: `MigrateLegacyPublicFileKeysJob` (enqueued via `IJobService` on every API start; keyset-paginated; retried by Hangfire) moves public FileAssets under `public/`; legacy private ones stay put. It reads the partial index `IX_FileAsset_LegacyKey`, which is empty once done, so repeat runs are nearly free. `FinalizeUpload` relocates uploads that were presigned before the upgrade. Legacy `UploadAsync<T>` objects (`uploads/…`) are **not** migrated.\n- `QuotaMeteredStorageService.RemoveAsync` refunds only when the object is gone afterwards (providers swallow delete errors).\n- With `Storage:S3:Prefix` set, keys become `{Prefix}/public/…` — the policy must match that, and the prefix must **not** be `public` or `private`.\n- The **Local** provider serves everything under wwwroot: it does not enforce `private/` at all. Use it for dev only.\n\n`FileType`: `Image` (5MB), `Document`, `Pdf` (10MB) — `FileTypeMetadata.GetRules` enforces extension + size. **Always propagate `CancellationToken`.**\n\n## Providers\n\n`AddHeroStorage(config)` reads `Storage:Provider` **eagerly at registration**: `\"s3\"` → `S3StorageService` (supports any S3-compatible store, e.g. RustFS, via `ServiceUrl` + `ForcePathStyle`), else `LocalStorageService`. When quotas are enabled the service is wrapped in `QuotaMeteredStorageService` (debits `StorageBytes`).\n\n`Storage:S3:PresignServiceUrl` (optional, validated at startup as an absolute http(s) URL): presigned PUT/GET URLs are signed for this host through a second, keyed `IAmazonS3` (`S3StorageService.PresignClientKey`); all real I/O stays on `ServiceUrl`. Needed when the API reaches the store on an internal address browsers can't resolve (compose: `http://rustfs:9000`). A test factory that re-registers the S3 stack must register that keyed client too (see `FshWebApplicationFactory`).\n\n## Presigned upload flow (preferred for user uploads)\n\nDon't stream large files through the API. The pattern (see Files module):\n1. `RequestUploadUrl` — server validates category/extension/size + quota pre-check, returns a presigned PUT URL, persists a `PendingUpload` record.\n2. Client uploads **directly** to storage.\n3. `FinalizeUpload` — flips to `Available`, **debits the quota here** (not at request time), publishes `FileFinalizedIntegrationEvent`.\n\nLocal/dev without an S3 store uses `LocalPresignTokenStore` (in-memory one-shot tokens).\n\n## Test gotcha\n\n`AddHeroStorage` reads `Storage:Provider` **before** a test factory's in-memory config overlay applies, so it wires `LocalStorageService`. Integration tests that need object storage must **remove the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-register the S3 stack** pointed at the RustFS container (see `FshWebApplicationFactory`). See `integration-testing.md`.\n",".agents/rules/testing.md":"# Testing conventions\n\nRead before writing or changing tests.\n\n## Stack\n\nxUnit · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For<IService>()`) · AutoFixture (`_fixture.Create<T>()`) · NetArchTest (architecture rules) · Testcontainers (integration).\n\n## Naming & shape\n\n- Method name: `MethodName_Should_ExpectedBehavior_When_Condition`.\n- Arrange-Act-Assert, grouped with `#region` (Happy Path / Exception / Edge Cases).\n- Assert on observable behavior. When verifying a forwarded `CancellationToken`, assert the **specific** token (`Received(1).XAsync(arg, ct)`), not the implicit default — NSubstitute fills optional params with `default`, so `Received(1).XAsync(arg)` silently asserts `CancellationToken.None`.\n\n## Test projects (`src/Tests/`)\n\n| Project | Scope | Docker? |\n|---|---|---|\n| `{Module}.Tests` | Unit: handlers, services, domain | no |\n| `Framework.Tests`, `Generic.Tests`, `Caching.Tests` | BuildingBlocks units | no |\n| `Architecture.Tests` | NetArchTest: module boundaries + tenant-isolation rules + handler↔validator pairing | no |\n| `Integration.Tests` | `WebApplicationFactory` over real PostgreSQL/Redis/RustFS | **yes** |\n| `Integration.Middleware.Tests` | Real middleware wiring | **yes** |\n\n```bash\ndotnet test src/FSH.Starter.slnx                 # all (integration needs Docker)\ndotnet test src/Tests/{Module}.Tests             # one project\ndotnet test --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\n\nIf Docker is down, integration tests fail fast with `DockerUnavailableException` — that is environmental, not a code regression. Run the unit projects to validate logic.\n\n## Architecture tests (must stay green)\n\n- Modules reference other modules only via `.Contracts`.\n- Tenant-isolation rules on entities.\n- Every command handler + paginated query handler has a `{Name}Validator` (`HandlerValidatorPairingTests`). Validator names accepted: `{Cmd}Validator`, `{Name}CommandValidator`, `{Name}Validator`.\n\n## Integration-test gotchas\n\n- Set the Finbuckle tenant context **inline** in the test method (AsyncLocal — an awaited helper loses it → NRE in the tenant filter).\n- `AddHeroStorage` reads config eagerly; rewire `IStorageService` **after** registration in the factory.\n- SignalR hub tests force **long-polling** (TestServer has no WebSocket).\n\n## Frontend tests\n\nPlaywright, route-mocked (no real backend) — see `frontend/shared.md`. `cd clients/{app} && npm run test:e2e`.\n",".agents/skills/add-entity/SKILL.md":"---\nname: add-entity\ndescription: Add a domain entity/aggregate with EF configuration and a migration to an existing FSH module. Use when adding a new database-backed entity. Pairs with add-feature and create-migration.\nargument-hint: \"[ModuleName] [EntityName]\"\n---\n\n# Add Entity\n\nRich domain model: `sealed` aggregate, private EF ctor, static factory, behavior via methods, domain\nevents. DB conventions: `.agents/rules/database.md`.\n\n## Entity — `AggregateRoot<Guid>` (or `BaseEntity<Guid>`)\n\n`BaseEntity<TId>` gives only `Id` + domain-event machinery. Audit/tenant/soft-delete are **opt-in via\nmarker interfaces** (the base does NOT carry those fields). New ids use **`Guid.CreateVersion7()`**.\n\n```csharp\npublic sealed class {Entity} : AggregateRoot<Guid>, IHasTenant, IAuditableEntity, ISoftDeletable\n{\n    public string Name { get; private set; } = default!;\n    public Money Price { get; private set; } = default!;\n\n    // IHasTenant\n    public string TenantId { get; private set; } = default!;\n    // IAuditableEntity\n    public DateTimeOffset CreatedOnUtc { get; set; }\n    public string? CreatedBy { get; set; }\n    public DateTimeOffset? LastModifiedOnUtc { get; set; }\n    public string? LastModifiedBy { get; set; }\n    // ISoftDeletable\n    public bool IsDeleted { get; set; }\n    public DateTimeOffset? DeletedOnUtc { get; set; }\n    public string? DeletedBy { get; set; }\n\n    private {Entity}() { }   // EF\n\n    public static {Entity} Create(string name, Money price)\n    {\n        ArgumentException.ThrowIfNullOrWhiteSpace(name);\n        ArgumentNullException.ThrowIfNull(price);\n\n        var entity = new {Entity} { Id = Guid.CreateVersion7(), Name = name.Trim(), Price = price };\n        entity.AddDomainEvent(DomainEvent.Create((id, ts) =>\n            new {Entity}CreatedDomainEvent(entity.Id, entity.Name, id, ts)));\n        return entity;\n    }\n\n    public void Rename(string name)\n    {\n        ArgumentException.ThrowIfNullOrWhiteSpace(name);\n        Name = name.Trim();\n    }\n}\n```\n\nNotes: setters are `private set`; `TenantId`/audit/soft-delete members are settable by the framework\n(interceptor + Finbuckle) so they aren't `private set`. Use `Guid.CreateVersion7()`, never `Guid.NewGuid()`.\n\n## Domain event — inherit `DomainEvent` (abstract record)\n\n```csharp\npublic sealed record {Entity}CreatedDomainEvent(\n    Guid {Entity}Id, string Name, Guid EventId, DateTimeOffset OccurredOnUtc)\n    : DomainEvent(EventId, OccurredOnUtc);\n```\n\nRaise with the `DomainEvent.Create((id, ts) => …)` helper + `AddDomainEvent(...)` (not `QueueDomainEvent`).\n\n## EF configuration\n\n```csharp\npublic sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>\n{\n    public void Configure(EntityTypeBuilder<{Entity}> builder)\n    {\n        ArgumentNullException.ThrowIfNull(builder);\n        builder.ToTable(\"{Entities}\");                       // schema is set once on the DbContext\n        builder.HasKey(x => x.Id);\n        builder.Property(x => x.Name).IsRequired().HasMaxLength(200);\n\n        // soft-deletable unique field → filter on live rows only\n        builder.HasIndex(x => x.Name).IsUnique().HasFilter(\"\\\"IsDeleted\\\" = FALSE\");\n\n        // owned value object\n        builder.OwnsOne(x => x.Price, m =>\n        {\n            m.Property(p => p.Amount).HasColumnName(\"PriceAmount\").HasPrecision(18, 4);\n            m.Property(p => p.Currency).HasColumnName(\"PriceCurrency\").HasMaxLength(3);\n        });\n\n        builder.Ignore(x => x.DomainEvents);\n    }\n}\n```\n\n- **Do NOT add a manual `HasQueryFilter` for soft-delete or tenant** — `BaseDbContext` applies both automatically.\n- A child entity reached only via a parent nav-collection needs `builder.Property(x => x.Id).ValueGeneratedNever()` in **its** config, or EF inserts it as `Modified` → 0-row UPDATE. See `database.md`.\n\n## Register in the module DbContext\n\nAdd a `DbSet`; configurations are picked up by `ApplyConfigurationsFromAssembly`:\n\n```csharp\npublic DbSet<{Entity}> {Entities} => Set<{Entity}>();\n```\n\nThe DbContext already extends `BaseDbContext` and calls `base.OnModelCreating` **last** — don't change that.\n\n## Migration\n\nUse the **create-migration** skill (build first, correct `--context`):\n\n```bash\ndotnet ef migrations add Add{Entity} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {X}DbContext\n```\n\n## Checklist\n\n- [ ] `sealed`, `AggregateRoot<Guid>` (+ `IHasTenant`/`IAuditableEntity`/`ISoftDeletable` as needed), private ctor, static `Create` using `Guid.CreateVersion7()`\n- [ ] Domain event inherits `DomainEvent`; raised via `DomainEvent.Create` + `AddDomainEvent`\n- [ ] EF config: no manual soft-delete/tenant filter; `ValueGeneratedNever()` on nav-collection children\n- [ ] `DbSet` added; build green; migration created with `--context {X}DbContext`\n",".agents/skills/add-feature/SKILL.md":"---\nname: add-feature\ndescription: Add a vertical-slice feature (command/query + handler + validator + endpoint) to an existing FSH module. Use when adding an API endpoint or business operation to a module that already exists.\nargument-hint: \"[ModuleName] [Area] [FeatureName]\"\n---\n\n# Add Feature\n\nA feature is a vertical slice **split across two projects**: the request/response types live in the\nmodule's `.Contracts` project (public API); the handler, validator, and endpoint live in the runtime\nproject. Full conventions: `.agents/rules/api-conventions.md`.\n\n## Layout (real)\n\n```\nsrc/Modules/{X}/Modules.{X}.Contracts/v1/{Area}/{Feature}Command.cs   # ICommand<T>/IQuery<T>\nsrc/Modules/{X}/Modules.{X}.Contracts/Dtos/{Entity}Dto.cs             # response DTOs (if any)\nsrc/Modules/{X}/Modules.{X}/Features/v1/{Area}/{Feature}/\n├── {Feature}CommandHandler.cs    # public sealed, injects the DbContext directly\n├── {Feature}CommandValidator.cs  # required for commands + paginated queries\n└── {Feature}Endpoint.cs          # internal static extension\n```\n\n## Step 1 — Command/Query (Contracts project)\n\n`Mediator` interfaces (`using Mediator;`). Records. A create command can return the raw `Guid`.\n\n```csharp\nnamespace FSH.Modules.{X}.Contracts.v1.{Area};\n\npublic sealed record Create{Entity}Command(string Name, decimal PriceAmount, string PriceCurrency)\n    : ICommand<Guid>;\n```\n\nRead/list DTOs go in `Modules.{X}.Contracts/Dtos/`. Paginated queries return `PagedResponse<T>`\n(`FSH.Framework.Shared.Persistence`) — see `query-patterns`.\n\n## Step 2 — Handler (runtime `Features/`) — inject the DbContext, NOT a repository\n\nThere is **no generic `IRepository<T>`**. Inject the module's `{X}DbContext`. `public sealed`, primary\nctor, `ValueTask<T>`, `.ConfigureAwait(false)`, guard first. Tenant/audit fields are auto-stamped — only\ninject `ICurrentUser` if you need the acting user (`GetUserId()` / `GetTenant()`).\n\n```csharp\npublic sealed class Create{Entity}CommandHandler(CatalogDbContext dbContext)\n    : ICommandHandler<Create{Entity}Command, Guid>\n{\n    public async ValueTask<Guid> Handle(Create{Entity}Command command, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(command);\n\n        var entity = {Entity}.Create(command.Name, new Money(command.PriceAmount, command.PriceCurrency));\n        dbContext.{Entities}.Add(entity);\n        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);\n        return entity.Id;\n    }\n}\n```\n\nThrow `NotFoundException` / `CustomException(msg, errors, HttpStatusCode)` (`FSH.Framework.Core.Exceptions`) — the global handler maps them to ProblemDetails.\n\n## Step 3 — Validator (required; same folder)\n\n```csharp\npublic sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command>\n{\n    public Create{Entity}CommandValidator()\n    {\n        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);\n        RuleFor(x => x.PriceCurrency).NotEmpty().Length(3);\n    }\n}\n```\n\n`Architecture.Tests` fails the build if a command/paginated-query handler has no `{Name}Validator`.\n\n## Step 4 — Endpoint (same folder)\n\n```csharp\npublic static class Create{Entity}Endpoint\n{\n    internal static RouteHandlerBuilder MapCreate{Entity}Endpoint(this IEndpointRouteBuilder endpoints) =>\n        endpoints.MapPost(\"/{entities}\",\n                async (Create{Entity}Command command, IMediator mediator, CancellationToken ct) =>\n                    Results.Ok(await mediator.Send(command, ct)))\n            .WithName(\"Create{Entity}\")\n            .WithSummary(\"Create a {entity}\")\n            .RequirePermission({X}Permissions.{Entities}.Create)\n            .WithIdempotency();   // on replay-safe POSTs\n}\n```\n\n## Step 5 — Wire it in `{X}Module.MapEndpoints`\n\n```csharp\ngroup.MapCreate{Entity}Endpoint();   // group = endpoints.MapGroup(\"api/v{version:apiVersion}/{x}\") …\n```\n\n## Step 6 — Verify\n\n```bash\ndotnet build src/FSH.Starter.slnx          # 0 warnings (TreatWarningsAsErrors)\ndotnet test src/Tests/{X}.Tests            # + add a handler/validator test (see testing-guide)\n```\n\n## Checklist\n\n- [ ] Command/Query in the **Contracts** project (`using Mediator;`), DTOs in `Contracts/Dtos/`\n- [ ] Handler `public sealed`, injects `{X}DbContext` (no repository), `ValueTask<T>` + `.ConfigureAwait(false)`\n- [ ] `{Name}Validator` exists\n- [ ] Endpoint `internal static …Map{Feature}Endpoint`, `.RequirePermission(...)`, `.WithName/.WithSummary`\n- [ ] Wired in `{X}Module.MapEndpoints`\n- [ ] Build 0 warnings; test added\n",".agents/skills/add-full-slice/SKILL.md":"---\nname: add-full-slice\ndescription: Build a capability end-to-end — backend vertical slice (Contracts→handler→validator→endpoint) AND the React page wired to it. Use when delivering a user-facing feature across API + UI. Composes add-feature + add-react-page.\nargument-hint: \"[ModuleName] [admin|dashboard] [FeatureName]\"\n---\n\n# Add Full Slice (backend → frontend)\n\nThe kit's accelerator: deliver a feature from database to UI in one pass. This skill **composes**\n`add-feature` (backend) and `add-react-page` (frontend) — follow each for the detailed code; this file is\nthe order of operations and the **contract** that keeps the two halves in sync.\n\n## Order of operations\n\n1. **Backend slice** — `add-feature` (and `add-entity` first if a new entity is needed):\n   - Command/Query + response DTO in `Modules.{X}.Contracts/v1/{Area}/` (+ `Contracts/Dtos/`).\n   - Handler (`public sealed`, injects `{X}DbContext`), Validator, Endpoint (`internal static Map…Endpoint`, `.RequirePermission(...)`).\n   - Wire in `{X}Module.MapEndpoints`. Build + test backend green.\n2. **Lock the contract** — note the final **route path**, HTTP method, request shape, and response DTO field names/casing. The React side must match these exactly.\n3. **Frontend page** — `add-react-page` in the chosen app:\n   - API module calls the **same path**; hand-write TS types mirroring the **response DTO** (the API serializes C# records as camelCase JSON — TS fields are camelCase even though admin *query params* are PascalCase).\n   - Page (`useQuery`/`useMutation`), route (`RouteGuard` for admin / `withSuspense` for dashboard).\n4. **Permission** — if the endpoint is gated, mirror the constant into admin's `lib/permissions.ts` and gate the route (`add-permission`). Dashboard relies on the server 403.\n5. **Tests both sides** — backend handler/validator test (xUnit/Shouldly/NSubstitute) + frontend Playwright spec (route-mocked).\n\n## The contract (the thing that breaks if you're sloppy)\n\n| Backend | Frontend must match |\n|---|---|\n| Endpoint route `api/v{n}/{module}/{resources}` | `apiFetch` path |\n| Request: the `Command`/`Query` record fields | request body / query-param keys (admin params PascalCase; **body JSON camelCase**) |\n| Response: the DTO record (camelCase JSON) | the hand-written TS `type` |\n| `.RequirePermission({X}Permissions...)` | (admin) `RouteGuard perms` + the mirrored constant |\n| Paginated → `PagedResponse<T>` | `PagedResponse<T>` (admin: `@/lib/api-types`; dashboard: inline) |\n\n## Verify end-to-end\n\n```bash\ndotnet build src/FSH.Starter.slnx && dotnet test src/Tests/{X}.Tests\ncd clients/{app} && npm run lint && npm run test:e2e\n# optional manual check: dotnet run --project src/Host/FSH.Starter.AppHost  (brings up API + both apps)\n```\n\n## Checklist\n\n- [ ] Backend slice complete + green (`add-feature`)\n- [ ] Contract locked: route, request shape, response DTO field names\n- [ ] Frontend api module path + TS types match the contract (body JSON camelCase)\n- [ ] Page + route added (`add-react-page`); admin permission mirrored + gated (`add-permission`)\n- [ ] Backend test + Playwright test added; both suites green\n",".agents/skills/add-integration-event/SKILL.md":"---\nname: add-integration-event\ndescription: Publish a cross-module integration event via the Outbox and handle it idempotently in another module. Use when one module must react to something that happened in another. See .agents/rules/eventing.md.\nargument-hint: \"[SourceModule] [EventName] [ConsumerModule]\"\n---\n\n# Add Integration Event\n\nCross-module communication goes through **integration events + the Outbox** (transactional, crash-safe) —\nnever a direct in-process call into another module's runtime, and never `IEventBus.PublishAsync` from a\nhandler. Full model: `.agents/rules/eventing.md`.\n\n## Step 1 — Define the event (source module's Contracts)\n\n`Modules.{Source}.Contracts/Events/{Event}IntegrationEvent.cs` — implement `IIntegrationEvent`:\n\n```csharp\npublic sealed record {Event}IntegrationEvent(\n    Guid Id,\n    DateTime OccurredOnUtc,\n    string? TenantId,\n    string CorrelationId,\n    string Source,\n    Guid {Entity}Id,\n    string SomePayload) : IIntegrationEvent;\n```\n\n⚠️ Don't rename/move this type later — the outbox stores its assembly-qualified name; a rename makes\n`Type.GetType()` return null and the message dead-letters. Keep the type name + namespace stable.\n\n## Step 2 — Publish via the Outbox (source handler)\n\nPublishing needs **no module registration** — the outbox is framework-owned and the host wires it once. Inject `IOutboxWriter` (from `FSH.Framework.Eventing.Abstractions`) and add the event in the same unit of work:\n\n```csharp\npublic sealed class Do{Thing}CommandHandler({Source}DbContext db, IOutboxWriter outbox)\n    : ICommandHandler<Do{Thing}Command, Unit>\n{\n    public async ValueTask<Unit> Handle(Do{Thing}Command command, CancellationToken cancellationToken)\n    {\n        // … mutate entities, db.SaveChangesAsync …\n        var evt = new {Event}IntegrationEvent(\n            Id: Guid.CreateVersion7(),\n            OccurredOnUtc: DateTime.UtcNow,\n            TenantId: /* current tenant */,\n            CorrelationId: Guid.NewGuid().ToString(),\n            Source: \"{Source}\",\n            {Entity}Id: entity.Id,\n            SomePayload: \"…\");\n        await outbox.AddAsync(evt, cancellationToken).ConfigureAwait(false);\n        return Unit.Value;\n    }\n}\n```\n\nThe `OutboxDispatcherHostedService` later publishes it via `IEventBus`.\n\n## Step 3 — Handle it (consumer module)\n\n`Modules.{Consumer}/IntegrationEventHandlers/{Event}IntegrationEventHandler.cs` — `sealed`, implement `IIntegrationEventHandler<T>`:\n\n```csharp\npublic sealed class {Event}IntegrationEventHandler({Consumer}DbContext db /*, IHubContext<AppHub> hub */)\n    : IIntegrationEventHandler<{Event}IntegrationEvent>\n{\n    public async Task HandleAsync({Event}IntegrationEvent @event, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(@event);\n        // … write to the consumer's tables / push a notification …\n        await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);\n    }\n}\n```\n\nRegister the consumer's handlers in its `ConfigureServices`:\n\n```csharp\nbuilder.Services.AddIntegrationEventHandlers(typeof({Consumer}Module).Assembly);\n```\n\n## Gotchas\n\n- **Idempotency is free** with the in-memory bus (the Inbox dedups by `{eventId, handlerName}`) — don't hand-roll it.\n- The in-memory bus runs handlers **synchronously in the publisher's scope** — keep the handler lean; a throw surfaces to the originating request. Published via the outbox, that scope belongs to the dispatcher, so the consumer runs on the next cycle and its failures never reach the caller. Don't let a caller (or a test) assume the side effect already happened; integration tests drain with `OutboxDrain.DrainAsync`.\n- If the handler reads a **tenant-filtered** DbContext from a background path (open-generic handler, Hangfire job), restore Finbuckle context first via `IMultiTenantContextSetter` (see `WebhookFanoutHandler`).\n- **Module load order:** the consumer must load before the publisher if it must react (`Order` in `[assembly: FshModule]`) — e.g. Notifications (750) before Chat (800).\n\n## Checklist\n\n- [ ] Event in source Contracts, implements `IIntegrationEvent`, stable type name\n- [ ] Published via `IOutboxWriter.AddAsync` (not the bus); no per-module eventing registration needed\n- [ ] Consumer handler `sealed : IIntegrationEventHandler<T>`; `AddIntegrationEventHandlers(assembly)` registered\n- [ ] Background readers restore tenant context; module `Order` lets the consumer load first\n- [ ] Build + tests green\n",".agents/skills/add-module/SKILL.md":"---\nname: add-module\ndescription: Create a new module (bounded context) — runtime + Contracts projects, IModule, DbContext, permissions, migrations, and the four registration sites. Use when adding a distinct business domain. For a feature in an existing module, use add-feature.\nargument-hint: \"[ModuleName]\"\n---\n\n# Add Module\n\nHigh-ceremony. The part people get wrong is **registration — a module must be wired in FOUR places**\n(see Step 6). Architecture rules: `.agents/rules/architecture.md`.\n\n## Projects\n\n```\nsrc/Modules/{Name}/\n├── Modules.{Name}/            ← runtime (internal): Domain/, Data/, Features/v1/, {Name}Module.cs\n└── Modules.{Name}.Contracts/  ← public API: v1/ (commands/queries), Dtos/, Authorization/, Events/\n```\n\n**Copy an existing module's two `.csproj` files** (e.g. `Modules.Catalog`) and rename — don't hand-write\nproject references. The runtime project references its Contracts project + the BuildingBlocks it needs;\nthe Contracts project references `Mediator` + shared contracts.\n\n## Step 1 — `[FshModule]` is an ASSEMBLY attribute (not class-level)\n\nIn `{Name}Module.cs`, above the namespace:\n\n```csharp\n[assembly: FshModule(typeof(FSH.Modules.{Name}.{Name}Module), 900)]   // (Type, order)\n\nnamespace FSH.Modules.{Name};\n\npublic sealed class {Name}Module : IModule\n{\n    public void ConfigureServices(IHostApplicationBuilder builder)\n    {\n        ArgumentNullException.ThrowIfNull(builder);\n        PermissionConstants.Register({Name}Permissions.All);\n        builder.Services.AddHeroDbContext<{Name}DbContext>();\n        builder.Services.AddScoped<IDbInitializer, {Name}DbInitializer>();\n\n        // Only if the module HANDLES integration events:\n        // builder.Services.AddIntegrationEventHandlers(typeof({Name}Module).Assembly);\n        //\n        // Publishing needs no registration at all — the outbox is framework-owned\n        // (host calls AddEventingCore once). Inject IOutboxWriter and publish.\n        // Never register a per-module outbox store; see .agents/rules/eventing.md.\n\n        builder.Services.AddHealthChecks()\n            .AddDbContextCheck<{Name}DbContext>(name: \"db:{name}\");\n    }\n\n    public void ConfigureMiddleware(IApplicationBuilder app) { }   // optional, runs after auth\n\n    public void MapEndpoints(IEndpointRouteBuilder endpoints)\n    {\n        ArgumentNullException.ThrowIfNull(endpoints);\n        var versionSet = endpoints.NewApiVersionSet().HasApiVersion(new ApiVersion(1)).ReportApiVersions().Build();\n        var group = endpoints.MapGroup(\"api/v{version:apiVersion}/{name}\")\n            .WithTags(\"{Name}\").WithApiVersionSet(versionSet).RequireAuthorization();\n        // group.MapCreate{Entity}Endpoint();  …\n    }\n}\n```\n\n`Order` controls load sequence (Auditing 300, Files 350, Webhooks 400, Billing 500, Catalog 600, Tickets 700, Notifications 750, Chat 800). If your module consumes another's events, load after it.\n\n## Step 2 — Permissions (Contracts/Authorization)\n\n`{Name}Permissions` with nested resource classes and an `All` collection registered via `PermissionConstants.Register({Name}Permissions.All)`. Mirror the shape of `CatalogPermissions`.\n\n## Step 3 — DbContext (extends `BaseDbContext`)\n\n```csharp\npublic sealed class {Name}DbContext : BaseDbContext\n{\n    public const string Schema = \"{name}\";\n\n    public {Name}DbContext(\n        IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor,\n        DbContextOptions<{Name}DbContext> options,\n        IOptions<DatabaseOptions> settings,\n        IHostEnvironment environment)\n        : base(multiTenantContextAccessor, options, settings, environment) { }\n\n    public DbSet<{Entity}> {Entities} => Set<{Entity}>();\n\n    protected override void OnModelCreating(ModelBuilder modelBuilder)\n    {\n        ArgumentNullException.ThrowIfNull(modelBuilder);\n        modelBuilder.HasDefaultSchema(Schema);\n        modelBuilder.ApplyConfigurationsFromAssembly(typeof({Name}DbContext).Assembly);\n        base.OnModelCreating(modelBuilder);   // MUST be last — applies tenant + soft-delete filters\n    }\n}\n```\n\n## Step 4 — Solution + project references\n\n```bash\ndotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}/Modules.{Name}.csproj\ndotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}.Contracts/Modules.{Name}.Contracts.csproj\n```\n\nAdd a `<ProjectReference>` to the runtime module from **both** `FSH.Starter.Api` and `FSH.Starter.DbMigrator`, and reference the runtime project from `FSH.Starter.Migrations.PostgreSQL`.\n\n## Step 5 — Migrations folder\n\nAdd a `{Name}/` folder in `src/Host/FSH.Starter.Migrations.PostgreSQL`, then create the initial migration (see **create-migration**) with `--context {Name}DbContext`.\n\n## Step 6 — ⚠️ Register in ALL FOUR places (the footgun)\n\nIdentical edits in **both** `FSH.Starter.Api/Program.cs` **and** `FSH.Starter.DbMigrator/Program.cs`:\n\n1. Mediator `o.Assemblies` — add **two** markers: a Contracts type (e.g. `typeof(FSH.Modules.{Name}.Contracts.{Name}ContractsMarker)`) **and** the module type (`typeof({Name}Module)`).\n2. `moduleAssemblies` array — add `typeof({Name}Module).Assembly`.\n\nMiss the Mediator marker → handlers silently undiscovered. Miss the assembly entry → module never loads. Miss the DbMigrator pair → migrate/seed skips the module.\n\n## Step 7 — Verify\n\n```bash\ndotnet build src/FSH.Starter.slnx                  # 0 warnings\ndotnet test src/Tests/Architecture.Tests           # boundary + tenant-isolation rules must pass\ndotnet test src/FSH.Starter.slnx\n```\n\n## Checklist\n\n- [ ] Two projects (copied csproj), added to `.slnx`, referenced from Api + DbMigrator (+ Migrations)\n- [ ] `[assembly: FshModule(typeof({Name}Module), order)]` (assembly-level, positional)\n- [ ] `IModule`: `AddHeroDbContext<T>()`, `PermissionConstants.Register`, version-set group, eventing trio if needed\n- [ ] `{Name}DbContext : BaseDbContext`, 4-arg ctor, `base.OnModelCreating` last\n- [ ] `{Name}Permissions` in Contracts/Authorization\n- [ ] Migrations folder + initial migration (`--context {Name}DbContext`)\n- [ ] **Registered in all four places** (Api + DbMigrator × Mediator + moduleAssemblies)\n- [ ] Build + Architecture.Tests green\n",".agents/skills/add-permission/SKILL.md":"---\nname: add-permission\ndescription: Add a new permission end-to-end — server constant + endpoint gate, and (admin app) mirror it into the permissions catalog + route guard. Use when a new endpoint needs authorization. See modules/identity.md + frontend/admin.md.\nargument-hint: \"[ModuleName] [Resource] [Action]\"\n---\n\n# Add Permission\n\nA permission spans server + the admin app. The dashboard app does **not** mirror permissions — it reads\nthem from the JWT and relies on the server's 403.\n\n## Step 1 — Server constant (`Modules.{X}.Contracts/Authorization/{X}Permissions.cs`)\n\nAdd the constant to the resource group and ensure it's in the module's `All` collection. Convention:\n`Permissions.{Resource}.{Action}`.\n\n```csharp\npublic static class {X}Permissions\n{\n    public static class {Resources}\n    {\n        public const string View   = \"Permissions.{Resources}.View\";\n        public const string Create = \"Permissions.{Resources}.Create\";   // ← new\n    }\n    public static IReadOnlyList<FshPermission> All { get; } = [ /* … include the new one … */ ];\n}\n```\n\nThe module already calls `PermissionConstants.Register({X}Permissions.All)` in `ConfigureServices`, so a new entry in `All` is picked up automatically.\n\n## Step 2 — Gate the endpoint\n\n```csharp\n.RequirePermission({X}Permissions.{Resources}.Create);\n```\n\n⚠️ `RequiredPermissionAttribute` implements `IRequiredPermissionMetadata`. **Never let a second/duplicate of that interface exist** — it silently disables **all** `.RequirePermission()` gates app-wide. (See `.agents/rules/modules/identity.md`.)\n\n## Step 3 — (admin only) mirror it\n\n`clients/admin/src/lib/permissions.ts` — add the matching string to the frozen tree (no runtime catalog endpoint exists; mirror by hand):\n\n```ts\nexport const {Module}Permissions = Object.freeze({\n  {Resources}: { View: \"Permissions.{Resources}.View\", Create: \"Permissions.{Resources}.Create\" },\n} as const);\n```\n\nIf it should appear in the Role editor UI, add a `PERMISSION_CATALOG` entry (`{ name, description, root?, basic? }` under the right category group).\n\n## Step 4 — (admin only) gate the route\n\n```tsx\n{ path: \"{resources}/new\",\n  element: <RouteGuard perms={[{Module}Permissions.{Resources}.Create]}><Create{Resource}Page /></RouteGuard> },\n```\n\n## Step 5 — (admin only) seed it in tests\n\nSo `RouteGuard` passes on first paint, add the new permission to the test seed set (`ADMIN_PERMS` in `clients/admin/tests/helpers/shell-mocks.ts`, used by `seedAuthedSession`).\n\n## Dashboard\n\nNo mirror, no `RouteGuard`. The JWT carries only role names — the app fetches the permission list from `GET /api/v1/identity/permissions` at hydration and the server enforces access; a missing permission yields a 403 the UI surfaces. Routes aren't permission-gated; to hide a nav entry, set `perm`/`anyPerm` on the item in `src/components/layout/nav-data.ts`. Permission-gated specs mock `GET /identity/permissions` with the grants they need (shell mocks stub it to `[]`).\n\n## Checklist\n\n- [ ] Server constant added to `{X}Permissions` **and** its `All` collection\n- [ ] Endpoint gated with `.RequirePermission(...)`; no duplicate `IRequiredPermissionMetadata`\n- [ ] (admin) mirrored in `lib/permissions.ts` (+ `PERMISSION_CATALOG` if role-editor-visible)\n- [ ] (admin) route wrapped in `<RouteGuard perms={[…]}>`; permission added to `ADMIN_PERMS` test seed\n- [ ] Build green; admin `test:e2e` green\n",".agents/skills/add-react-page/SKILL.md":"---\nname: add-react-page\ndescription: Add a list+create page to a React app (clients/admin or clients/dashboard) — API module, page, lazy route, (admin) permission gate, Playwright test. Use when adding any frontend screen. See .agents/rules/frontend/.\nargument-hint: \"[admin|dashboard] [Area] [Resource]\"\n---\n\n# Add React Page\n\nThe frontend slice. Read `.agents/rules/frontend/shared.md` plus the app file (`frontend/admin.md` /\n`frontend/dashboard.md`) — the two apps **deliberately diverge**:\n\n| | **admin** (operator) | **dashboard** (tenant) |\n|---|---|---|\n| Query params | PascalCase (`PageNumber`, `Search`) | camelCase (`pageNumber`, `search`) |\n| `PagedResponse<T>` | import from `@/lib/api-types` | re-declare inline in the api module |\n| Path constant | `const BASE = \"/api/v1/...\"` | inline the full path per call |\n| Forms | **react-hook-form + zod** | **hand-rolled** controlled inputs (no RHF/zod) |\n| List + create | separate routed pages (`list.tsx`, `create.tsx`) | one file with `<Dialog>` editors |\n| Route wrapper | `<RouteGuard perms={[…]}>` | `withSuspense(<X/>)` (no permission gate) |\n| Permissions | mirror in `src/lib/permissions.ts` | fetched from `GET /identity/permissions` (not JWT); nav gating via `perm`/`anyPerm` in `nav-data.ts`; no route guard — server 403 backstops |\n\nShared everywhere: types are **hand-written** (no codegen); `apiFetch<T>` from `@/lib/api-client`; `cn()` from `@/lib/cn`; `env.apiBase` from runtime `/config.json`; CVA `components/ui` + `components/list` primitives; Tailwind v4 CSS-first (tokens in `src/styles/globals.css`); `toast` from `sonner`; pages are **named exports**; `placeholderData: keepPreviousData` (v5).\n\n## Step 1 — API module (`src/api/{resource}.ts`)\n\nHand-write the DTO/param/input types and thin `apiFetch` functions.\n\n```ts\n// admin\nimport { apiFetch } from \"@/lib/api-client\";\nimport type { PagedResponse } from \"@/lib/api-types\";\nconst BASE = \"/api/v1/{module}/{resources}\";\n\nexport type {Resource}Dto = { id: string; name: string; /* … */ };\n\nexport async function search{Resources}(p: { pageNumber?: number; search?: string } = {}) {\n  const q = new URLSearchParams();\n  q.set(\"PageNumber\", String(p.pageNumber ?? 1));\n  q.set(\"PageSize\", \"10\");\n  if (p.search?.trim()) q.set(\"Search\", p.search.trim());\n  return apiFetch<PagedResponse<{Resource}Dto>>(`${BASE}/search?${q}`);\n}\nexport async function create{Resource}(input: Create{Resource}Input) {\n  return apiFetch<{ id: string }>(BASE, { method: \"POST\", body: JSON.stringify(input) });\n}\n```\n\n(dashboard: inline `type PagedResponse<T> = …`, inline the path, camelCase params, mutations often return `Promise<string>`.)\n\n## Step 2 — Page (`src/pages/{area}/...tsx`, named export)\n\n```tsx\nexport function {Resource}ListPage() {\n  const [search, setSearch] = useState(\"\");           // debounce → reset page to 1 on change\n  const [pageNumber, setPage] = useState(1);\n  const query = useQuery({\n    queryKey: [\"{resources}\", { pageNumber, search }],   // hierarchical; params object last\n    queryFn: () => search{Resources}({ pageNumber, search: search || undefined }),\n    placeholderData: keepPreviousData,\n  });\n  // render with components/ui/* + components/list/* (admin: PageHeader/Field…; dashboard: Entity* family)\n}\n```\n\n## Step 3 — Mutation (race-safe `mutate(arg)`)\n\nPass per-call data through `mutate(arg)`; read it from the callback `variables` — never from a closed-over render variable.\n\n```tsx\nconst qc = useQueryClient();\nconst createMut = useMutation({\n  mutationFn: (input: Create{Resource}Input) => create{Resource}(input),\n  onSuccess: () => { toast.success(\"Created\"); qc.invalidateQueries({ queryKey: [\"{resources}\"] }); },\n  onError: (e) => toast.error(e instanceof ApiRequestError ? e.message : \"Failed\"),\n});\n// admin: const form = useForm({ resolver: zodResolver(schema) });  form.handleSubmit(v => createMut.mutate(v))\n// dashboard: controlled useState fields; onSubmit(e){ e.preventDefault(); createMut.mutate(payload); }\n```\n\nIf you need to track the in-flight item (e.g. a per-row busy state), use `onMutate: (arg) => setBusyId(arg)` reading the `mutate(arg)` value (pattern: `admin/src/pages/settings/sessions.tsx`).\n\n## Step 4 — Register the route (`routes.tsx`)\n\n```tsx\nconst {Resource}ListPage = lazyNamed(() => import(\"@/pages/{area}/list\"), \"{Resource}ListPage\");\n// admin — under AppShell.children, gated:\n{ path: \"{resources}\", element: <RouteGuard perms={[{Module}Permissions.{Resources}.View]}><{Resource}ListPage /></RouteGuard> },\n// dashboard — under AppShell.children, suspense only:\n{ path: \"{area}/{resources}\", element: withSuspense(<{Resource}ListPage />) },\n```\n\n## Step 5 — (admin only) mirror the permission\n\nAdd the constant to `src/lib/permissions.ts` (`{Module}Permissions.{Resources}.View` = `\"Permissions.{Resources}.View\"`), and a `PERMISSION_CATALOG` entry if it belongs in the Role editor. See `add-permission`.\n\n## Step 6 — Playwright test (`tests/{area}/{resource}.spec.ts`)\n\n```ts\ntest.beforeEach(async ({ page }) => {\n  // admin: seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); await installAdminShellMocks(page);\n  // dashboard: await seedAuthedSession(page, TEST_USER); await installShellMocks(page);\n  await mockJsonResponse(page, \"**/api/v1/{module}/{resources}**\", paged([SAMPLE]));   // page mocks AFTER shell mocks\n});\n```\n\nUse `mockProblemDetails(...)` for error states. Dashboard: scope row assertions with `.last()` / dialog scoping (lists render mobile + desktop copies → strict-mode double match).\n\n## Step 7 — Verify\n\n```bash\ncd clients/{app} && npm run lint && npm run test:e2e\n```\n\n## Checklist\n\n- [ ] API module: hand-written types, `apiFetch`, correct param casing per app (Pascal=admin, camel=dashboard)\n- [ ] Page is a **named export**; `useQuery` key hierarchical + `placeholderData: keepPreviousData`\n- [ ] Mutation passes data via `mutate(arg)`, invalidates in `onSuccess`\n- [ ] Route via `lazyNamed`; admin wraps in `<RouteGuard perms>`, dashboard in `withSuspense`\n- [ ] (admin) permission mirrored in `lib/permissions.ts`\n- [ ] Playwright test: seed + shell mocks + page mocks; `lint` + `test:e2e` green\n",".agents/skills/create-migration/SKILL.md":"---\nname: create-migration\ndescription: Create and apply an EF Core migration for a module's DbContext the FSH way (central Migrations project, per-module folder, correct --context). Use after changing entities/EF config. See .agents/rules/database.md.\nargument-hint: \"[ModuleName] [MigrationName]\"\n---\n\n# Create Migration\n\nAll migrations live in **one** project — `src/Host/FSH.Starter.Migrations.PostgreSQL` — but are foldered\n**per module/context** (`Catalog/`, `Identity/`, …), each with its own `{X}DbContextModelSnapshot`. The DB\nis **not** migrated at API startup; the `DbMigrator` host applies it.\n\n## Step 0 — restore the pinned tool (first time)\n\n```bash\ndotnet tool restore     # dotnet-ef is pinned in .config/dotnet-tools.json\n```\n\n## Step 1 — BUILD FIRST (snapshot footgun)\n\n`dotnet ef migrations add` reads the **current snapshot**, which is regenerated from a build. If you skip\nthe build after editing entities/config, you can generate against a stale snapshot and lose changes. Also,\n`migrations remove` rewrites the snapshot — only remove the latest, and rebuild after.\n\n```bash\ndotnet build src/FSH.Starter.slnx\n```\n\n## Step 2 — add the migration\n\nSpecify **all three** of `--project` (the Migrations project), `--startup-project` (the API host), and\n`--context {X}DbContext`. Use `--output-dir {X}` so it lands in that module's folder (match the existing\nfolder for the context).\n\n```bash\ndotnet ef migrations add {MigrationName} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {X}DbContext \\\n  --output-dir {X}\n```\n\n## Step 3 — review the generated SQL before applying\n\n```bash\ndotnet ef migrations script --idempotent \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {X}DbContext\n```\n\nCheck for: unintended table/column drops, non-nullable columns added without a default to an existing\ntable, and renames surfacing as drop+add (data loss). Adjust the model or hand-edit the migration if needed.\n\n## Step 4 — apply\n\nPreferred (the canonical path — migrates the tenant catalog then each tenant's per-module schema):\n\n```bash\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- apply\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending   # to preview first\n```\n\n(Or, single-context local dev, `dotnet ef database update --context {X}DbContext --project … --startup-project …`.)\n\n## Notes\n\n- A **new module** also needs a `{X}/` folder in the Migrations project and the runtime project referenced from it — see `add-module`.\n- `dotnet ef` against a `BaseDbContext` works because the 4-arg ctor is satisfied by the startup host's DI.\n\n## Checklist\n\n- [ ] `dotnet tool restore` done (first time)\n- [ ] Built **before** `migrations add`\n- [ ] `--context {X}DbContext` + `--output-dir {X}` (lands in the right folder)\n- [ ] Reviewed the generated SQL for data loss\n- [ ] Applied via DbMigrator `apply` (or `ef database update` for one context locally)\n",".agents/skills/mediator-reference/SKILL.md":"---\nname: mediator-reference\ndescription: CQRS interface reference for FSH. This project uses the Mediator source generator, NOT MediatR. Reference when implementing commands, queries, and handlers.\nuser-invocable: false\n---\n\n# Mediator Reference\n\n⚠️ **FSH uses the `Mediator` source-generator package (`using Mediator;`), NOT `MediatR`.** Different\ninterfaces — MediatR types won't compile. The CQRS interfaces below are the library's own (no FSH wrapper).\n\n## Interfaces\n\n| Purpose | ✅ Mediator (use) | ❌ MediatR (don't) |\n|---|---|---|\n| Command | `ICommand<TResponse>` | `IRequest<TResponse>` |\n| Query | `IQuery<TResponse>` | `IRequest<TResponse>` |\n| Command handler | `ICommandHandler<TCommand, TResponse>` | `IRequestHandler<…>` |\n| Query handler | `IQueryHandler<TQuery, TResponse>` | `IRequestHandler<…>` |\n| Notification / domain event | `INotification` (`IDomainEvent : INotification`) | `INotification` |\n\n## Pattern\n\n```csharp\n// Command/Query → the Contracts project (Modules.{X}.Contracts/v1/{Area}/)\npublic sealed record Create{Entity}Command(string Name) : ICommand<Guid>;\n\n// Handler → the runtime project (Modules.{X}/Features/v1/{Area}/{Feature}/), public sealed\npublic sealed class Create{Entity}CommandHandler({X}DbContext db)\n    : ICommandHandler<Create{Entity}Command, Guid>\n{\n    public async ValueTask<Guid> Handle(Create{Entity}Command command, CancellationToken cancellationToken)\n    {\n        // …\n    }\n}\n```\n\nRules: handlers return **`ValueTask<T>`** (not `Task<T>`); parameter named `command`/`query` (not `request`);\n`public sealed`; `.ConfigureAwait(false)` on awaits. Send via `mediator.Send(command, ct)` (the `IMediator`\ninterface name matches MediatR's — that part is fine).\n\n## Registration — the four places\n\nThe source generator only scans assemblies listed in `o.Assemblies`, and that list exists in **two host\nfiles**. A new module needs **two markers** (a Contracts type **and** the module type) added to the Mediator\nlist **plus** an entry in the `moduleAssemblies` array — in **both** `FSH.Starter.Api/Program.cs` **and**\n`FSH.Starter.DbMigrator/Program.cs`:\n\n```csharp\nbuilder.Services.AddMediator(o =>\n{\n    o.ServiceLifetime = ServiceLifetime.Scoped;\n    o.Assemblies = [\n        /* … */\n        typeof(FSH.Modules.{X}.Contracts.{X}ContractsMarker),   // Contracts assembly\n        typeof(FSH.Modules.{X}.{X}Module)];                     // runtime assembly\n});\n```\n\nSee `add-module` for the full procedure.\n\n## Common errors\n\n| Symptom | Cause → fix |\n|---|---|\n| `IRequest<T>` / `IRequestHandler<,>` not found | MediatR interface → use `ICommand`/`IQuery` + `ICommandHandler`/`IQueryHandler` |\n| `Task<T>` vs `ValueTask<T>` mismatch | Handler must return `ValueTask<T>` |\n| Handler not invoked at runtime | Assembly missing from `o.Assemblies` (in one or both Program.cs files) |\n",".agents/skills/query-patterns/SKILL.md":"---\nname: query-patterns\ndescription: Implement read queries — paginated lists, search/filter/sort, and single-entity fetches — the FSH way (DbContext LINQ + PagedResponse). Use when adding GET endpoints. See also add-feature.\n---\n\n# Query Patterns\n\nThe dominant pattern is **raw `IQueryable` on the module DbContext** (`AsNoTracking`) with manual\npagination. There is **no generic repository** and **no `PaginatedListAsync`/`EntitiesByPaginationFilterSpec`/\n`PaginationFilter`**. Paged results are `PagedResponse<T>` (`FSH.Framework.Shared.Persistence`) — there is no `PagedList<T>`.\n\n## Paginated search query\n\n```csharp\n// Contracts/v1/{Area}/\npublic sealed record Search{Entities}Query(\n    string? Search = null,\n    bool? IsActive = null,\n    int PageNumber = 1,\n    int PageSize = 20,\n    string? SortBy = null,\n    string? SortDir = null) : IQuery<PagedResponse<{Entity}Dto>>;\n```\n\n```csharp\n// Features/v1/{Area}/Search{Entities}/\npublic sealed class Search{Entities}QueryHandler({X}DbContext dbContext)\n    : IQueryHandler<Search{Entities}Query, PagedResponse<{Entity}Dto>>\n{\n    public async ValueTask<PagedResponse<{Entity}Dto>> Handle(\n        Search{Entities}Query query, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(query);\n        int page = query.PageNumber < 1 ? 1 : query.PageNumber;\n        int size = Math.Clamp(query.PageSize, 1, 100);\n\n        var q = dbContext.{Entities}.AsNoTracking().AsQueryable();\n        if (!string.IsNullOrWhiteSpace(query.Search))\n            q = q.Where(x => EF.Functions.ILike(x.Name, $\"%{query.Search}%\"));\n        if (query.IsActive is { } active)\n            q = q.Where(x => x.IsActive == active);\n\n        q = ApplySort(q, query.SortBy, query.SortDir);\n\n        long total = await q.LongCountAsync(cancellationToken).ConfigureAwait(false);\n        var items = await q.Skip(PaginationExtensions.GetOffset(page, size)).Take(size) // overflow-safe offset\n            .ToListAsync(cancellationToken).ConfigureAwait(false);\n\n        return new PagedResponse<{Entity}Dto>\n        {\n            Items = items.Select(x => x.ToDto()).ToList(),\n            PageNumber = page, PageSize = size,\n            TotalCount = total,\n            TotalPages = (int)Math.Ceiling(total / (double)size)\n        };\n    }\n\n    private static IQueryable<{Entity}> ApplySort(IQueryable<{Entity}> q, string? by, string? dir)\n    {\n        bool desc = string.Equals(dir, \"desc\", StringComparison.OrdinalIgnoreCase);\n        return by?.ToLowerInvariant() switch\n        {\n            \"name\" => desc ? q.OrderByDescending(x => x.Name) : q.OrderBy(x => x.Name),\n            _      => q.OrderByDescending(x => x.CreatedOnUtc)\n        };\n    }\n}\n```\n\nTenant + soft-delete filters apply automatically — don't re-filter them. Project to a DTO (`.ToDto()` mapper); never return entities.\n\n## Single-entity query\n\n```csharp\npublic sealed record Get{Entity}Query(Guid Id) : IQuery<{Entity}Dto>;\n\npublic sealed class Get{Entity}QueryHandler({X}DbContext dbContext)\n    : IQueryHandler<Get{Entity}Query, {Entity}Dto>\n{\n    public async ValueTask<{Entity}Dto> Handle(Get{Entity}Query query, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(query);\n        var entity = await dbContext.{Entities}.AsNoTracking()\n            .FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken).ConfigureAwait(false)\n            ?? throw new NotFoundException($\"{Entity} {query.Id} not found\");\n        return entity.ToDto();\n    }\n}\n```\n\n## Endpoints\n\n```csharp\n// list — bind the query with [AsParameters]\nendpoints.MapGet(\"/{entities}\", async ([AsParameters] Search{Entities}Query query,\n        IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(query, ct)))\n    .WithName(\"Search{Entities}\").RequirePermission({X}Permissions.{Entities}.View);\n\n// single\nendpoints.MapGet(\"/{entities}/{id:guid}\", async (Guid id,\n        IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(new Get{Entity}Query(id), ct)))\n    .WithName(\"Get{Entity}\").RequirePermission({X}Permissions.{Entities}.View);\n```\n\nA paginated query **needs a validator** (`Search{Entities}QueryValidator`: `PageNumber >= 1`, `PageSize` in `[1,100]`) — enforced by `Architecture.Tests`.\n\n## When to use a Specification instead\n\n`Specification<T>` (`src/BuildingBlocks/Persistence/Specifications/`) is for **composing reusable query\nshapes** (`protected Where(...)`/`Include(...)`/`OrderBy(...)` in a derived spec's ctor; `AsNoTracking`\ndefaults true; specs never paginate). Reach for it when the same filter/include set is shared across\nhandlers; otherwise inline LINQ is the norm here.\n",".agents/skills/testing-guide/SKILL.md":"---\nname: testing-guide\ndescription: Write tests for an FSH feature — xUnit + Shouldly + NSubstitute + AutoFixture, with naming and AAA conventions. Use when adding unit/handler/validator/entity tests. Full rules in .agents/rules/testing.md + integration-testing.md.\n---\n\n# Testing Guide\n\nStack: **xUnit** + **Shouldly** (`.ShouldBe`) + **NSubstitute** (`Substitute.For<>`) + **AutoFixture**\n(`new Fixture()`). **Not** Moq, **not** FluentAssertions. Detailed conventions + integration-test gotchas\nlive in `.agents/rules/testing.md` and `.agents/rules/integration-testing.md`.\n\n## Conventions\n\n- Test class: `public sealed class {Sut}Tests`; SUT field named `_sut`.\n- Method name: **`MethodName_Should_ExpectedBehavior[_When_Condition]`**.\n- Arrange-Act-Assert with `// Arrange` / `// Act` / `// Assert`; group with `#region` (Happy Path / Guards / Edge Cases).\n- Mocks via `Substitute.For<IService>()`; assert calls with `.Received(1).X(arg, Arg.Any<CancellationToken>())`.\n- When asserting a forwarded `CancellationToken`, assert the **specific** token, not the default (NSubstitute fills optional params with `default`).\n\n## Handler test\n\n```csharp\npublic sealed class Create{Entity}CommandHandlerTests\n{\n    private readonly {X}DbContext _db;            // or Substitute.For<IService>() for service deps\n    private readonly Create{Entity}CommandHandler _sut;\n    private readonly IFixture _fixture = new Fixture();\n\n    public Create{Entity}CommandHandlerTests()\n    {\n        _db = /* in-memory or test DbContext */;\n        _sut = new Create{Entity}CommandHandler(_db);\n    }\n\n    [Fact]\n    public async Task Handle_Should_PersistEntity_And_ReturnId()\n    {\n        // Arrange\n        var command = new Create{Entity}Command(_fixture.Create<string>(), 9.99m, \"USD\");\n\n        // Act\n        var id = await _sut.Handle(command, CancellationToken.None);\n\n        // Assert\n        id.ShouldNotBe(Guid.Empty);\n    }\n}\n```\n\nService-dependency example (NSubstitute):\n\n```csharp\n_userService = Substitute.For<IUserService>();\n// Act … then:\nawait _userService.Received(1).ToggleStatusAsync(true, command.UserId, Arg.Any<CancellationToken>());\n```\n\n## Validator test\n\n```csharp\npublic sealed class Create{Entity}CommandValidatorTests\n{\n    private readonly Create{Entity}CommandValidator _sut = new();\n\n    [Theory]\n    [InlineData(\"\")]\n    public void Validate_Should_Fail_When_NameInvalid(string name)\n    {\n        var result = _sut.Validate(new Create{Entity}Command(name, 1m, \"USD\"));\n        result.IsValid.ShouldBeFalse();\n        result.Errors.ShouldContain(e => e.PropertyName == nameof(Create{Entity}Command.Name));\n    }\n}\n```\n\n## Entity / domain test (no mocks)\n\n```csharp\n[Fact]\npublic void Create_Should_RaiseCreatedEvent()\n{\n    var entity = {Entity}.Create(\"Test\", Money.Zero());\n    entity.Id.ShouldNotBe(Guid.Empty);\n    entity.DomainEvents.ShouldContain(e => e is {Entity}CreatedDomainEvent);\n}\n```\n\n## Architecture tests (guardrails — keep green)\n\n`Architecture.Tests` (NetArchTest) enforce: module boundaries (cross-module refs only via `.Contracts`),\ntenant-isolation rules, handlers `sealed`, and **every command/paginated-query handler has a validator**.\nDon't weaken these to make a change pass — fix the code.\n\n## Integration tests\n\n`Integration.Tests` runs over real Postgres/Redis/RustFS via Testcontainers — **Docker required**. Set the\nFinbuckle tenant context inline, rewire `IStorageService` post-registration for RustFS (S3), force long-polling\nfor SignalR. All detailed in `.agents/rules/integration-testing.md`.\n\n## Run\n\n```bash\ndotnet test src/Tests/{X}.Tests\ndotnet test src/Tests/Architecture.Tests\ndotnet test src/FSH.Starter.slnx --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\n","AGENTS.md":"# FullStackHero .NET Starter Kit\n\n> A production-ready modular .NET 10 monolith + two React 19 apps, built for enterprise SaaS.\n\nThis file is the canonical guide for **all** AI coding tools (Claude Code, Gemini CLI, Cursor, Codex, …).\n`CLAUDE.md` and `GEMINI.md` are thin bridges that import this file — edit conventions **here**, not there.\n\nThis file is the map. Detailed conventions live in `.agents/rules/` and are read on demand — **read the\nrelevant rule file before working in that area** (see the index below). Keep this file lean.\n\n## What this is\n\nA **modular monolith** (Vertical Slice Architecture) backend that ships with two **React + Vite**\nfront-ends and a CLI. Multitenancy, auth, auditing, billing, files, chat and more are first-class.\n\n- **Backend** — .NET 10, EF Core 10, PostgreSQL, Redis, JWT + ASP.NET Identity, Finbuckle multitenancy,\n  Hangfire, OpenAPI/Scalar, Serilog + OpenTelemetry, .NET Aspire.\n- **Frontends** — `clients/admin` (operator-facing) and `clients/dashboard` (tenant-facing): React 19,\n  Vite 7, TypeScript, TanStack Query v5, React Router 7, Radix + Tailwind v4 (shadcn-style), SignalR/SSE.\n\n## Repo map\n\n| Path | What |\n|------|------|\n| `src/BuildingBlocks/` | Shared framework libraries (Core, Persistence, Web, Caching, Eventing, Storage, Quota…). **Protected — see below.** |\n| `src/Modules/{Name}/` | Bounded contexts. Each has a runtime project + a `.Contracts` project (its only public API). |\n| `src/Host/FSH.Starter.Api` | Composition-root Web API host. |\n| `src/Host/FSH.Starter.AppHost` | .NET Aspire orchestrator (Postgres, Redis, RustFS, migrator, API, **both React apps**). |\n| `src/Host/FSH.Starter.DbMigrator` | One-shot migrate/seed runner. DB is **not** migrated at API startup. |\n| `src/Host/FSH.Starter.Migrations.PostgreSQL` | All EF migrations, organized per-module by folder. |\n| `src/Tests/` | Per-module tests, `Architecture.Tests` (NetArchTest), `Integration.Tests` (Testcontainers). |\n| `clients/admin`, `clients/dashboard` | The two React apps. |\n| `deploy/` | Infra (docker, terraform, dokploy). |\n\n<!--#if (includeTools) -->\nThe `fsh` CLI (Spectre.Console) lives in `src/Tools/CLI`.\n\n<!--#endif -->\n## Tech stack\n\n| Backend | | Frontend | |\n|---|---|---|---|\n| Framework | .NET 10 / C# latest | Framework | React 19 + Vite 7 + TS 5.x |\n| CQRS | Mediator 3.x (source-gen) | Data | TanStack Query v5 |\n| Validation | FluentValidation 12.x | Routing | React Router 7 |\n| ORM / DB | EF Core 10 / PostgreSQL (Npgsql) | UI | Radix + Tailwind v4 + CVA (shadcn) |\n| Auth | JWT Bearer + ASP.NET Identity | Forms | react-hook-form + zod (**admin only**) |\n| Multitenancy | Finbuckle 10.x | Realtime | `@microsoft/signalr`, SSE (dashboard) |\n| Cache / Jobs | Redis, Hangfire | Tests | Playwright (route-mocked) |\n| Docs | OpenAPI + Scalar | API client | hand-written `apiFetch` (no codegen) |\n| Hosting | .NET Aspire | Env | runtime `/config.json` (not `VITE_*`) |\n| Testing | xUnit, Shouldly, NSubstitute, AutoFixture, NetArchTest, Testcontainers | | |\n\n## Build & run\n\n```bash\n# Whole stack (Postgres + pgAdmin + Redis + RustFS + migrator + API + both React apps)\ndotnet run --project src/Host/FSH.Starter.AppHost   # one-time: npm install in clients/admin & clients/dashboard\n\ndotnet build src/FSH.Starter.slnx                   # build backend\ndotnet run --project src/Host/FSH.Starter.Api       # API only → https://localhost:7030 (/scalar)\ndotnet test src/FSH.Starter.slnx                    # tests — integration tests REQUIRE Docker\n\ncd clients/admin && npm install && npm run dev       # → http://localhost:5173\ncd clients/dashboard && npm install && npm run dev   # → http://localhost:5174\n```\n\nMigrations / seed (DbMigrator, separate step):\n```bash\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- apply [--seed]\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending\n```\n\n**Ports:** API 7030 (https)/5030 (http) · admin 5173 · dashboard 5174 · Postgres 5432 · pgAdmin 5050 · Valkey 6379 · RustFS 9000/9001 (S3 API/console).\n\n<!--#if (includeTools) -->\n## Branching & PRs\n\nSingle long-lived branch: **`main`** (the default) — there is **no `develop`**. Branch from and target `main`; stable releases are cut from `v*` tags. CI is split into path-scoped **Backend CI** (`src/**`) and **Frontend CI** (`clients/**`) workflows; branch protection requires only those two gate checks — never the individual jobs, which are skipped on the other side's PRs.\n\n<!--#endif -->\n## Golden rules (do not break)\n\n1. **Module boundaries** — a module references another module only through its `.Contracts` project, never its runtime project. Enforced by `Architecture.Tests`.\n2. **Registering a module touches FOUR places** — `Program.cs` Mediator `o.Assemblies` (two markers each) + `moduleAssemblies` array, **and the identical pair in `DbMigrator/Program.cs`**. A missing Mediator marker = handlers silently undiscovered. See `architecture.md`.\n3. **Tenant isolation is default-ON** via `BaseDbContext`. Opt out only via `IGlobalEntity`. Subclass DbContexts call `base.OnModelCreating` **last**. See `database.md`.\n4. **Do NOT modify `src/BuildingBlocks`** without explicit approval — shared by every module, wide blast radius.\n5. **Mediator handlers must be `public sealed`**, return `ValueTask<T>`, and `.ConfigureAwait(false)` every await.\n6. **Structured logging only** — no string interpolation in log messages; use message templates / `[LoggerMessage]`.\n7. **Propagate `CancellationToken`** into every EF/IO call; add as `= default` on public service methods.\n8. **Every command handler + paginated query handler needs a validator** (`{Name}Validator`). Enforced by `Architecture.Tests`.\n9. **Frontend: pass per-call data through `mutate(arg)`**, never via state the mutation callbacks close over (execute-time race). See `frontend/shared.md`.\n<!--#if (includeTools) -->\n10. **Docs + changelog travel with the change** — a user-facing change (feature, endpoint, config, infra, breaking change) isn't done until the **separate docs repo** (`github.com/fullstackhero/docs`, the Astro site) is updated to match **and** a changelog entry is added (`src/content/docs/changelog/`). Don't let the docs drift from the code.\n<!--#endif -->\n\n## Rules index — read the relevant file before you work\n\n**Backend / cross-cutting** (`.agents/rules/`)\n\n| Working on… | Read |\n|---|---|\n| Module structure, boundaries, registration, DI, middleware order, config | `architecture.md` |\n| Endpoints, CQRS, validation, exceptions, permissions, versioning | `api-conventions.md` |\n| EF Core, entities, migrations, tenant isolation, query filters | `database.md` |\n| Cross-module events, Outbox/Inbox, idempotent handlers | `eventing.md` |\n| Caching (HybridCache/Redis), keys, invalidation | `caching.md` |\n| Background jobs (Hangfire), recurring jobs | `jobs.md` |\n| Outbound HTTP resilience (Polly) | `resilience.md` |\n| Files/blobs, presigned uploads, providers | `storage.md` |\n| CORS, security headers, rate limiting, idempotency, quotas, front-end link origins | `security.md` |\n| SignalR / SSE backend | `realtime.md` |\n| Logging, correlation, OpenTelemetry | `logging.md` |\n| Localization (i18n), request culture, resource catalogs, localized exceptions | `localization.md` |\n| Unit test conventions, NetArchTest | `testing.md` |\n| Integration tests (Testcontainers harness + gotchas) | `integration-testing.md` |\n| **Modifying `src/BuildingBlocks`** (read first — it's protected) | `buildingblocks-protection.md` |\n| A specific module's quirks | `modules/{module}.md` (identity, multitenancy, chat, files, webhooks, auditing, billing, catalog, tickets, notifications) |\n\n**Frontend** (`.agents/rules/frontend/`)\n\n| Working on… | Read |\n|---|---|\n| Any React work (shared stack, API client, Query, Tailwind, design language) | `frontend/shared.md` |\n| The operator app (`clients/admin`) | `frontend/admin.md` |\n| The tenant app (`clients/dashboard`) | `frontend/dashboard.md` |\n\n## Coding style (backend)\n\nFile-scoped namespaces · 4-space indent · explicit types (`var` only when RHS-obvious) · `is null` /\n`is not null` · pattern matching + switch expressions · `ArgumentNullException.ThrowIfNull` guards ·\nrecords for DTOs/events/value objects · `default!` for required non-nullable strings. Build runs with\n`TreatWarningsAsErrors` — warnings fail the build.\n\n## Adding things (quick pointers)\n\n- **Feature** — Contracts command/query → handler → validator → endpoint → wire in module `MapEndpoints()` → tests. Details: `api-conventions.md`.\n- **Module** — new `Modules.{Name}` + `.Contracts`, implement `IModule` w/ assembly-level `[assembly: FshModule(typeof(XModule), order)]`, register in **all four places**, add migration folder + tests. Details: `architecture.md`.\n- **React page** — API module (`src/api/`) → page → register lazy route → (admin) mirror permission + RouteGuard → Playwright test. Details: `frontend/shared.md`.\n\n## AI tooling resources\n\n- **Rules** — `.agents/rules/*.md` (indexed above). Read on demand.\n- **Skills** — `.agents/skills/*/SKILL.md`: step-by-step task recipes. Scaffolders: `add-feature`, `add-entity`, `add-module`, `add-react-page`, `add-full-slice`. Ops: `create-migration`, `add-integration-event`, `add-permission`. Reference: `query-patterns`, `testing-guide`, `mediator-reference`.\n- **Workflows** — `.agents/workflows/*.md`: task playbooks (`code-reviewer`, `feature-scaffolder`, `module-creator`, `architecture-guard`, `migration-helper`).\n","CLAUDE.md":"# Claude Code\n\nThe canonical project guide is **`AGENTS.md`** (tool-neutral). It is imported below; edit conventions there, not here.\n\n@AGENTS.md\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# FullStackHero .NET Starter Kit\n\n> A production-ready modular .NET 10 monolith + two React 19 apps, built for enterprise SaaS.\n\nThis file is the canonical guide for **all** AI coding tools (Claude Code, Gemini CLI, Cursor, Codex, …).\n`CLAUDE.md` and `GEMINI.md` are thin bridges that import this file — edit conventions **here**, not there.\n\nThis file is the map. Detailed conventions live in `.agents/rules/` and are read on demand — **read the\nrelevant rule file before working in that area** (see the index below). Keep this file lean.\n\n## What this is\n\nA **modular monolith** (Vertical Slice Architecture) backend that ships with two **React + Vite**\nfront-ends and a CLI. Multitenancy, auth, auditing, billing, files, chat and more are first-class.\n\n- **Backend** — .NET 10, EF Core 10, PostgreSQL, Redis, JWT + ASP.NET Identity, Finbuckle multitenancy,\n  Hangfire, OpenAPI/Scalar, Serilog + OpenTelemetry, .NET Aspire.\n- **Frontends** — `clients/admin` (operator-facing) and `clients/dashboard` (tenant-facing): React 19,\n  Vite 7, TypeScript, TanStack Query v5, React Router 7, Radix + Tailwind v4 (shadcn-style), SignalR/SSE.\n\n## Repo map\n\n| Path | What |\n|------|------|\n| `src/BuildingBlocks/` | Shared framework libraries (Core, Persistence, Web, Caching, Eventing, Storage, Quota…). **Protected — see below.** |\n| `src/Modules/{Name}/` | Bounded contexts. Each has a runtime project + a `.Contracts` project (its only public API). |\n| `src/Host/FSH.Starter.Api` | Composition-root Web API host. |\n| `src/Host/FSH.Starter.AppHost` | .NET Aspire orchestrator (Postgres, Redis, RustFS, migrator, API, **both React apps**). |\n| `src/Host/FSH.Starter.DbMigrator` | One-shot migrate/seed runner. DB is **not** migrated at API startup. |\n| `src/Host/FSH.Starter.Migrations.PostgreSQL` | All EF migrations, organized per-module by folder. |\n| `src/Tests/` | Per-module tests, `Architecture.Tests` (NetArchTest), `Integration.Tests` (Testcontainers). |\n| `clients/admin`, `clients/dashboard` | The two React apps. |\n| `deploy/` | Infra (docker, terraform, dokploy). |\n\n<!--#if (includeTools) -->\nThe `fsh` CLI (Spectre.Console) lives in `src/Tools/CLI`.\n\n<!--#endif -->\n## Tech stack\n\n| Backend | | Frontend | |\n|---|---|---|---|\n| Framework | .NET 10 / C# latest | Framework | React 19 + Vite 7 + TS 5.x |\n| CQRS | Mediator 3.x (source-gen) | Data | TanStack Query v5 |\n| Validation | FluentValidation 12.x | Routing | React Router 7 |\n| ORM / DB | EF Core 10 / PostgreSQL (Npgsql) | UI | Radix + Tailwind v4 + CVA (shadcn) |\n| Auth | JWT Bearer + ASP.NET Identity | Forms | react-hook-form + zod (**admin only**) |\n| Multitenancy | Finbuckle 10.x | Realtime | `@microsoft/signalr`, SSE (dashboard) |\n| Cache / Jobs | Redis, Hangfire | Tests | Playwright (route-mocked) |\n| Docs | OpenAPI + Scalar | API client | hand-written `apiFetch` (no codegen) |\n| Hosting | .NET Aspire | Env | runtime `/config.json` (not `VITE_*`) |\n| Testing | xUnit, Shouldly, NSubstitute, AutoFixture, NetArchTest, Testcontainers | | |\n\n## Build & run\n\n```bash\n# Whole stack (Postgres + pgAdmin + Redis + RustFS + migrator + API + both React apps)\ndotnet run --project src/Host/FSH.Starter.AppHost   # one-time: npm install in clients/admin & clients/dashboard\n\ndotnet build src/FSH.Starter.slnx                   # build backend\ndotnet run --project src/Host/FSH.Starter.Api       # API only → https://localhost:7030 (/scalar)\ndotnet test src/FSH.Starter.slnx                    # tests — integration tests REQUIRE Docker\n\ncd clients/admin && npm install && npm run dev       # → http://localhost:5173\ncd clients/dashboard && npm install && npm run dev   # → http://localhost:5174\n```\n\nMigrations / seed (DbMigrator, separate step):\n```bash\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- apply [--seed]\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending\n```\n\n**Ports:** API 7030 (https)/5030 (http) · admin 5173 · dashboard 5174 · Postgres 5432 · pgAdmin 5050 · Valkey 6379 · RustFS 9000/9001 (S3 API/console).\n\n<!--#if (includeTools) -->\n## Branching & PRs\n\nSingle long-lived branch: **`main`** (the default) — there is **no `develop`**. Branch from and target `main`; stable releases are cut from `v*` tags. CI is split into path-scoped **Backend CI** (`src/**`) and **Frontend CI** (`clients/**`) workflows; branch protection requires only those two gate checks — never the individual jobs, which are skipped on the other side's PRs.\n\n<!--#endif -->\n## Golden rules (do not break)\n\n1. **Module boundaries** — a module references another module only through its `.Contracts` project, never its runtime project. Enforced by `Architecture.Tests`.\n2. **Registering a module touches FOUR places** — `Program.cs` Mediator `o.Assemblies` (two markers each) + `moduleAssemblies` array, **and the identical pair in `DbMigrator/Program.cs`**. A missing Mediator marker = handlers silently undiscovered. See `architecture.md`.\n3. **Tenant isolation is default-ON** via `BaseDbContext`. Opt out only via `IGlobalEntity`. Subclass DbContexts call `base.OnModelCreating` **last**. See `database.md`.\n4. **Do NOT modify `src/BuildingBlocks`** without explicit approval — shared by every module, wide blast radius.\n5. **Mediator handlers must be `public sealed`**, return `ValueTask<T>`, and `.ConfigureAwait(false)` every await.\n6. **Structured logging only** — no string interpolation in log messages; use message templates / `[LoggerMessage]`.\n7. **Propagate `CancellationToken`** into every EF/IO call; add as `= default` on public service methods.\n8. **Every command handler + paginated query handler needs a validator** (`{Name}Validator`). Enforced by `Architecture.Tests`.\n9. **Frontend: pass per-call data through `mutate(arg)`**, never via state the mutation callbacks close over (execute-time race). See `frontend/shared.md`.\n<!--#if (includeTools) -->\n10. **Docs + changelog travel with the change** — a user-facing change (feature, endpoint, config, infra, breaking change) isn't done until the **separate docs repo** (`github.com/fullstackhero/docs`, the Astro site) is updated to match **and** a changelog entry is added (`src/content/docs/changelog/`). Don't let the docs drift from the code.\n<!--#endif -->\n\n## Rules index — read the relevant file before you work\n\n**Backend / cross-cutting** (`.agents/rules/`)\n\n| Working on… | Read |\n|---|---|\n| Module structure, boundaries, registration, DI, middleware order, config | `architecture.md` |\n| Endpoints, CQRS, validation, exceptions, permissions, versioning | `api-conventions.md` |\n| EF Core, entities, migrations, tenant isolation, query filters | `database.md` |\n| Cross-module events, Outbox/Inbox, idempotent handlers | `eventing.md` |\n| Caching (HybridCache/Redis), keys, invalidation | `caching.md` |\n| Background jobs (Hangfire), recurring jobs | `jobs.md` |\n| Outbound HTTP resilience (Polly) | `resilience.md` |\n| Files/blobs, presigned uploads, providers | `storage.md` |\n| CORS, security headers, rate limiting, idempotency, quotas, front-end link origins | `security.md` |\n| SignalR / SSE backend | `realtime.md` |\n| Logging, correlation, OpenTelemetry | `logging.md` |\n| Localization (i18n), request culture, resource catalogs, localized exceptions | `localization.md` |\n| Unit test conventions, NetArchTest | `testing.md` |\n| Integration tests (Testcontainers harness + gotchas) | `integration-testing.md` |\n| **Modifying `src/BuildingBlocks`** (read first — it's protected) | `buildingblocks-protection.md` |\n| A specific module's quirks | `modules/{module}.md` (identity, multitenancy, chat, files, webhooks, auditing, billing, catalog, tickets, notifications) |\n\n**Frontend** (`.agents/rules/frontend/`)\n\n| Working on… | Read |\n|---|---|\n| Any React work (shared stack, API client, Query, Tailwind, design language) | `frontend/shared.md` |\n| The operator app (`clients/admin`) | `frontend/admin.md` |\n| The tenant app (`clients/dashboard`) | `frontend/dashboard.md` |\n\n## Coding style (backend)\n\nFile-scoped namespaces · 4-space indent · explicit types (`var` only when RHS-obvious) · `is null` /\n`is not null` · pattern matching + switch expressions · `ArgumentNullException.ThrowIfNull` guards ·\nrecords for DTOs/events/value objects · `default!` for required non-nullable strings. Build runs with\n`TreatWarningsAsErrors` — warnings fail the build.\n\n## Adding things (quick pointers)\n\n- **Feature** — Contracts command/query → handler → validator → endpoint → wire in module `MapEndpoints()` → tests. Details: `api-conventions.md`.\n- **Module** — new `Modules.{Name}` + `.Contracts`, implement `IModule` w/ assembly-level `[assembly: FshModule(typeof(XModule), order)]`, register in **all four places**, add migration folder + tests. Details: `architecture.md`.\n- **React page** — API module (`src/api/`) → page → register lazy route → (admin) mirror permission + RouteGuard → Playwright test. Details: `frontend/shared.md`.\n\n## AI tooling resources\n\n- **Rules** — `.agents/rules/*.md` (indexed above). Read on demand.\n- **Skills** — `.agents/skills/*/SKILL.md`: step-by-step task recipes. Scaffolders: `add-feature`, `add-entity`, `add-module`, `add-react-page`, `add-full-slice`. Ops: `create-migration`, `add-integration-event`, `add-permission`. Reference: `query-patterns`, `testing-guide`, `mediator-reference`.\n- **Workflows** — `.agents/workflows/*.md`: task playbooks (`code-reviewer`, `feature-scaffolder`, `module-creator`, `architecture-guard`, `migration-helper`).\n","isInternal":false,"tokens":2510,"sizeBytes":9554},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# Claude Code\n\nThe canonical project guide is **`AGENTS.md`** (tool-neutral). It is imported below; edit conventions there, not here.\n\n@AGENTS.md\n","isInternal":false,"tokens":37,"sizeBytes":146},{"name":"api-conventions.md","path":".agents/rules/api-conventions.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/api-conventions.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# API conventions\n\nRead before adding endpoints, commands/queries, validators, or error handling.\n\n## Endpoints\n\nStatic extension methods on `IEndpointRouteBuilder`, returning `RouteHandlerBuilder`. The handler delegates to Mediator. Gate with `.RequirePermission(...)`.\n\n```csharp\npublic static class RegisterUserEndpoint\n{\n    internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) =>\n        endpoints.MapPost(\"/register\", (RegisterUserCommand command,\n                IMediator mediator, CancellationToken cancellationToken) =>\n                mediator.Send(command, cancellationToken))\n            .WithName(\"RegisterUser\")\n            .WithSummary(\"Register user\")\n            .RequirePermission(IdentityPermissionConstants.Users.Create);\n}\n```\n\n- **Always accept and forward `CancellationToken`** to `mediator.Send`. ASP.NET injects it.\n- Wire each endpoint in the module's `MapEndpoints()`. Endpoints group under `api/v{version:apiVersion}/{module}`.\n- Use `TypedResults` / `.Produces<T>(...)` for accurate OpenAPI. Add `.WithIdempotency()` on POSTs that must be replay-safe.\n\n## CQRS\n\n- **Commands/Queries** live in `Modules.{Name}.Contracts` — implement `ICommand<TResponse>` / `IQuery<TResponse>`. Records preferred.\n- **Handlers** live in `Modules.{Name}/Features/` — `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on awaits.\n- Paginated queries implement `IPagedQuery` (`PageNumber`, `PageSize`, `Sort`) and return `PagedResponse<T>`.\n\n## Validation\n\nFluentValidation, auto-registered by `ModuleLoader`. Name `{Command}Validator`. Live in the same feature folder.\n\n- **Every command handler needs a validator; every paginated query handler needs one too.** Enforced by `Architecture.Tests` (`HandlerValidatorPairingTests`). A handler legitimately without rules can be added to that test's known-missing allowlist, but prefer writing the validator.\n- Validators run via the `ValidationBehavior<,>` Mediator pipeline before the handler.\n\n## Exceptions → ProblemDetails\n\nThrow framework exception types; the global handler converts to RFC 9457 `ProblemDetails`:\n\n| Throw | HTTP |\n|---|---|\n| `NotFoundException` | 404 |\n| `ForbiddenException` | 403 |\n| `UnauthorizedException` | 401 |\n| `CustomException(msg, errors?, HttpStatusCode)` | as specified (default 400) |\n\nDon't catch broadly to swallow. Background loops may `catch (Exception)` to stay alive, but must **log with context** and exclude `OperationCanceledException` (filtered catch or a preceding `catch (OperationCanceledException)`).\n\n## Permissions\n\nConstants in `Shared/Identity/*Permissions.cs` (e.g. `IdentityPermissionConstants`). Apply with `.RequirePermission(...)` on the endpoint. `RequiredPermissionAttribute` implements `IRequiredPermissionMetadata` — never let a duplicate of that interface appear; it silently disables **all** `.RequirePermission()` gates.\n\n## Specifications\n\nUse `Specification<T>` (`src/BuildingBlocks/Persistence/Specifications/`) for query composition. Default `AsNoTracking = true` — see `database.md` for when tracking is required instead.\n\n## Adding a feature (checklist)\n\n1. Command/query + response in `Modules.{Name}.Contracts/v1/{Area}/{Feature}/`.\n2. Handler in `Modules.{Name}/Features/v1/{Area}/{Feature}/`.\n3. Validator in the same folder.\n4. Endpoint in the same folder; wire in module `MapEndpoints()`.\n5. Tests in `Tests/{Name}.Tests/` (+ integration test if it touches DB/IO).\n","isInternal":false,"tokens":782,"sizeBytes":3542},{"name":"architecture.md","path":".agents/rules/architecture.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/architecture.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Architecture rules\n\nModular Monolith + Vertical Slice Architecture (VSA). Read this before adding/moving modules or touching wiring.\n\n## Layers & dependency direction\n\n```\nHost (composition root)  →  Modules.{Name} (runtime)  →  Modules.{Name}.Contracts (public API)\n                         →  BuildingBlocks (shared framework)\n```\n\n- **BuildingBlocks** (`src/BuildingBlocks/`) — Core, Persistence, Web, Caching, Eventing, Storage, Quota, Jobs, Mailing, Shared. Consumed by all modules. **Do not modify without explicit approval.**\n- **Modules** (`src/Modules/{Name}/`) — bounded contexts. Each = a runtime project (internal) + a `.Contracts` project (public API: commands, queries, events, DTOs, service interfaces).\n- A module **MUST NOT** reference another module's runtime project — only its `.Contracts`. Enforced by `Architecture.Tests` (NetArchTest).\n\n## Module = runtime + Contracts\n\n```\nModules.Identity/            ← runtime (internal): handlers, services, domain, data\nModules.Identity.Contracts/  ← public: ICommand/IQuery types, DTOs, events, service interfaces\n```\n\nCross-module communication: through Contracts service interfaces or integration events only.\n\n## Feature folder layout (VSA)\n\nEach feature is a vertical slice in `Features/v{version}/{Area}/{Feature}/`:\n\n```\nFeatures/v1/Users/RegisterUser/\n├── RegisterUserEndpoint.cs          # minimal API endpoint\n├── RegisterUserCommandHandler.cs    # CQRS handler (public sealed)\n└── RegisterUserCommandValidator.cs  # FluentValidation\n```\n\nModule support folders: `Domain/`, `Data/`, `Services/`, `Events/`, `Authorization/`.\n\n## IModule registration\n\nEach module implements `IModule`, declared via an **assembly-level** `[FshModule]` attribute (positional `(Type moduleType, int order = 0)`) — **not** a class-level `[FshModule(Order = n)]`:\n\n```csharp\n[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 1)]   // above the namespace\n\nnamespace FSH.Modules.Identity;\n\npublic sealed class IdentityModule : IModule\n{\n    public void ConfigureServices(IHostApplicationBuilder builder) { ... }\n    public void ConfigureMiddleware(IApplicationBuilder app) { ... }   // optional, runs AFTER UseAuthentication\n    public void MapEndpoints(IEndpointRouteBuilder endpoints) { ... }\n}\n```\n\n`ModuleLoader.AddModules` (`src/BuildingBlocks/Web/Modules/ModuleLoader.cs`) discovers `[FshModule]` attributes, orders by `Order` then name, instantiates each, and calls `ConfigureServices`. Endpoints map under `api/v{version:apiVersion}/{module}`.\n\n## ⚠️ The four-place registration footgun\n\nAdding a module requires editing **four** lists. Miss one and it fails *silently*:\n\n| Place | File | Symptom if missed |\n|---|---|---|\n| Mediator `o.Assemblies` (two markers: Contracts type **and** module type) | `src/Host/FSH.Starter.Api/Program.cs` | Handlers silently undiscovered |\n| `moduleAssemblies` array | `src/Host/FSH.Starter.Api/Program.cs` | Module never loaded |\n| Mediator assemblies (same pair) | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |\n| module assemblies array | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |\n\nAfter wiring, the fastest sanity check is: build, hit the endpoint, confirm the handler runs.\n\n## DI & handler conventions\n\n- Mediator handlers: `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on every await. `ServiceLifetime.Scoped`.\n- Validators auto-register via `ModuleLoader` (`AddValidatorsFromAssemblies`). Name them `{Command}Validator`.\n- Prefer constructor injection / primary constructors. Watch DI lifetimes: stateful singletons must be thread-safe (use `ConcurrentDictionary` / immutable snapshots).\n\n## Middleware ordering (critical)\n\nIn `src/BuildingBlocks/Web/Extensions.cs` (`UseHeroPlatform`):\n\n1. ExceptionHandler → ResponseCompression\n2. **CORS before HTTPS redirect** (so OPTIONS preflight isn't 307-redirected)\n3. HttpsRedirection → SecurityHeaders → static files → Routing\n4. **`UseAuthentication`**\n5. **`UseHeroLocalization`** — request localization, sits **between `UseAuthentication` and `UseAuthorization`** so the user-`locale`-claim culture provider can read `HttpContext.User`\n6. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth\n7. RateLimiting → Quotas → `UseAuthorization` → `MapModules`\n\n`app.UseHeroMultiTenantDatabases()` (Finbuckle `UseMultiTenant()`) runs in `Program.cs` **before** `UseHeroPlatform`, i.e. **before `UseAuthentication`**. Finbuckle's `ClaimStrategy` authenticates on its own, so authenticated requests resolve by tenant claim and anonymous ones by the `tenant` header. See `modules/multitenancy.md`.\n\n## Static/global state\n\nNo global mutable static collections enumerated under concurrency. `Audit` (Auditing module) swaps an immutable `IAuditEnricher[]` atomically; `ModuleLoader` guards with a lock. Follow that pattern if you must hold process-global state.\n\n## Configuration & options\n\n- `appsettings.json` (+ `.Development`/`.Production`) live in `src/Host/FSH.Starter.Api/`. DbMigrator links the same files.\n- Bind config with the Options pattern: `AddOptions<T>().BindConfiguration(nameof(T))`, section name == type name (e.g. `JwtOptions`, `DatabaseOptions`, `CachingOptions`, `CorsOptions`, `QuotaOptions`, `RateLimitingOptions`; **storage section is `Storage`**, not `StorageOptions`). Add `.ValidateDataAnnotations().ValidateOnStart()` for fail-fast.\n- Validate critical options via `IValidatableObject` — `JwtOptions` requires `SigningKey` ≥32 chars and **rejects placeholder strings containing `\"replace-with\"`**; `DatabaseOptions` rejects empty connection strings.\n- **Production fail-fast** (`Program.cs`, before service registration): missing `DatabaseOptions:ConnectionString`, `CachingOptions:Redis`, or `JwtOptions:SigningKey` throws. Dev secrets via `dotnet user-secrets` (AppHost has a `UserSecretsId`); RustFS (S3) creds are Aspire secret parameters.\n- Platform composition is one call each: `builder.AddHeroPlatform(o => { o.Enable... })` (DI) and `app.UseHeroPlatform(...)` (middleware). Feature flags toggle Caching/Jobs/Mailing/Quotas/Sse/Realtime/OpenTelemetry/CORS/Idempotency.\n","isInternal":false,"tokens":1503,"sizeBytes":6314},{"name":"buildingblocks-protection.md","path":".agents/rules/buildingblocks-protection.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/buildingblocks-protection.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"---\npaths:\n  - \"src/BuildingBlocks/**/*\"\n---\n\n# ⚠️ BuildingBlocks Protection\n\n**STOP. You are modifying BuildingBlocks.**\n\nChanges to BuildingBlocks affect ALL modules across the entire framework. These are core abstractions that many projects depend on.\n\n## Before Proceeding\n\n1. **Confirm explicit approval** - Has the user specifically approved this change?\n2. **Consider alternatives** - Can this be done in the module instead?\n3. **Assess impact** - What modules will this affect?\n\n## If Approved\n\n- Make minimal, focused changes\n- Ensure backward compatibility\n- Update all affected modules\n- Run full test suite: `dotnet test src/FSH.Starter.slnx`\n- Document the change\n\n## Alternatives to Consider\n\n| Instead of... | Consider... |\n|---------------|-------------|\n| Modifying Core | Extension method in module |\n| Changing Persistence | Custom repository in module |\n| Updating Web | Module-specific middleware |\n\n## If Not Approved\n\nDo not proceed. Suggest alternatives that don't require BuildingBlocks modifications.\n","frontmatter":{"paths":["src/BuildingBlocks/**/*"]},"isInternal":false,"tokens":220,"sizeBytes":1031},{"name":"caching.md","path":".agents/rules/caching.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/caching.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Caching\n\n`src/BuildingBlocks/Caching/`. Read before adding cached reads or invalidation.\n\n## What's registered\n\n`AddHeroCaching(config)` always registers **`HybridCache`** (L1 in-memory + optional L2 Redis). Inject `HybridCache`, not `IDistributedCache`.\n\n- `CachingOptions.Redis` empty → in-memory only (dev fallback). Set → a **single shared `ConnectionMultiplexer`** (singleton `IConnectionMultiplexer`) backs both the L2 cache and the DataProtection key ring.\n- Defaults: total expiration 1h, L1 (local) expiration 2min (`CachingOptions`).\n- `ObservableHybridCache` transparently decorates `HybridCache` to emit OpenTelemetry (hits/misses/factory-duration/invalidations). You don't reference it — just inject `HybridCache`.\n\n## Pattern\n\n```csharp\nvar perms = await cache.GetOrCreateAsync(\n    CacheKeys.UserPermissions(userId),\n    async ct => await LoadPermissionsAsync(userId, ct),\n    tags: [CacheKeys.Tags.Permissions, CacheKeys.Tags.User(userId)],\n    cancellationToken: ct);\n```\n\n- **Keys & tags live in `CacheKeys.cs`** — add new keys/tags there, don't inline strings. Existing: `UserPermissions(userId)`, `TenantTheme(tenantId)`, `IdempotencyEntry(tenantId,key)`, `ImpersonationGrantStatus(jti)`; tags `Permissions`, `Themes`, `Idempotency`, `Tenant(id)`, `User(id)`.\n- Invalidate with `RemoveAsync(key)` or `RemoveByTagAsync(tag)` in the relevant mutation handler.\n- `GetOrCreateAsync` gives **stampede protection** for free (factory runs once per key).\n\n## Gotchas\n\n- **No L1 backplane.** `RemoveByTagAsync` on one node does **not** evict L1 on peer nodes — cross-node staleness is bounded only by the 2-min local expiration. Don't rely on instant cross-node invalidation; keep local expiration short for hot, mutable data.\n- Don't reach for `IDistributedCache` directly except where the framework already does so deliberately (idempotency probe-read) — prefer `HybridCache`.\n","isInternal":false,"tokens":475,"sizeBytes":1904},{"name":"database.md","path":".agents/rules/database.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/database.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Database & EF Core conventions\n\nRead before touching entities, DbContexts, migrations, or query filters.\n\n## Entities\n\n- `BaseEntity` — `Id`, `CreatedAt`, `UpdatedAt`, `TenantId`.\n- `AggregateRoot` — `BaseEntity` + domain events (`IHasDomainEvents`, `_domainEvents` list).\n- Marker interfaces: `IHasTenant`, `IAuditableEntity`, `ISoftDeletable`, `IGlobalEntity`.\n- Domain events inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Integration events implement `IIntegrationEvent`; handlers `IIntegrationEventHandler<T>`.\n\n## Tenant isolation (default-ON)\n\n- `BaseDbContext` auto-applies a tenant query filter to every entity. **Isolation is on by default.**\n- Opt out **only** via `IGlobalEntity` (e.g. `BillingPlan`, `ImpersonationGrant`, `Outbox`/`InboxMessage`).\n- A subclass DbContext that overrides `OnModelCreating` **must call `base.OnModelCreating(modelBuilder)` LAST**, or the auto-applied filters are lost.\n- Cross-tenant reads use `IgnoreQueryFilters()` **plus an explicit re-filter** — never rely on the absence of the filter.\n- **Query-filter naming:** SoftDelete filter is *named*; the tenant filter stays *anonymous* (Finbuckle owns it). Don't rename the tenant filter.\n\n## AsNoTracking — and when NOT to\n\n- Read-only queries: add `.AsNoTracking()` (Specifications default to it).\n- **Do NOT add `AsNoTracking()` to a read-then-mutate-then-`SaveChanges` query** — the entity must stay tracked or your changes won't persist. The analyzer (AP010) flags these as a smell, but for mutate-and-save flows it is a false positive — leave them tracked.\n- `AnyAsync(...)` materializes no entity, so `AsNoTracking()` there is a no-op — skip it.\n\n## Value generation for nav-collection children\n\nA child entity reached **only** through a parent's navigation collection needs `Property(x => x.Id).ValueGeneratedNever()` in its EF config — otherwise EF treats it as `Modified` instead of `Added` and the insert silently misbehaves.\n\n## Migrations\n\nAll migrations live in **one** project, `src/Host/FSH.Starter.Migrations.PostgreSQL`, organized **per-module by folder** (`Identity/`, `Catalog/`, `Chat/`, …), each with its own `{Module}DbContextModelSnapshot`.\n\n```bash\ndotnet ef migrations add {Name} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {Module}DbContext\n```\n\n- **`migrations remove` operates on the snapshot** — run a full build *before* `migrations add` so the snapshot is current, or you can lose the previous migration.\n- The DB is **not** migrated at API startup. The `DbMigrator` host is a separate step: `apply` (default), `seed`, `seed-demo` (dev only), `list-pending`; flags `--tenant <id>`, `--catalog-only`, `--seed`. It migrates the tenant catalog first, then each tenant's per-module schema, serialized by a Postgres advisory lock.\n- `dotnet-ef` is pinned in `.config/dotnet-tools.json` — run `dotnet tool restore` first.\n\n## Tests + EF\n\n- Integration tests use Testcontainers (real PostgreSQL) — **Docker must be running**.\n- In integration tests, set the Finbuckle tenant context **inline in the same method** as the `UserManager`/`DbContext` call; an awaited-helper set is lost (AsyncLocal) and the tenant query filter NREs.\n","isInternal":false,"tokens":822,"sizeBytes":3287},{"name":"eventing.md","path":".agents/rules/eventing.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/eventing.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Eventing — domain events, integration events, Outbox/Inbox\n\nRead before publishing/handling cross-module events. `src/BuildingBlocks/Eventing/`.\n\n## Two tiers\n\n- **Domain events** (in-process, pre-commit) — inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Raised on aggregates (`IHasDomainEvents`).\n- **Integration events** (cross-module, async) — implement `IIntegrationEvent` (`Id`, `OccurredOnUtc`, `TenantId`, `CorrelationId`, `Source`). Handlers implement `IIntegrationEventHandler<T>` (single `HandleAsync(T, ct)`), are `sealed`, live in `Events/` or `IntegrationEventHandlers/`.\n\n## The Outbox is the only way to publish\n\n**Do not call `IEventBus` directly from a handler.** Publish via the outbox so the event commits with the business write and survives a crash:\n\n```csharp\nawait _outbox.AddAsync(integrationEvent, ct).ConfigureAwait(false);   // IOutboxWriter\n```\n\nInject **`IOutboxWriter`** (`FSH.Framework.Eventing.Abstractions`) — the publish-side contract, and all a module ever needs. `IOutboxStore` is the full dispatcher-side surface and lives in the eventing runtime, which modules don't reference.\n\n`EfCoreOutboxStore.AddAsync` serializes + `SaveChanges` immediately, joining the caller's transaction when there is one. `OutboxDispatcherHostedService` polls every `OutboxDispatchIntervalSeconds` (default 10), `OutboxDispatcher` **claims** a batch (`OutboxBatchSize`, default 100), publishes via `IEventBus`, and dead-letters after `OutboxMaxRetries` (default 5) → `IsDead`. Failures back off exponentially (`NextRetryAt`); `RedriveDeadLettersAsync` recovers dead rows. `OutboxMessage`/`InboxMessage` are `IGlobalEntity` (no tenant filter — the dispatcher has no tenant context; `TenantId` is an explicit column).\n\n**Publishing is asynchronous.** The consumer runs on the next dispatch cycle, not inside the request. Don't write a caller — or a test — that assumes the side effect already happened. Integration tests drain explicitly via `OutboxDrain.DrainAsync`.\n\n**The one documented exception:** Chat `SendMessageCommandHandler` still publishes mentions on the bus, because the Notifications handler pushes over SignalR and a delayed mention badge reads as broken. Adding a second exception needs the same kind of reason, in a comment at the call site.\n\n## One store, owned by the framework\n\n`OutboxMessages`/`InboxMessages` live in schema `framework`, owned by `EventingDbContext` (`src/BuildingBlocks/Eventing/Persistence/`) — **not** by any module's context. That is what keeps `IOutboxStore`/`IInboxStore` to a single, non-keyed DI registration: registering them per module DbContext made .NET DI resolve whichever module registered last for the whole application, so a second module publishing broke every module's outbox (issue #1349). `EventingRegistrationTests` guards the registration count; don't add a second one.\n\n`EventingDbContext` derives from `BaseDbContext`, so a tenant with a dedicated database gets its outbox rows in that database, next to the business data they accompany.\n\n## Dispatch across tenant databases\n\nBecause rows follow the tenant connection, the dispatcher can't just poll one database. Each cycle it asks `IEventingDrainTargetProvider` for the drain targets and runs one pass per target inside `IEventingDrainScope`, which installs the tenant context **before** the scope's `EventingDbContext` is built (it captures `TenantInfo`, and with it the connection string, at construction).\n\nDefaults in BuildingBlocks are single-database (`SingleDatabaseDrainTargetProvider`, `NullEventingDrainScope`); the multitenancy module replaces them with `TenantStoreDrainTargetProvider` (default DB + one target per distinct **active** per-tenant connection string; tenants sharing a database collapse to one target) and `FinbuckleEventingDrainScope`. One unreachable tenant database is logged and skipped, not fatal to the cycle.\n\n## Multi-instance safety\n\n`ClaimBatchAsync` leases rows with `FOR UPDATE SKIP LOCKED` in a single `UPDATE … RETURNING`, so several API instances partition a batch instead of all publishing the same message. `ClaimedUntilUtc` is an expiry (`OutboxClaimLeaseSeconds`, default 300), so a dispatcher that dies mid-batch has its rows recovered rather than stranded — raise it if a batch can take longer than the lease, or a second instance re-claims rows still in flight. Completing or failing a message releases the lease. Non-Postgres providers have no portable `SKIP LOCKED`: they fall back to an unclaimed read and log a warning that only one instance is safe.\n\n## Atomicity\n\n`IScopedDbConnectionProvider` gives every DbContext in a DI scope the same `DbConnection`, which is the only way EF Core can enlist a second context in a transaction another one opened (Npgsql has no distributed-transaction promotion). `AmbientDbTransactionRegistry` — an `IDbTransactionInterceptor` on every Hero context — records open transactions, since `DbConnection` can't be asked. `AddAsync` joins the ambient transaction when there is one, so the outbox row commits or rolls back with the business data; with none, it commits on its own exactly as before.\n\n## Idempotency is free (in-memory bus)\n\n`InMemoryEventBus` resolves handlers in a fresh DI scope and applies the **Inbox**: skips if `IInboxStore.HasProcessedAsync(eventId, handlerName)`, marks processed after success. Composite key `{Id, HandlerName}`; concurrent-insert race is swallowed. Don't hand-roll dedup.\n\n## Wiring\n\nThe **host** bootstraps eventing once (`FSH.Starter.Api/Program.cs` and `FSH.Starter.DbMigrator/Program.cs`, before `AddModules` so `EventingDbInitializer` migrates the `framework` schema first):\n\n```csharp\nbuilder.Services.AddEventingCore(builder.Configuration);   // serializer + bus + dispatcher + EventingDbContext + stores\n```\n\nA **module** only registers its handlers:\n\n```csharp\nservices.AddIntegrationEventHandlers(typeof(MyModule).Assembly);        // scans IIntegrationEventHandler<>\n```\n\nThere is no per-module store registration — `AddEventingForDbContext<T>` was removed in #1349. A module publishes by injecting `IOutboxWriter`; nothing else is needed.\n\nBus = `EventingOptions.Provider`: `\"RabbitMQ\"` → `RabbitMqEventBus` (durable topic exchange); else `InMemoryEventBus` (default).\n\n## Gotchas\n\n- **Renaming/moving an integration event type breaks deserialization** — the outbox stores the assembly-qualified type name; `Type.GetType()` returns null → the message dead-letters. Keep event type names/namespaces stable, or migrate dead rows.\n- **Background handlers carry no HTTP/tenant context.** An open-generic or background handler that reads a tenant-filtered DbContext must restore Finbuckle context first via `IMultiTenantContextSetter` (see `WebhookFanoutHandler`, `modules/webhooks.md`).\n- In-memory bus runs handlers **synchronously in the publisher's scope** — keep handler work minimal; exceptions surface to the originating request (relevant for Notifications consuming Chat events). Via the outbox that scope is the dispatcher's, not the request's.\n- Set `UseHostedServiceDispatcher=false` to drive the outbox via Hangfire instead of the hosted service.\n- A background publisher must set the tenant context **before** `AddAsync` — otherwise, with per-tenant databases, the row lands in the wrong one (see `TenantExpiryScanJob`).\n","isInternal":false,"tokens":1687,"sizeBytes":7377},{"name":"admin.md","path":".agents/rules/frontend/admin.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/frontend/admin.md","title":"Frontend Skill","category":"anthropic-skill","format":"markdown","content":"# Frontend — admin app (`clients/admin`)\n\nOperator/SuperAdmin-facing console. Read `frontend/shared.md` first; this file is only the divergences.\n\n- **Port** 5173 · dev proxy target `http://localhost:5030` (HTTP) · localStorage prefix `fsh.admin.*` · login header `X-FSH-App: admin`.\n- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, dashboardUrl }`. `dashboardUrl` is used for the one-way impersonation handoff into the dashboard app.\n\n## Forms — react-hook-form + zod\n\nAdmin is the **only** app with `react-hook-form` + `zod` + `@hookform/resolvers`. Use them:\n\n```ts\nconst form = useForm<Schema>({ resolver: zodResolver(schema) });\n```\n\nForm layout primitives live in `src/components/list/` (`PageHeader`, `Field`, `FormShell`, `FormSection`, `FormActions`, `Pagination`, `ErrorBand`).\n\n## Permissions — fetched, hydrated, gated\n\n- The JWT carries only role names. Admin fetches the permission set separately: `GET /api/v1/identity/permissions` (`getMyPermissions`), cached under `fsh.admin.permissions`.\n- `AuthProvider` hydrates them in an effect keyed on subject change and exposes `permissionsHydrated` to avoid a 403 flash on first paint.\n- **Route gating:** wrap gated route elements in `<RouteGuard perms={[IdentityPermissions.Users.View]}>…</RouteGuard>`. It renders a \"Resolving permissions\" state while `!permissionsHydrated`, else `<ForbiddenView missing={…}/>`. (`ProtectedRoute` also accepts a `permissions?` prop.)\n- **Mirror server permissions by hand** in `src/lib/permissions.ts` (`IdentityPermissions`, `MultitenancyPermissions`, … frozen objects + `PERMISSION_CATALOG` driving the role editor). There is intentionally **no** runtime catalog fetch — when the server adds a permission, mirror the constant here.\n\n## Routing & realtime\n\n- Routes wrap elements in `<RouteGuard perms={…}>` (no per-route Suspense wrapper).\n- `RealtimeProvider` is mounted in `App.tsx` and wires only `[\"NotificationCreated\"]`.\n\n## Theme\n\nCool-cast neutrals (hue 240, small non-zero chroma — **not** chroma 0), a single fixed chartreuse \"signal\" accent (`--accent-signal` / `--signal-500`), Geist / Geist Mono fonts. Defined in `src/styles/globals.css`.\n\n## Add-a-page deltas (on top of shared steps)\n\n- Use RHF + zod for any form.\n- If the endpoint requires a permission, mirror the constant in `src/lib/permissions.ts` (and `PERMISSION_CATALOG` if it belongs in the role editor) and wrap the route in `<RouteGuard perms={[…]}>`.\n- Playwright: `seedAuthedSession` here also pre-seeds `fsh.admin.permissions` so `RouteGuard` passes on first paint.\n","isInternal":false,"tokens":645,"sizeBytes":2573},{"name":"dashboard.md","path":".agents/rules/frontend/dashboard.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/frontend/dashboard.md","title":"Frontend Skill","category":"anthropic-skill","format":"markdown","content":"# Frontend — dashboard app (`clients/dashboard`)\n\nTenant-facing application. Read `frontend/shared.md` first; this file is only the divergences.\n\n- **Port** 5174 · dev proxy target `https://localhost:7030` (HTTPS, with `ws: true` for the SignalR hub) · localStorage prefix `fsh.dashboard.*` · login header `X-FSH-App: dashboard`.\n- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, demoMode }`.\n- Dev-proxy is HTTPS on purpose: routing the bearer token through an HTTP→HTTPS 307 redirect stripped the `Authorization` header.\n\n## No RHF/zod — hand-rolled forms\n\nThe dashboard does **not** depend on react-hook-form or zod. Use controlled inputs + local state. Don't add those deps to match admin.\n\n## Permissions — fetched, not in the JWT\n\nThe JWT carries **only role names**. `auth-context.tsx` fetches the effective permission list from `GET /api/v1/identity/permissions` (`getMyPermissions()` in `src/api/identity.ts`), caches it in `tokenStore` under `fsh.dashboard.permissions`, and exposes a `permissionsHydrated` flag so gated UI doesn't flash while the fetch is in flight (re-fetched on login/impersonation swaps; `refreshPermissions()` for role changes). Nav items are gated via `perm`/`anyPerm` in `src/components/layout/nav-data.ts`. `ProtectedRoute` is still **auth-only** (no per-route permission gating) — don't add `RouteGuard`-style gating here.\n\n## Routing & realtime/SSE\n\n- Every route element is wrapped in `withSuspense(node)` (per-route skeleton fallback). No per-route permission guards.\n- `RealtimeProvider` **and** `SseProvider` are mounted inside `AppShell` (authenticated routes only), under a `CommandPaletteProvider` (cmdk).\n- SignalR provider pre-wires ~11 chat/notification events.\n- **SSE** (`src/sse/`, dashboard-only): two-step token — `POST /api/v1/sse/token`, then `GET /api/v1/sse/stream?token=<guid>` consumed via fetch streaming (`parseSseStream` async generator; EventSource can't send auth headers). **Two split contexts:** `useSseStatus()` (stable, for status dots) vs `useSseEvents()` (mutates per event) to avoid cascading re-renders; `useSse()` is the composite.\n\n## Impersonation\n\n`token-store.ts` has `beginImpersonation` / `endImpersonationWithFreshTokens` / `restoreStashedActor` that stash the operator's tokens under `fsh.dashboard.impersonation.*`. `AuthProvider` exposes `beginImpersonation`/`stopImpersonation` and derives `ImpersonationInfo` from `act_sub` / `act_tenant` / `act_name` claims. Admin triggers the handoff one-way via its `dashboardUrl`.\n\n## Performance\n\n- `@tanstack/react-virtual` for long lists — use it for any large collection (chat history, big tables).\n- `cmdk` powers the command palette.\n\n## Theme\n\n**Chroma-0 neutrals** (`--neutral-*: oklch(L 0 0)` — untinted; the warm-paper tint was deliberately removed). Rose default brand with **swappable accent themes** via `.accent-{rose,indigo,violet,sky,emerald,amber}` classes that override the `--brand-*` oklch stops; saffron secondary; Figtree font. Defined in `src/styles/globals.css`. Keep neutrals at chroma 0.\n\n## Add-a-page deltas (on top of shared steps)\n\n- Hand-roll forms (no RHF/zod).\n- Wrap the route element in `withSuspense(<X/>)`; no permission guard.\n- If it consumes pushes: `useRealtimeEvent(\"EventName\", handler)` (register the name in `realtime-context.tsx`) or `useSseEvents()` for SSE.\n- Use `react-virtual` for long lists; keep neutrals chroma 0.\n","isInternal":false,"tokens":890,"sizeBytes":3412},{"name":"shared.md","path":".agents/rules/frontend/shared.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/frontend/shared.md","title":"Frontend Skill","category":"anthropic-skill","format":"markdown","content":"# Frontend — shared conventions\n\nApplies to **both** `clients/admin` and `clients/dashboard`. Read this for any React work, then read\nthe app-specific file (`admin.md` / `dashboard.md`) for divergences.\n\nStack: React 19 · Vite 7 · TypeScript · TanStack Query v5 · React Router 7 · Radix UI · Tailwind v4 ·\nclass-variance-authority (shadcn-style) · `@microsoft/signalr`. Path alias `@` → `src` (`vite.config.ts`).\n\n## API client (`src/lib/api-client.ts`, `src/api/*`)\n\n- One fetch wrapper: `apiFetch<T>(path, init)`. No axios.\n- **Types are hand-written**, not generated. (`openapi-typescript` is declared in admin's devDeps but unused — there is no codegen step.) Define DTO `type`s and a `const BASE = \"/api/v1/...\"` in each `src/api/{feature}.ts`, with thin async functions calling `apiFetch`.\n- Auth header `Authorization: Bearer <access>` from `tokenStore.getAccessToken()` (unless `skipAuth:true`).\n- Tenant header (lowercase `tenant`) = `tokenStore.getTenant() ?? env.defaultTenant`, unless overridden per request.\n- Errors: non-OK parses RFC 9457 `application/problem+json` and throws `ApiRequestError(status, message, problem)`. 204/empty → `undefined`.\n- **Single-flight refresh:** on 401 with a refresh token, `POST /api/v1/identity/token/refresh` with `{token, refreshToken}`; a module-level `refreshPromise` dedupes concurrent refreshes; rotated token returns on `token` (not `accessToken`); the original request retries once.\n- Search endpoints: build `URLSearchParams` with **PascalCase** keys (`PageNumber`, `PageSize`, …) to match the API.\n\n## Env (`src/env.ts`) — runtime, not build-time\n\n`loadRuntimeConfig()` fetches `/config.json` once at boot (awaited in `main.tsx` before React mounts); `env` is a getter that throws if read too early. One built image promotes across environments (operator writes `config.json`). The only `VITE_*` var, `VITE_API_BASE_URL`, configures the **Vite dev proxy target only** — it is not the runtime apiBase (`config.json` ships `apiBase: \"\"`, relative).\n\n## Data fetching (TanStack Query v5)\n\n- Shared `queryClient` (`src/lib/query-client.ts`): `staleTime: 30_000`, `refetchOnWindowFocus:false`, no retry on 401/403 else `failureCount < 2`.\n- **Query keys are inline literal arrays**, hierarchical, params object last: `[\"users\", {pageNumber, searchTerm}]`, `[\"user\", id]`, `[\"user\", id, \"roles\"]`. No central key factory.\n- `useQuery`/`useMutation` live inline in page components. Invalidate in `onSuccess`: `queryClient.invalidateQueries({ queryKey: [\"users\"] })`. Pagination: `placeholderData: keepPreviousData`.\n\n### ⚠️ The `mutate(arg)` race-safe pattern (golden rule #9)\n\n`useMutation` reads its options at execute time, so values produced at call time (e.g. a fresh\n`crypto.randomUUID()` client id) must ride **through `mutate(arg)`** and be read from the `variables`\nargument of `onMutate`/`onSuccess`/`onError` — never from component state the callbacks close over,\nor two rapid calls collide.\n\n```ts\nmutation.mutate({ text, clientId: crypto.randomUUID() });\n// onMutate: ({ clientId }) => insert optimistic `temp:${clientId}`\n// onSuccess: (real, { clientId }) => swap temp → real\n// onError:   (_e, { clientId }) => rollback\n```\n\n## Routing (`routes.tsx`, `App.tsx`)\n\n- `createBrowserRouter`, flat config. Pages are **named exports** loaded via a `lazyNamed(importer, name)` helper (adapts named → `React.lazy`'s default contract). No default exports.\n- Nesting: public auth routes → `<ProtectedRoute/>` → `<AppShell/>` → page children. `errorElement: <RouteError/>`.\n- Provider tree: `ThemeProvider > QueryClientProvider > AuthProvider > … > RouterProvider` + `sonner` `<Toaster/>`.\n\n## Auth (`src/auth/`)\n\n`token-store.ts` (localStorage + pub/sub), `jwt.ts` (`decodeJwt`), `AuthProvider`/`useAuth()`, `ProtectedRoute`.\nLogin `POST /api/v1/identity/token/issue` with header `X-FSH-App: \"admin\"|\"dashboard\"`. **localStorage keys are namespaced per app** (`fsh.admin.*` / `fsh.dashboard.*`) so both run side-by-side. Permission *source* differs per app — see the app files.\n\n## Design system (Tailwind v4, shadcn-style)\n\n- **`cn()` is at `src/lib/cn.ts`** (`twMerge(clsx(...))`) — not `lib/utils.ts`. `components.json`: `style:new-york`, `baseColor:slate`, `cssVariables:true`, `iconLibrary:lucide`.\n- UI primitives in `src/components/ui/` are cva-based: `cva(base, { variants, defaultVariants })` + Radix `Slot`/`asChild` + `cn(buttonVariants({...}))`. Layout primitives in `src/components/list/` (admin) / similar (dashboard), re-exported from `index.ts`.\n- **Tailwind v4 is CSS-first — there is NO `tailwind.config`.** Configured via the `@tailwindcss/vite` plugin and one entrypoint `src/styles/globals.css` (imported in `main.tsx`). Tokens: `:root` oklch primitives → semantic vars → an `@theme inline { --color-*: var(--…) }` block exposing them as utilities. `@custom-variant dark (&:is(.dark *))`.\n- Add a new token in `globals.css` (primitive → semantic → `@theme inline`), then use the utility. Don't hard-code colors in components.\n\n### Design language (both apps differ on purpose)\n\n| | admin (operator) | dashboard (tenant) |\n|---|---|---|\n| Neutrals | cool-cast, hue 240, small non-zero chroma | **chroma 0** (untinted — the warm tint was removed) |\n| Accent | single fixed chartreuse \"signal\" (`--accent-signal`) | rose brand + **swappable** `.accent-{rose,indigo,violet,sky,emerald,amber}` |\n| Font | Geist / Geist Mono | Figtree (+ saffron secondary) |\n\nSo \"neutrals must be chroma 0\" is a **dashboard** rule. Admin neutrals are intentionally cool — match the file you're editing.\n\n## Realtime (SignalR)\n\n`src/realtime/realtime-context.tsx`: one `HubConnection` to `/api/v1/realtime/hub`. `@microsoft/signalr` is **dynamically imported** (lazy ~37KB) only when an authed session opens the hub. Auth via `accessTokenFactory`; a `tokenEpoch` (bumped by `tokenStore.subscribe`) forces reconnect on login/refresh/impersonation. Consume with `useRealtimeEvent(\"EventName\", handler, deps)` (handler kept in a ref). Pre-register new event names in the provider's event list.\n\n## Testing (Playwright, route-mocked)\n\n- `playwright.config.ts`: `testDir: ./tests`, chromium, auto-boots `npm run dev`, no real backend.\n- Tests in `tests/{area}/{name}.spec.ts`; helpers in `tests/helpers/`.\n- **JWT seeding:** `seedAuthedSession(page, TEST_USER)` builds a fake JWT and `addInitScript`-writes `fsh.{app}.*` to localStorage before React boots (server isn't called, so signature is junk).\n- **Route mocking:** `mockJsonResponse(page, urlGlob, body)` / `mockProblemDetails(...)`. `installShellMocks(page)` stubs every call `AppShell` fires and **aborts** SSE/SignalR. Playwright matches most-recently-registered first → broad shell mocks in `beforeEach`, page-specific mocks after (they win).\n- `beforeEach`: `seedAuthedSession(page, TEST_USER)` → `installShellMocks(page)`.\n\n## Add a page/feature (shared steps)\n\n1. API: extend `src/api/{feature}.ts` — hand-written types + `apiFetch` calls.\n2. Page: `src/pages/{area}/{name}.tsx`, **named** export. `useQuery` with hierarchical key; `useMutation` invalidating in `onSuccess`, passing per-call data via `mutate(arg)`.\n3. Route: add `const X = lazyNamed(() => import(\"@/pages/area/name\"), \"XPage\")` and a child route under `AppShell`.\n4. Test: `tests/{area}/{name}.spec.ts` with seed + shell mocks + page mocks.\n\nThen apply the app-specific steps in `admin.md` / `dashboard.md` (forms, permission gating, suspense, etc.).\n","isInternal":false,"tokens":2019,"sizeBytes":7499},{"name":"integration-testing.md","path":".agents/rules/integration-testing.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/integration-testing.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Integration testing\n\n`src/Tests/Integration.Tests/` + `Integration.Middleware.Tests/`. Read before writing tests that touch the DB/HTTP pipeline. See `testing.md` for unit conventions.\n\n## Harness\n\n`WebApplicationFactory` over **real** infra via Testcontainers — PostgreSQL + Redis + RustFS (S3-compatible, generic container). **Docker must be running**; if it isn't, tests fail fast with `DockerUnavailableException` (environmental, not a regression — run the unit projects instead).\n\n`FshWebApplicationFactory` (`Integration.Tests/Infrastructure/`) boots the containers, overlays in-memory config, swaps `IMailService` → `NoOpMailService`, and rewires storage to RustFS.\n\n## Must-know gotchas\n\n- **Tenant context is AsyncLocal — set it inline.** Set the Finbuckle tenant context **in the same method** as the `UserManager`/`DbContext` call. Setting it in an awaited helper loses it across the async boundary → NRE in the tenant query filter.\n- **Storage is wired eagerly.** `AddHeroStorage` reads `Storage:Provider` before the test config overlay, so it picks `LocalStorageService`. The factory **removes the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-registers the S3 stack** at RustFS. Follow that when a test needs real object storage. (See `storage.md`.)\n- **SignalR tests force long-polling** — TestServer has no WebSocket. Configure the client transport accordingly.\n- **Rate limiting is read eagerly** — `Integration.Middleware.Tests` sets `RateLimitingOptions:Enabled` via env var **before** host build, since flipping it after has no effect.\n\n## Coverage\n\n```bash\ndotnet test --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\nCobertura; includes `[FSH.Modules.*]` + `[FSH.Framework.*]`; excludes tests, the Migrations project, and `*HostedService`.\n","isInternal":false,"tokens":433,"sizeBytes":1847},{"name":"jobs.md","path":".agents/rules/jobs.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/jobs.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Background jobs (Hangfire)\n\n`src/BuildingBlocks/Jobs/`. Read before enqueuing or scheduling work.\n\n## Fire-and-forget / scheduled — `IJobService`\n\nInject `IJobService` (`Jobs/Services/IJobService.cs`) and use it; don't call Hangfire's `BackgroundJob` directly in feature code.\n\n```csharp\njobService.Enqueue(() => mailService.SendAsync(req, CancellationToken.None));   // default queue\njobService.Enqueue(\"email\", () => mailService.SendAsync(req, CancellationToken.None));\njobService.Schedule(() => DoLater(), TimeSpan.FromMinutes(5));\n```\n\nQueues: `default`, `email` (5 workers, 30s poll). Storage auto-selected from `DatabaseOptions.Provider` (Postgres/MSSQL).\n\n## Recurring jobs — `IRecurringJobManager`\n\n`IJobService` has **no** recurring API. Register recurring jobs in the module's `MapEndpoints` with `IRecurringJobManager.AddOrUpdate<T>(...)`, always `TimeZoneInfo.Utc`:\n\n```csharp\nrecurringJobs.AddOrUpdate<PurgeOrphanedFilesJob>(\"files:purge-orphaned\",\n    j => j.RunAsync(CancellationToken.None), Cron.Hourly(), new() { TimeZone = TimeZoneInfo.Utc });\n```\n\nExamples in the tree: `PurgeOrphanedFiles`/`PurgeDeletedFiles` (Files), `MonthlyInvoiceJob` (Billing), `AuditRetentionJob` (Auditing), `WebhookDispatchJob` (Webhooks).\n\n## Dashboard & config\n\n`/jobs` (default), behind `HangfireOptions.UserName`/`Password` basic auth — both `[Required]`, password `[MinLength(12)]`, so **startup fails in non-dev if unset**.\n\n## Gotchas\n\n- Jobs run on the server with **no HTTP/tenant context** — restore Finbuckle tenant context inside the job (fresh scope + `IMultiTenantContextSetter`) before touching a tenant-filtered DbContext.\n- The DbMigrator registers `NoOpJobService` whose methods **throw** — surfaces any accidental enqueue during migration. Don't enqueue from migration/seed paths.\n- A job class is a normal DI-resolved type (scope-per-job via `FshJobActivator`); inject what you need.\n","isInternal":false,"tokens":477,"sizeBytes":1911},{"name":"localization.md","path":".agents/rules/localization.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/localization.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Localization (i18n)\n\n`src/BuildingBlocks/Core/Localization/` + per-module `Localization/` folders. Read before adding any user-facing message (exception, validation, API error). **The client's culture decides which words the client reads; it never decides how the API formats numbers or dates.**\n\n## Culture negotiation (already wired — don't re-add)\n\n`AddHeroLocalization()` / `UseHeroLocalization()` (`BuildingBlocks/Web/Localization/`) negotiate the request **UI** culture in this order: `?culture=` query → `locale` JWT claim (`UserLocaleRequestCultureProvider`) → `Accept-Language` → configured default → `en-US`. Supported tags live in `SupportedCultures.Tags`. The culture is set before endpoints and the exception handler run, so any `IStringLocalizer` resolved downstream picks up the request culture automatically.\n\n**`CultureInfo.CurrentUICulture` only — `CurrentCulture` stays invariant.** An API that emits JSON must not shift `ToString()`, `Parse()` or interpolation per request; both React apps format at the presentation layer. `RequestLocalizationMiddleware` assigns both cultures unconditionally, so the culture half is pinned rather than left alone: `DefaultRequestCulture` carries `(InvariantCulture, configured default)` and `SupportedCultures` is `null` so the middleware skips culture filtering. Do **not** \"fix\" this by adding `AddSupportedCultures(...)`; `Formatting_culture_stays_invariant_while_ui_culture_negotiates` fails if you do.\n\n`SupportedCultures.Tags` is **specific tags only**, no neutrals. A request asking for a bare `pt`, or for an unsupported variant like `pt-PT`, resolves to the configured default rather than being served a language it was not translated into. The React apps canonicalize variants onto supported tags (`CANON` in `clients/*/src/i18n.ts`) before calling the API, so app traffic is unaffected; a hand-rolled client sending bare `pt` gets the default. Adding a language means: add its specific tag to `Tags`, add a `*.{tag}.resx` per catalog, add its JSON catalogs to both apps, and remove it from `CANON` if it was being folded into another tag.\n\n## Catalogs — hybrid, one marker per catalog\n\n- **Core (`SharedResources`)** — generic / cross-cutting messages: ProblemDetails titles (`Error.*`), cross-module errors (`Error.TenantContextRequired`, `Error.NoCurrentUser`, …), and shared validation (`Validation.*`).\n- **Per module (`<Module>Resources`)** — domain-specific messages owned by the module: `src/Modules/<Module>/Modules.<Module>/Localization/<Module>Resources.cs` (marker `public sealed class <Module>Resources;`) + co-located `<Module>Resources.resx` (neutral / en-US) + an optional `<Module>Resources.pt-BR.resx`. `ResourcesPath = \"\"` (co-located), so the resx manifest name must equal the marker's full type name.\n\nCatalogs are named for **specific** cultures (`.pt-BR`, never a neutral `.pt`), matching the front-end catalog folders. The neutral, un-suffixed `.resx` is the en-US / ultimate-fallback catalog.\n\nKey naming: `Error.<Module>.<Case>` for domain messages (`Catalog.ProductNotFound`), `Error.<CrossCutting>` / `Validation.<Case>` for Core. PascalCase. Placeholders are `{0}`, `{1}` (`string.Format` via the localizer) — **not** the frontend's `{{name}}`.\n\n**Placeholder arguments must be culture-insensitive.** The localizer's indexer calls `string.Format` under `CurrentCulture`, which is invariant (above). Pass `int`/`long`/`string`/enum — never a `double`, `decimal`, `DateTime` or `TimeSpan.TotalX`, which would render with an invariant separator instead of the reader's. Where a count is conceptually whole, expose it as an `int` at the source rather than converting at the call site (see `GetAuditsQueryHandler.MaxWindowDays` next to `MaxWindow`). Money and dates belong in structured response fields formatted by the client, not interpolated into a message.\n\n## Exceptions — localize at the boundary, log stays English\n\nThrow with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site.\n\n```csharp\n// domain message -> module catalog\nthrow new NotFoundException($\"Product {id} not found.\")\n{\n    MessageKey = \"Catalog.ProductNotFound\",\n    MessageArgs = [id],\n    ResourceSource = typeof(CatalogResources),\n};\n\n// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources)\nthrow new UnauthorizedException(\"Tenant context is required.\")\n{\n    MessageKey = \"Error.TenantContextRequired\",\n};\n```\n\n`GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException(\"...\")` still renders its English literal.\n\n**Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body.\n\n## Validators — inject the localizer, defer resolution\n\n```csharp\npublic sealed class XCommandValidator : AbstractValidator<XCommand>\n{\n    public XCommandValidator(IStringLocalizer<SharedResources> localizer)\n    {\n        RuleFor(x => x.Name)\n            .NotEmpty().WithMessage(_ => localizer[\"Validation.NameRequired\"]);\n    }\n}\n```\n\nAlways the `.WithMessage(_ => localizer[\"Key\"])` lambda (resolution is deferred to `Validate()`, under the request culture) — never `.WithMessage(localizer[\"Key\"])`. **Catalog choice:** inject `IStringLocalizer<SharedResources>` for genuinely shared/generic validation (`Validation.*` already in Core, reuse them), or `IStringLocalizer<<Module>Resources>` for module-specific validation messages kept in the module's own catalog. DI provides the localizer automatically (`AddValidatorsFromAssembly` + `AddHeroLocalization` + the module's own `AddLocalization`); nested validators (`Include(new PagedQueryValidator<T>(localizer))`) receive it from the parent.\n\n## Known behaviour (documented, not bugs)\n\n- **The `locale` claim lags a language switch by one token.** The culture provider reads the JWT `locale` claim, so a switch does not reach the API until the next token issue. The front-end persists the choice to the profile and re-mints, so it converges; in the window between, the shell can be in the new language while an API error still comes back in the old one. Deliberate: the alternative is a per-request DB read on every authenticated call.\n- **Impersonation carries the operator's language, not the target's.** `StartImpersonationCommandHandler` strips the target's `locale` claim so the operator keeps reading in their own language, and the cross-app handoff URL carries `locale` because the dashboard is normally on a different origin and cannot read admin's `fsh.admin.lng`. During impersonation the switcher is client-side only — it must not PUT onto the impersonated user's profile.\n- **SignalR does not carry the app locale.** The hub client builds its own requests instead of going through `apiFetch`, so `Accept-Language` on the negotiate is the browser's. Applies to every session. `handoff-locale.spec.ts` names the exception explicitly so any *other* channel that stops carrying the locale fails the test.\n\n## Tests (required with every catalog change)\n\n- **Parity** — add every new key to the neutral (English) catalog; the `pt-BR` entry is optional, and a key it lacks falls back to English at runtime, so nobody has to write Portuguese to get a green build. The build fails only on a `pt-BR` key the neutral catalog does not have (an orphan, usually a renamed or deleted key) or on a `pt-BR` value whose `{n}` placeholders differ from the English one. Applies to Core and every `<Module>Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test.\n- **Code → resx guard** — every referenced key (`MessageKey`, `localizer[\"…\"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently).\n- Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub.\n\n## Emails / background handlers\n\nIntegration-event handlers run without an HTTP request, so there is no negotiated culture. Localizing outbound emails needs the recipient's stored locale propagated to the handler — **not yet implemented** (tracked for a future PR); email bodies stay English for now.\n","isInternal":false,"tokens":1961,"sizeBytes":8760},{"name":"logging.md","path":".agents/rules/logging.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/logging.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Logging & observability\n\n`src/BuildingBlocks/Web/Observability/`. Read before adding logs, traces, or metrics.\n\n## Structured logging only\n\n**No string interpolation in log messages.** Use message templates with named placeholders, or `[LoggerMessage]` source-gen for hot paths.\n\n```csharp\n// good\n_logger.LogInformation(\"Cleaned up {Count} expired sessions for tenant {TenantId}\", count, tenantId);\n// also good (hot path) — see OutboxDispatcher, InMemoryEventBus, AppHub\n[LoggerMessage(Level = LogLevel.Warning, Message = \"Outbox message {MessageId} dead-lettered\")]\nprivate partial void LogDeadLettered(Guid messageId);\n// NEVER\n_logger.LogInformation($\"Cleaned up {count} sessions\");   // breaks structured logging + analyzers\n```\n\nBuild runs with `TreatWarningsAsErrors` — interpolated log calls won't even compile clean under analysis.\n\n## Serilog\n\n`AddHeroLogging()` reads the `Serilog` config section (Console sink by default), attaches `HttpRequestContextEnricher` (adds `RequestMethod`/`RequestPath`/`UserAgent` + `UserId`/`Tenant`/`UserEmail` when authenticated), overrides Microsoft/EF/Hangfire/Finbuckle to higher levels, and excludes the `ExceptionHandlerMiddleware` source (the global handler logs exceptions itself — don't double-log).\n\n## Correlation\n\n`X-Correlation-ID` request header (falls back to `HttpContext.TraceIdentifier`), surfaced in every ProblemDetails and pushed to the Serilog `LogContext`. `CurrentUserMiddleware` tags the current `Activity` with `fsh.user_id` / `fsh.tenant_id` / `fsh.correlation_id`.\n\n## OpenTelemetry\n\n`AddHeroOpenTelemetry()` no-ops unless `OpenTelemetryOptions.Enabled`. Metrics + traces for AspNetCore/HttpClient/Npgsql/EFCore/Redis/Runtime, plus caching + auditing meters and Mediator pipeline spans (`MediatorTracingBehavior`). Add a new meter/source name to `OpenTelemetryOptions` config, not by editing the extension.\n\n**OTLP export is auto-detected.** It turns on when **either** `Exporter.Otlp.Enabled=true` **or** the `OTEL_EXPORTER_OTLP_ENDPOINT` env var is present. Under .NET Aspire that env var is injected automatically, so traces/metrics flow to the Aspire dashboard with no config change (the SDK reads endpoint + protocol from the standard `OTEL_EXPORTER_OTLP_*` env vars; the config `Endpoint`/`Protocol` are only used when no env var is set — e.g. the docker-compose collector at `http://localhost:4317`). Plain `dotnet run` with no collector and `Enabled=false` exports nothing.\n\n**Logs ride the same OTLP detection.** Serilog owns the logging pipeline and does not forward to other `ILogger` providers, so the OTel SDK's log exporter can't see Serilog events — instead `AddHeroLogging` adds a `Serilog.Sinks.OpenTelemetry` sink under the same auto-detect rule (global `Enabled` + env-var-or-config endpoint). It also parses `OTEL_EXPORTER_OTLP_HEADERS` (the Aspire dashboard's OTLP receiver requires its `x-otlp-api-key`; the SDK reads this for traces/metrics, the Serilog sink does not) and stamps `service.name` = `OTEL_SERVICE_NAME ?? ApplicationName` so logs group under the same dashboard resource as the spans. Both `AddHeroOpenTelemetry` and the Serilog sink resolve the name that way: under an orchestrator that injects `OTEL_SERVICE_NAME` (Aspire sets it to the resource name, e.g. `fsh-starter-api`) the process adopts that identity; hardcoding the entry-assembly name (`FSH.Starter.Api`) instead would de-correlate the telemetry and list the process twice in the dashboard. Plain `dotnet run` with no env var falls back to `ApplicationName`.\n","isInternal":false,"tokens":841,"sizeBytes":3540},{"name":"auditing.md","path":".agents/rules/modules/auditing.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/auditing.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Auditing\n\nAppend-only audit trail (entity changes, security events, exceptions, HTTP activity) with async channel-buffered persistence + DLQ. Module `Order = 300`.\n\n**Entities / DbContext:** `AuditRecord`, `AuditDbContext`. `AuditEnvelope` is the in-flight event. Rich Contracts surface: `IAuditClient`, `ISecurityAudit`, `IAuditPublisher`, `IAuditSink`, `IAuditDlqSink`, `IAuditEnricher`, `NoAuditAttribute`, payload records.\n**Areas:** read-only query side — GetAudits / ByCorrelation / ByTrace / Summary / Exception / Security. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Static `Audit` fluent API** — `Audit.ForSecurity(...).WithUser(...).WriteAsync(ct)` (also `ForEntityChange`/`ForActivity`/`ForException`). Configured once at startup via `Audit.Configure(publisher, serializer, enrichers)`. Enrichers are held in a **volatile immutable array swapped atomically** — never mutate a live enricher list (it'd race the enrich loop).\n- **Two interceptors, don't confuse them:** `AuditingSaveChangesInterceptor` (this module) captures EF entity diffs → EntityChange events and **skips `AuditDbContext`** (no recursive self-audit). `AuditableEntitySaveChangesInterceptor` (BuildingBlocks) stamps audit/soft-delete fields — different file, different job.\n- **Channel-buffered, never blocks the request** — `ChannelAuditPublisher` has two lanes: default (`DropOldest` under pressure) and a **security lane that back-pressures and never drops** (login/permission/impersonation ride here). `AuditBackgroundWorker` drains both (security first), batches, writes via `IAuditSink`; on sink failure it retries then spills to `IAuditDlqSink` (file) so events survive a Postgres outage.\n- `SqlAuditSink` groups a batch by `TenantId` and sets tenant context per group in a fresh scope (null → Root) — background writer has no ambient tenant.\n- **JSON masking** redacts fields by keyword (password/secret/token/apiKey/connectionString…) → `****`. Add sensitive keys there.\n- Exclude an endpoint from activity auditing with `[NoAudit]` / the `NoAudit` endpoint extension.\n- **Entity diffs mask sensitive values** in `EntityDiffBuilder` (property name contains password/secret/token/apikey/connectionstring/securitystamp → `****`, null stays null). `[NoAudit]` does **not** affect entity diffs — to keep an entity's values out of `AuditRecords` entirely, implement `IAuditExempt` (Contracts) on the entity.\n","isInternal":false,"tokens":579,"sizeBytes":2438},{"name":"billing.md","path":".agents/rules/modules/billing.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/billing.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Billing\n\nPlans, subscriptions, usage metering, monthly invoicing. **Manual payment marking — no payment provider.** Module `Order = 500`.\n\n**Entities / DbContext:** `BillingPlan`, `Subscription`, `Invoice` (+ `InvoiceLineItem`), `UsageSnapshot`. **`BillingDbContext : DbContext`** (NOT `BaseDbContext`) — billing lives in the main DB with an explicit `TenantId` column for cross-tenant admin visibility, filtered in query services. Contracts = DTOs; `IBillingService`/`IUsageReporter` are internal.\n**Areas:** Plans, Subscriptions, Invoices (generate/issue/mark-paid/void), Usage (capture/get). Monthly invoice job (`5 0 1 * *`). Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **`BillingPlan` is `IGlobalEntity`** — platform-wide catalogue rows, **not tenant-scoped** (opts out of tenant isolation). A plan's `Key` matches the quota config key (e.g. `\"pro\"`): limits come from `QuotaOptions`, prices/overage from the plan.\n- **`BillingDbContext` is a plain `DbContext`** — tenant filtering is done explicitly in query services, not by the `BaseDbContext` auto-filter. Don't assume the global tenant filter applies here.\n- **Invoice state machine** — `Draft → Issued → Paid | Void`. Line items only addable in Draft; a Paid invoice can't be voided; totals recompute on add; Issue defaults due = +14 days.\n- **Usage metering is idempotent** — `IUsageReporter.CaptureForPeriodAsync` reads `IQuotaService` and persists one `UsageSnapshot` per `QuotaResource` per (tenant, period), so invoicing math is reproducible even after a mid-period plan change.\n","isInternal":false,"tokens":393,"sizeBytes":1582},{"name":"catalog.md","path":".agents/rules/modules/catalog.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/catalog.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Catalog\n\nProduct catalog — products, categories (tree), brands — with soft-delete/restore/trash and search. Module `Order = 600`. This is the **reference module** for the soft-delete + image patterns; copy from here.\n\n**Entities / DbContext:** `Product` (aggregate, soft-deletable) + `ProductImage`, `Brand`, `Category` (self-referencing tree), `Money` (owned value object). `CatalogDbContext`. Domain events (`ProductCreated`/`PriceChanged`/`StockAdjusted`) are **internal**, not integration events.\n**Areas:** Products (+ price/stock/images), Categories (+ tree), Brands — each with Create/Update/Delete/Search/ListTrashed/Restore. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas / patterns to copy\n\n- **Soft-delete + restore + trashed-listing** is the standard pattern. Unique indexes are **filtered on `\"IsDeleted\" = FALSE`** so SKU/Slug stay unique-per-tenant among *live* rows only (a deleted SKU can be reused). Replicate this on any soft-deletable unique field.\n- **EF value-generation for nav children** — `ProductImageConfiguration` sets `Id.ValueGeneratedNever()` (same nav-child footgun as Chat — see `database.md`).\n- **Single-thumbnail invariant** is enforced by the **aggregate**, not a partial unique index (Postgres non-deferrable partial unique indexes can't handle the demote/promote ordering in one transaction). Enforce such invariants in the domain.\n- Registers `ProductFileAccessPolicy` (OwnerType `\"Product\"`) for product images via the Files module.\n- Route ordering: literal segments (`/trash`, `/tree`, `/restore`) are registered **before** `/{id:guid}` catch-alls.\n","isInternal":false,"tokens":379,"sizeBytes":1617},{"name":"chat.md","path":".agents/rules/modules/chat.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/chat.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Chat\n\nSlack-style messaging: 1:1 DMs, group DMs, named channels, threads, reactions, mentions, pins. Module `Order = 800` (after Notifications 750, so Notifications' handlers register first).\n\n**Entities / DbContext:** `ChatChannel` (aggregate, soft-deletable) + `ChannelMember`; `Message` (aggregate) + `MessageAttachment`/`MessageMention`/`MessageReaction`. `ChatDbContext : BaseDbContext`, schema `chat`. Publishes `MentionedInChannelIntegrationEvent`.\n**Areas:** Channels, Messages (incl. pin/edit/delete/threads), Reactions, Search. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **EF value-generation for nav children** — `MessageConfiguration` sets `Property(x => x.Id).ValueGeneratedNever()` for child collections (attachments/mentions/reactions). The domain assigns `Guid.CreateVersion7()` in factories; without this EF treats nav-collection children as `Modified` → 0-row UPDATE instead of INSERT. See `database.md`.\n- `ChatDbContext` calls **`base.OnModelCreating` LAST** so tenant auto-apply sees the configured child types.\n- **`ChannelAuthorization`** (`Features/v1/Internal/`): `RequireMember` throws **NotFound (404)** (not 403) so non-members can't probe channel existence; `RequireAdmin` throws `ForbiddenException`. Use these in every channel/message handler.\n- **SignalR via `IHubContext<AppHub>`** (the shared hub in BuildingBlocks), groups `channel:{id}`. The hub reads the user via `Context.User`, not `ICurrentUser` (see `realtime.md`). Chat registers `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups.\n- SendMessage publishes `MentionedInChannelIntegrationEvent` **per distinct mentioned user**; Notifications consumes it.\n- DMs use a sorted `DirectKey` (`\"{lo}:{hi}\"`) for find-or-create; **threads are single-level only**.\n- Chat attachments register `ChatChannelFileAccessPolicy` (OwnerType `\"ChatChannel\"`): attach/read require membership, delete is uploader-only (see `modules/files.md`).\n","isInternal":false,"tokens":474,"sizeBytes":1996},{"name":"files.md","path":".agents/rules/modules/files.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/files.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Files\n\nPresigned-URL file lifecycle (upload → finalize → serve → delete) shared by Catalog images, Chat attachments, avatars. Module `Order = 350` (loads before consumer modules).\n\n**Entities / DbContext:** `FileAsset` (aggregate, soft-deletable): status `PendingUpload → Available | Quarantined`, `Visibility` (Public/Private), `ScanStatus`. `FilesDbContext`. Publishes `FileFinalizedIntegrationEvent`.\n**Areas:** RequestUploadUrl, FinalizeUpload, GetFileDownloadUrl/Metadata, ChangeVisibility, Delete/Restore, ListMy/Shared/Trashed. Purge jobs (orphaned hourly, deleted daily). Full list: `Features/v1/` or `/scalar`. Storage mechanics: `storage.md`.\n\n## Gotchas\n\n- **Presigned flow** — never stream uploads through the API. RequestUploadUrl validates category/extension/size + quota **pre-check** and persists a `PendingUpload`; client uploads directly to storage; **FinalizeUpload debits the quota** (not at request time) and flips to Available/Quarantined.\n- **`FileAccessPolicyRegistry`** resolves `IFileAccessPolicy` by **OwnerType** — case-insensitive, **closed by default** (unknown OwnerType → forbidden), **last-write-wins** on duplicates (intentional, for test substitution). Each owning module registers its own policy in its `ConfigureServices` (Catalog/Tickets load after Files). Files ships `DefaultUploaderOnlyPolicy` for built-in OwnerTypes `\"MyFiles\"`/`\"User\"`.\n- **Visibility is in the storage key** (`public/…` vs `private/…`, built only by `StorageKeyBuilder`). Changing visibility moves the object via `FileStorageRelocator` and publishes `FileStorageKeyChangedIntegrationEvent`; a module that stores a FileAsset's public URL must handle it. `MigrateLegacyPublicFileKeysJob` (enqueued on each start) moves pre-#1410 public files. See `storage.md`.\n- `CanChangeVisibilityAsync` defaults to the delete rule (uploader-only); domain-bound files (e.g. product images) may override to forbid visibility flips.\n- Tenant scoping is implicit via `BaseDbContext` (no explicit `TenantId` on `FileAsset`).\n\nTo support uploads for a new owner type: implement `IFileAccessPolicy`, register it in the owning module, and use that OwnerType in RequestUploadUrl.\n","isInternal":false,"tokens":515,"sizeBytes":2196},{"name":"identity.md","path":".agents/rules/modules/identity.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/identity.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Identity\n\nAuth (JWT + ASP.NET Identity), users, roles, permissions, sessions, impersonation, 2FA.\n\n## Service shape\n\n`IUserService` is a **facade** that delegates to focused single-responsibility services — change behavior in the specific service, not the facade:\n\n| Interface | Concern |\n|---|---|\n| `IUserRegistrationService` | register, external-principal create, email/phone confirm |\n| `IUserProfileService` | get/list/count, update profile, image, existence checks |\n| `IUserStatusService` | activate/deactivate (`DeleteAsync` == deactivate), audited toggles |\n| `IUserRoleService` | role assignment, admin-role guards |\n| `IUserPasswordService` | forgot/reset/change password, history + expiry |\n| `IUserPermissionService` | effective permissions, cache invalidation |\n\n`ChangePassword`/`Update`/`Delete` etc. flow facade → service → EF/UserManager. `CancellationToken` is `= default` on these interfaces and propagated into EF sinks (note: `UserManager`/`RoleManager` have no CT overloads, so private helpers that only call them don't take one).\n\n## Permission gating footgun\n\n`RequiredPermissionAttribute` implements `FSH.Framework.Shared.Identity.Authorization.IRequiredPermissionMetadata`. **Never let a second/duplicate `IRequiredPermissionMetadata` appear** — it silently disables **all** `.RequirePermission()` gates across the app. Permission constants live in `Shared/Identity/*Permissions.cs`.\n\n## Hosted services (background)\n\n- `RolePermissionSyncHostedService` — best-effort sync of the permission catalog; loops, catches `Exception` *with* an `OperationCanceledException` filter, logs and continues.\n- `SessionCleanupHostedService` — hourly expired-session purge; OCE handled by a preceding catch.\n\nThese are the model for background loops: stay alive, log with context, never swallow cancellation. See `api-conventions.md`.\n\n## Tokens / sessions\n\nLogin `POST /api/v1/identity/token/issue` (header `X-FSH-App` enforces the operator/tenant app boundary). Refresh `POST /api/v1/identity/token/refresh` cross-checks subject. Session rows are written best-effort during login — failures log a warning and login still succeeds. Admin can't demote/deactivate the last admin or the root-tenant seed admin (guards in `UserRoleService`/`UserStatusService`).\n\n## Tests\n\n`Identity.Tests` is the largest unit suite. When asserting a forwarded `CancellationToken`, assert the specific token (see `testing.md`).\n","isInternal":false,"tokens":549,"sizeBytes":2443},{"name":"multitenancy.md","path":".agents/rules/modules/multitenancy.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/multitenancy.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Multitenancy\n\nTenant catalog, provisioning, activation/upgrade, per-tenant theming (Finbuckle.MultiTenant). Foundational — registered early.\n\n**Entities / DbContext:** `AppTenantInfo` (catalog), `TenantProvisioning` + `TenantProvisioningStep`, `TenantTheme`. `TenantDbContext` holds the tenant catalog in the main DB.\n**Areas:** CreateTenant, ChangeTenantActivation, UpgradeTenant, Get(Tenants/Status/Migrations), TenantProvisioning (status/retry), TenantTheme (get/update/reset). Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Finbuckle pipeline ordering** — strategy chain Claim → Header → `?tenant=` → DistributedCache → EFCoreStore. `UseMultiTenant()` runs **before `UseAuthentication()`**, but Finbuckle's `ClaimStrategy` runs the default auth scheme's handler itself, so an authenticated caller resolves to its **tenant claim** and a `tenant` header cannot override it. The header (`MultitenancyConstants.Identifier`) decides only for anonymous requests (login, refresh); the root-operator override is post-auth middleware in `MultitenancyModule.ConfigureMiddleware`.\n- **Root-operator cross-tenant override is a post-auth middleware** in `ConfigureMiddleware` (not a Finbuckle strategy). Gate: caller's JWT tenant claim == `MultitenancyConstants.Root.Id` **and** a `tenant` header != root; it re-resolves via `IMultiTenantContextSetter`. Claim-aware tenant logic must go here, never in a strategy.\n- **`ITenantInitialPasswordBuffer`** (singleton) — the tenant admin password is **operator-supplied**, not a constant. `CreateTenantCommandHandler` calls `Store(tenantId, password)` **before** kicking off provisioning; the background seed step `TryConsume`s it (`ConcurrentDictionary`, consume = remove).\n- **Provisioning** runs 4 steps (Database → Migrations → Seeding → CacheWarm) via a Hangfire `TenantProvisioningJob`, falling back to inline execution if Hangfire storage is unavailable. **Activation is gated on `Status == Completed`.**\n- `ITenantService.MigrateTenantAsync`/`SeedTenantAsync` create a fresh scope and set `IMultiTenantContext` **first**, then run the `IDbInitializer`s.\n\nTenant **isolation** mechanics (default-on filter, `IGlobalEntity` opt-out, `base.OnModelCreating` last) live in `database.md`.\n","isInternal":false,"tokens":524,"sizeBytes":2266},{"name":"notifications.md","path":".agents/rules/modules/notifications.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/notifications.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Notifications\n\nPer-user in-app inbox (bell icon) driven by cross-module integration events, with live SignalR push. Module `Order = 750` (**before Chat 800** so its handlers are registered before Chat publishes).\n\n**Entities / DbContext:** `Notification` (aggregate: `UserId`, `Type`, `Title`/`Body`/`Link`, `Source`, `MetadataJson`, `ReadAtUtc`). `NotificationsDbContext`. Consumes integration events from other modules (e.g. Chat's `MentionedInChannelIntegrationEvent`).\n**Areas:** List, GetUnreadCount, MarkRead, MarkAllRead. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **It's a consumer.** New notification types come from **handling another module's integration event** (`AddIntegrationEventHandlers`), not from new endpoints. The handler writes an inbox row **and** pushes `\"NotificationCreated\"` to SignalR group `user:{userId}` via `IHubContext<AppHub>`.\n- **Order matters** — Notifications (750) must load before any publisher whose events it consumes (Chat 800). If a new module publishes events Notifications should react to, mind the `Order`.\n- In-memory bus runs handlers **synchronously in the publisher's request scope** — keep the handler minimal; an exception surfaces to the originating request. See `eventing.md`.\n- Inbox rows are **denormalized** (Title/Body/Link/MetadataJson copied in) so rendering never calls back into the source module. `MarkRead` is idempotent (`ReadAtUtc ??= now`).\n","isInternal":false,"tokens":339,"sizeBytes":1435},{"name":"tickets.md","path":".agents/rules/modules/tickets.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/tickets.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Tickets\n\nSupport ticket lifecycle with comments. Module `Order = 700`.\n\n**Entities / DbContext:** `Ticket` (aggregate, soft-deletable, state machine) + `TicketComment`. `TicketsDbContext`. `TicketStatus`/`TicketPriority` enums in Contracts; domain events internal.\n**Areas:** Create, Assign, Resolve, Reopen, Restore, AddComment, ListComments, GetById, Search, ListTrashed. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **State machine** (`Domain/Ticket.cs`): `Open → InProgress → Resolved → Closed`. Illegal transitions throw **`CustomException` with `HttpStatusCode.Conflict` (409)** — not a generic 400. Assigning auto-starts (Open→InProgress); unassigning an InProgress ticket reverts to Open; creating with an assignee starts at InProgress. Closed tickets reject comments/resolve until reopened. Keep all transition guards in the aggregate.\n- Soft-delete/restore/trash pattern is identical to Catalog (filtered unique indexes — see `modules/catalog.md`).\n- Endpoints are mapped on the bare `api/v{version}` group (no `/tickets` sub-path); literal routes precede `{ticketId:guid}`.\n","isInternal":false,"tokens":264,"sizeBytes":1117},{"name":"webhooks.md","path":".agents/rules/modules/webhooks.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/modules/webhooks.md","title":"Modules Skill","category":"anthropic-skill","format":"markdown","content":"# Module: Webhooks\n\nTenant-scoped outbound webhook subscriptions with HMAC-signed delivery and retries. Module `Order = 400`.\n\n**Entities / DbContext:** `WebhookSubscription` (`Url`, `EventsCsv`, `SecretHash`, `IsActive`), `WebhookDelivery` (per-attempt log). `WebhookDbContext` (tenant-filtered). Contracts expose **DTOs only** — `IWebhookDispatcher`/`IWebhookDeliveryService` are internal.\n**Areas:** Create/Delete/Get subscriptions, GetDeliveries, Test. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Fan-out is an open-generic handler** — `WebhookFanoutHandler<TEvent>` is registered as an open generic, so it handles **every** `IIntegrationEvent` with no per-event wiring. It skips events with null `TenantId` (subscriptions are tenant-only) and matches event-type name against each subscription's `EventsCsv` (`*` wildcard supported).\n- **Restore tenant context in the background** — both the fan-out handler and `WebhookDispatchJob` set `IMultiTenantContext` in a fresh scope before reading the tenant-filtered DbContext (background pumps/Hangfire carry no HTTP context). This is the canonical pattern for any background reader of tenant data — see `eventing.md`, `jobs.md`.\n- **HMAC signing** — `X-Webhook-Signature: sha256=<hex HMACSHA256>` (`WebhookPayloadSigner.Sign`), plus `X-Webhook-Event` and `X-Webhook-Delivery-Id` headers.\n- **Delivery** — `WebhookDispatcher.EnqueueAsync` enqueues a Hangfire `WebhookDispatchJob` per subscription; `[AutomaticRetry(Attempts=4, DelaysInSeconds={30,120,600,3600})]`. Transient (5xx/408/429) throws to reschedule; permanent 4xx completes silently. Each attempt persists a `WebhookDelivery` row. The `\"Webhooks\"` HttpClient uses `AddHeroResilience` (see `resilience.md`).\n","isInternal":false,"tokens":442,"sizeBytes":1742},{"name":"realtime.md","path":".agents/rules/realtime.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/realtime.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Realtime — SignalR & SSE (backend)\n\n`src/BuildingBlocks/Web/Realtime/` + `Sse/`. For the frontend side see `frontend/shared.md` + `frontend/dashboard.md`.\n\n## SignalR (`AppHub`)\n\n`[Authorize] AppHub` mapped at **`/api/v1/realtime/hub`**. Groups: `user:{userId}`, `tenant:{tenantId}`, `channel:{channelId}`.\n\n- **Channel-group join is connect-time + on-demand.** `OnConnectedAsync` auto-joins `user:{id}`, `tenant:{id}`, and every `channel:{id}` the user is *already* a member of. A channel that becomes relevant **after** the socket is live (a new DM, or being added to a channel) is **not** auto-joined — the client must call the membership-gated **`JoinChannel(channelId)`** hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs `OnConnectedAsync`. New-DM creation pushes `ChatChannelAdded` to each other participant's `user:{id}` group so their channel list refreshes.\n- **⚠️ Read the user from `Context.User`, NOT `ICurrentUser`.** `ICurrentUser` flows through `IHttpContextAccessor`, but the negotiate `HttpContext` isn't pinned to subsequent hub invocations → `ICurrentUser` returns nulls inside the hub. Use `Context.User` (the hub's `GetUserId()`/`GetTenantId()` helpers).\n- Broadcasts are **scoped to groups** (`tenant:{id}`, `user:{id}`, `channel:{id}`), never `Clients.All`. `PresenceChanged` goes to the tenant group.\n- Redis backplane is added automatically when `CachingOptions:Redis` is set (channel prefix `fsh-signalr`) — required for multi-replica.\n- Push to a user from a module via `IHubContext<AppHub>` to group `user:{userId}` (e.g. Notifications' `\"NotificationCreated\"`).\n- `IPresenceTracker` (in-memory, **per-host** — single-replica only for presence). Modules supply `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups without depending on Chat.\n\n## SSE (`Web/Sse/`) — two-step token\n\nEventSource can't send `Authorization`, so SSE uses a token handshake:\n1. `POST /api/v1/sse/token` (authorized) → opaque Guid, **single-use, 30s TTL** in `IDistributedCache`.\n2. `GET /api/v1/sse/stream?token={guid}` (anonymous, consumes the token) → `text/event-stream`, `X-Accel-Buffering: no`, 15s heartbeat.\n\n`SseConnectionManager` (singleton, `ConcurrentDictionary`, bounded channel cap 100 `DropOldest`): `TrySend(userId)` (all tabs), `Broadcast(tenantId)`, `BroadcastAll()`.\n\n## Tests\n\nSignalR hub tests force **long-polling** (TestServer has no WebSocket). See `integration-testing.md`.\n","isInternal":false,"tokens":677,"sizeBytes":2580},{"name":"resilience.md","path":".agents/rules/resilience.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/resilience.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# HTTP resilience\n\n`src/BuildingBlocks/Web/HttpResilience/`. Uses `Microsoft.Extensions.Http.Resilience` (Polly v8).\n\n## Pattern — opt-in per HttpClient\n\n`AddHeroResilience(config)` is an `IHttpClientBuilder` extension that adds `AddStandardResilienceHandler` configured from `HttpResilienceOptions` (retry, total-request timeout, attempt timeout, circuit breaker). It is **NOT global** — chain it onto the specific outbound client that needs it:\n\n```csharp\nbuilder.Services.AddHttpClient(\"Webhooks\", ...)\n    .AddHeroResilience(builder.Configuration);\n```\n\nDefaults (`HttpResilienceOptions`): 3 retries, 30s total, 10s per attempt, 50% failure ratio, throughput 10. No-ops when `Enabled=false`.\n\n## Notes\n\n- Only outbound integrations need this. The only current caller is the Webhooks delivery client (`WebhooksModule.cs`). Add it to any new typed/named `HttpClient` that calls a flaky external service.\n- For internal ret/timeout of *background* work, prefer Hangfire's `[AutomaticRetry]` on the job (see `modules/webhooks.md`) — that's durable across restarts; the resilience handler only covers the in-flight HTTP call.\n","isInternal":false,"tokens":270,"sizeBytes":1131},{"name":"security.md","path":".agents/rules/security.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/security.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Web security & request governance\n\nCORS, security headers, rate limiting, idempotency, quota enforcement. `src/BuildingBlocks/Web/` + `Quota/`.\nFor auth/JWT/permissions see `modules/identity.md`; for the global exception handler see `api-conventions.md`.\n\n## CORS (`Web/Cors/`) — the SignalR gotcha\n\nPolicy `FSHCorsPolicy`. When `CorsOptions.AllowAll=true` it uses **`SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod().AllowCredentials()`** — deliberately **NOT `AllowAnyOrigin()`**. `Access-Control-Allow-Origin: *` is illegal with credentialed requests, and **SignalR's negotiate always runs credentialed**, so `AllowAnyOrigin()` silently breaks SignalR while REST keeps working. Never \"simplify\" it to `AllowAnyOrigin()`. `UseHeroCors()` runs **before** `UseHttpsRedirection()` so OPTIONS preflight isn't 307-redirected.\n\n## Front-end origin for outbound links (`Web/Frontend/`)\n\n`IFrontendOriginResolver` builds the origin of user-facing links sent in e-mails (password reset, e-mail confirmation). Backed by **`FrontendOptions`**, deliberately separate from `CorsOptions`: CORS governs which browsers may *call* the API, this governs which origins may appear *inside an outbound link*. Never merge the two lists — coupling them breaks same-origin/reverse-proxy topologies and overloads a security boundary.\n\n- **`ResolveForCurrentRequest()`** — self-service flows only (forgot-password, self-register), where the caller *is* the recipient. Validates the request `Origin` against `FrontendOptions:AllowedOrigins` and returns the **canonical configured entry**, never the client's string. Present-but-unlisted against a non-empty list → `CustomException(HttpStatusCode.BadRequest)`: these endpoints are anonymous, so a forged header must never reach an e-mail. No `Origin` header, or an empty/all-unparseable list, falls through to the default.\n- **`ResolveDefault()`** — operator-driven flows (admin register, resend-confirmation) and background jobs, where the caller is **not** the recipient. Chain: `FrontendOptions:DefaultOrigin` → throw. Never the caller's `Origin`, or an operator would send a tenant user a link into the admin console; and never the request's `Host` either, which is the same string under a different name. There is deliberately no fallback to `OriginOptions:OriginUrl`: these links address SPA routes (`/confirm-email`, `/reset-password`), so the API's own origin would 404 them.\n\nPicking the wrong method is a silent bug — both compile and both return a plausible origin. Match the method to **who receives the link**, not to who sent the request.\n\nNo `ValidateOnStart` on `FrontendOptions`, on purpose: a deployment that never sends such a link must not be taken down by the setting. `UseHeroPlatform` logs at startup instead — `Warning` for an empty or all-typo allow-list (counted after normalization, so a list of nothing but typos reports as the empty list it effectively is), `Error` for a missing `DefaultOrigin`, since without it those flows return 500 rather than degrading. `OriginOptions:OriginUrl` keeps its own job — the API's own public base for back-end-served assets (avatars), read through `IRequestContext.Origin`.\n\n## Security headers (`Web/Security/`)\n\n`UseHeroSecurityHeaders()` sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, HSTS (HTTPS), and a CSP. `SecurityHeadersOptions.ExcludedPaths` defaults to `[\"/scalar\",\"/openapi\"]` (they manage their own scripts) — keep those excluded.\n\n## Rate limiting (`Web/RateLimiting/`)\n\nChained partitioned fixed-window limiter: **tenant → user → IP** (defaults 1000 / 200 / 300 per 60s) + a stricter named `\"auth\"` policy (10/60s). Health paths are unlimited. Rejection → 429 + ProblemDetails + `Retry-After`. `RateLimitingOptions.Enabled` is read **eagerly** — when false the middleware is skipped entirely (tests set it via env var before host build).\n\n## Idempotency (`Web/Idempotency/`)\n\nOpt-in per endpoint with **`.WithIdempotency()`** on **authenticated** POST/PUTs that must be replay-safe (e.g. CreateTenant). Reads `Idempotency-Key` (max 128 chars, `DefaultTtl` 24h); a replay returns the cached status + body + `Location`/`ETag` and `Idempotency-Replayed: true`.\n\n- **Key scope:** resolved tenant (never the raw `tenant` header) + operation (method + route pattern) + route values + caller (`GetUserId()`, else `\"anon\"`), via `CacheKeys.IdempotencyEntry`.\n- **Only 2xx is stored**, before the body is sent, on `CancellationToken.None`. Probe and write must share one `IDistributedCache` + key + serializer, or replay silently never engages.\n- **Concurrent duplicates:** in-flight reservation under a `lock:` prefix (Redis `SET NX`, else in-process, `ReservationTtl` default 1m); cache re-probed after the lock; duplicate in flight → **409** + `Retry-After: 1`. Probe, reserve and release all fail open.\n- **`RequestAborted` is detached** while the handler runs, so a client disconnect can't cancel a committed side effect. Keep these handlers short; **never** on streaming or large-file endpoints (the response is buffered).\n- **Never on `AllowAnonymous()`** endpoints — all anonymous callers share `\"anon\"`. `IdempotencyWiringTests` fails the build if one appears.\n\n## Quota enforcement (`Quota/`)\n\n`QuotaEnforcementMiddleware` charges 1 `ApiCalls` unit per request via `CheckAndRecordAsync`; over-limit → 429 + ProblemDetails + `Retry-After`, and sets `HttpContext.Items[QuotaRejected]` so auditing can tag it. Resources: `ApiCalls` (counter), `StorageBytes`, `Users`, `ActiveFeatureFlags` (gauges). Skips health/metrics, unresolved tenants, and the root tenant. **Pipeline:** runs after auth (needs tenant) and after the rate limiter. Inject `TimeProvider` (not `DateTimeOffset.UtcNow`) for any time math here — the subsystem is `TimeProvider`-based.\n\n`IQuotaService`: `CheckAsync` (no mutation), `RecordAsync` (increment), `CheckAndRecordAsync` (atomic — won't increment past the limit). Store: Redis (`RedisQuotaService`) or per-process `InMemoryQuotaService` (dev/test). `NoopQuotaService` when disabled.\n","isInternal":false,"tokens":1484,"sizeBytes":6093},{"name":"storage.md","path":".agents/rules/storage.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/storage.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Storage & file uploads\n\n`src/BuildingBlocks/Storage/`. Read before working with files/blobs.\n\n## `IStorageService`\n\n`UploadAsync<T>(FileUploadRequest, FileType, ct)`, `RemoveAsync(path, ct)`, `DownloadAsync`, `ExistsAsync`, `GetSizeAsync` (0 if absent), `GenerateUploadUrlAsync`/`GenerateDownloadUrlAsync` (presigned), `HeadObjectAsync`, `BuildPublicUrl(key)→string` (string, not Uri — local storage returns a server-relative path).\n\n`CopyAsync(source, destination, ct)` is a server-side copy that **throws** on failure (unlike `RemoveAsync`) — callers move objects as copy → update the reference → delete. The quota decorator charges the copy, so a move is net-zero.\n\n## Visibility lives in the key\n\nBucket policies in every stack (Aspire bootstrap, compose `rustfs-init`, Terraform `app_s3_public_read_prefix` + the CloudFront read statement) grant anonymous `s3:GetObject` on **`public/*` only** (`StorageVisibilityRoot`). So:\n- Files keys are `{public|private}/tenants/{tenant}/{owner}/{yyyy}/{MM}/{id:N}/{name}` — `StorageKeyBuilder` (Files module) is the **one** place that decides; never hand-build a key.\n- `S3StorageService.UploadAsync<T>` always returns a public URL, so it writes under `public/uploads/…`.\n- Moves go through `FileStorageRelocator.ApplyAsync`, never by hand: it takes the file's row lock (`SELECT … FOR UPDATE`, PostgreSQL), re-reads the row, then copy → commit row + `FileStorageKeyChangedIntegrationEvent` → delete old. The old object is deleted only when no row references it; the first attempt runs right after the commit, and Files' own `DeleteSupersededObjectHandler` retries it via the outbox until it is really gone (so a Public → Private flip can't leave a public copy behind).\n- Modules that persist a public URL must handle that event and use `ResolveUrl` (it returns the API's current `NewPublicUrl` for public files) — Catalog (product images) and Identity (avatars) do.\n- Keys from before #1410 (`tenants/…`, `uploads/…`) have no root: `MigrateLegacyPublicFileKeysJob` (enqueued via `IJobService` on every API start; keyset-paginated; retried by Hangfire) moves public FileAssets under `public/`; legacy private ones stay put. It reads the partial index `IX_FileAsset_LegacyKey`, which is empty once done, so repeat runs are nearly free. `FinalizeUpload` relocates uploads that were presigned before the upgrade. Legacy `UploadAsync<T>` objects (`uploads/…`) are **not** migrated.\n- `QuotaMeteredStorageService.RemoveAsync` refunds only when the object is gone afterwards (providers swallow delete errors).\n- With `Storage:S3:Prefix` set, keys become `{Prefix}/public/…` — the policy must match that, and the prefix must **not** be `public` or `private`.\n- The **Local** provider serves everything under wwwroot: it does not enforce `private/` at all. Use it for dev only.\n\n`FileType`: `Image` (5MB), `Document`, `Pdf` (10MB) — `FileTypeMetadata.GetRules` enforces extension + size. **Always propagate `CancellationToken`.**\n\n## Providers\n\n`AddHeroStorage(config)` reads `Storage:Provider` **eagerly at registration**: `\"s3\"` → `S3StorageService` (supports any S3-compatible store, e.g. RustFS, via `ServiceUrl` + `ForcePathStyle`), else `LocalStorageService`. When quotas are enabled the service is wrapped in `QuotaMeteredStorageService` (debits `StorageBytes`).\n\n`Storage:S3:PresignServiceUrl` (optional, validated at startup as an absolute http(s) URL): presigned PUT/GET URLs are signed for this host through a second, keyed `IAmazonS3` (`S3StorageService.PresignClientKey`); all real I/O stays on `ServiceUrl`. Needed when the API reaches the store on an internal address browsers can't resolve (compose: `http://rustfs:9000`). A test factory that re-registers the S3 stack must register that keyed client too (see `FshWebApplicationFactory`).\n\n## Presigned upload flow (preferred for user uploads)\n\nDon't stream large files through the API. The pattern (see Files module):\n1. `RequestUploadUrl` — server validates category/extension/size + quota pre-check, returns a presigned PUT URL, persists a `PendingUpload` record.\n2. Client uploads **directly** to storage.\n3. `FinalizeUpload` — flips to `Available`, **debits the quota here** (not at request time), publishes `FileFinalizedIntegrationEvent`.\n\nLocal/dev without an S3 store uses `LocalPresignTokenStore` (in-memory one-shot tokens).\n\n## Test gotcha\n\n`AddHeroStorage` reads `Storage:Provider` **before** a test factory's in-memory config overlay applies, so it wires `LocalStorageService`. Integration tests that need object storage must **remove the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-register the S3 stack** pointed at the RustFS container (see `FshWebApplicationFactory`). See `integration-testing.md`.\n","isInternal":false,"tokens":1172,"sizeBytes":4815},{"name":"testing.md","path":".agents/rules/testing.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/rules/testing.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"# Testing conventions\n\nRead before writing or changing tests.\n\n## Stack\n\nxUnit · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For<IService>()`) · AutoFixture (`_fixture.Create<T>()`) · NetArchTest (architecture rules) · Testcontainers (integration).\n\n## Naming & shape\n\n- Method name: `MethodName_Should_ExpectedBehavior_When_Condition`.\n- Arrange-Act-Assert, grouped with `#region` (Happy Path / Exception / Edge Cases).\n- Assert on observable behavior. When verifying a forwarded `CancellationToken`, assert the **specific** token (`Received(1).XAsync(arg, ct)`), not the implicit default — NSubstitute fills optional params with `default`, so `Received(1).XAsync(arg)` silently asserts `CancellationToken.None`.\n\n## Test projects (`src/Tests/`)\n\n| Project | Scope | Docker? |\n|---|---|---|\n| `{Module}.Tests` | Unit: handlers, services, domain | no |\n| `Framework.Tests`, `Generic.Tests`, `Caching.Tests` | BuildingBlocks units | no |\n| `Architecture.Tests` | NetArchTest: module boundaries + tenant-isolation rules + handler↔validator pairing | no |\n| `Integration.Tests` | `WebApplicationFactory` over real PostgreSQL/Redis/RustFS | **yes** |\n| `Integration.Middleware.Tests` | Real middleware wiring | **yes** |\n\n```bash\ndotnet test src/FSH.Starter.slnx                 # all (integration needs Docker)\ndotnet test src/Tests/{Module}.Tests             # one project\ndotnet test --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\n\nIf Docker is down, integration tests fail fast with `DockerUnavailableException` — that is environmental, not a code regression. Run the unit projects to validate logic.\n\n## Architecture tests (must stay green)\n\n- Modules reference other modules only via `.Contracts`.\n- Tenant-isolation rules on entities.\n- Every command handler + paginated query handler has a `{Name}Validator` (`HandlerValidatorPairingTests`). Validator names accepted: `{Cmd}Validator`, `{Name}CommandValidator`, `{Name}Validator`.\n\n## Integration-test gotchas\n\n- Set the Finbuckle tenant context **inline** in the test method (AsyncLocal — an awaited helper loses it → NRE in the tenant filter).\n- `AddHeroStorage` reads config eagerly; rewire `IStorageService` **after** registration in the factory.\n- SignalR hub tests force **long-polling** (TestServer has no WebSocket).\n\n## Frontend tests\n\nPlaywright, route-mocked (no real backend) — see `frontend/shared.md`. `cd clients/{app} && npm run test:e2e`.\n","isInternal":false,"tokens":577,"sizeBytes":2465},{"name":"SKILL.md","path":".agents/skills/add-entity/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/add-entity/SKILL.md","title":"add-entity","category":"anthropic-skill","format":"markdown","content":"---\nname: add-entity\ndescription: Add a domain entity/aggregate with EF configuration and a migration to an existing FSH module. Use when adding a new database-backed entity. Pairs with add-feature and create-migration.\nargument-hint: \"[ModuleName] [EntityName]\"\n---\n\n# Add Entity\n\nRich domain model: `sealed` aggregate, private EF ctor, static factory, behavior via methods, domain\nevents. DB conventions: `.agents/rules/database.md`.\n\n## Entity — `AggregateRoot<Guid>` (or `BaseEntity<Guid>`)\n\n`BaseEntity<TId>` gives only `Id` + domain-event machinery. Audit/tenant/soft-delete are **opt-in via\nmarker interfaces** (the base does NOT carry those fields). New ids use **`Guid.CreateVersion7()`**.\n\n```csharp\npublic sealed class {Entity} : AggregateRoot<Guid>, IHasTenant, IAuditableEntity, ISoftDeletable\n{\n    public string Name { get; private set; } = default!;\n    public Money Price { get; private set; } = default!;\n\n    // IHasTenant\n    public string TenantId { get; private set; } = default!;\n    // IAuditableEntity\n    public DateTimeOffset CreatedOnUtc { get; set; }\n    public string? CreatedBy { get; set; }\n    public DateTimeOffset? LastModifiedOnUtc { get; set; }\n    public string? LastModifiedBy { get; set; }\n    // ISoftDeletable\n    public bool IsDeleted { get; set; }\n    public DateTimeOffset? DeletedOnUtc { get; set; }\n    public string? DeletedBy { get; set; }\n\n    private {Entity}() { }   // EF\n\n    public static {Entity} Create(string name, Money price)\n    {\n        ArgumentException.ThrowIfNullOrWhiteSpace(name);\n        ArgumentNullException.ThrowIfNull(price);\n\n        var entity = new {Entity} { Id = Guid.CreateVersion7(), Name = name.Trim(), Price = price };\n        entity.AddDomainEvent(DomainEvent.Create((id, ts) =>\n            new {Entity}CreatedDomainEvent(entity.Id, entity.Name, id, ts)));\n        return entity;\n    }\n\n    public void Rename(string name)\n    {\n        ArgumentException.ThrowIfNullOrWhiteSpace(name);\n        Name = name.Trim();\n    }\n}\n```\n\nNotes: setters are `private set`; `TenantId`/audit/soft-delete members are settable by the framework\n(interceptor + Finbuckle) so they aren't `private set`. Use `Guid.CreateVersion7()`, never `Guid.NewGuid()`.\n\n## Domain event — inherit `DomainEvent` (abstract record)\n\n```csharp\npublic sealed record {Entity}CreatedDomainEvent(\n    Guid {Entity}Id, string Name, Guid EventId, DateTimeOffset OccurredOnUtc)\n    : DomainEvent(EventId, OccurredOnUtc);\n```\n\nRaise with the `DomainEvent.Create((id, ts) => …)` helper + `AddDomainEvent(...)` (not `QueueDomainEvent`).\n\n## EF configuration\n\n```csharp\npublic sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>\n{\n    public void Configure(EntityTypeBuilder<{Entity}> builder)\n    {\n        ArgumentNullException.ThrowIfNull(builder);\n        builder.ToTable(\"{Entities}\");                       // schema is set once on the DbContext\n        builder.HasKey(x => x.Id);\n        builder.Property(x => x.Name).IsRequired().HasMaxLength(200);\n\n        // soft-deletable unique field → filter on live rows only\n        builder.HasIndex(x => x.Name).IsUnique().HasFilter(\"\\\"IsDeleted\\\" = FALSE\");\n\n        // owned value object\n        builder.OwnsOne(x => x.Price, m =>\n        {\n            m.Property(p => p.Amount).HasColumnName(\"PriceAmount\").HasPrecision(18, 4);\n            m.Property(p => p.Currency).HasColumnName(\"PriceCurrency\").HasMaxLength(3);\n        });\n\n        builder.Ignore(x => x.DomainEvents);\n    }\n}\n```\n\n- **Do NOT add a manual `HasQueryFilter` for soft-delete or tenant** — `BaseDbContext` applies both automatically.\n- A child entity reached only via a parent nav-collection needs `builder.Property(x => x.Id).ValueGeneratedNever()` in **its** config, or EF inserts it as `Modified` → 0-row UPDATE. See `database.md`.\n\n## Register in the module DbContext\n\nAdd a `DbSet`; configurations are picked up by `ApplyConfigurationsFromAssembly`:\n\n```csharp\npublic DbSet<{Entity}> {Entities} => Set<{Entity}>();\n```\n\nThe DbContext already extends `BaseDbContext` and calls `base.OnModelCreating` **last** — don't change that.\n\n## Migration\n\nUse the **create-migration** skill (build first, correct `--context`):\n\n```bash\ndotnet ef migrations add Add{Entity} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {X}DbContext\n```\n\n## Checklist\n\n- [ ] `sealed`, `AggregateRoot<Guid>` (+ `IHasTenant`/`IAuditableEntity`/`ISoftDeletable` as needed), private ctor, static `Create` using `Guid.CreateVersion7()`\n- [ ] Domain event inherits `DomainEvent`; raised via `DomainEvent.Create` + `AddDomainEvent`\n- [ ] EF config: no manual soft-delete/tenant filter; `ValueGeneratedNever()` on nav-collection children\n- [ ] `DbSet` added; build green; migration created with `--context {X}DbContext`\n","frontmatter":{"name":"add-entity","description":"Add a domain entity/aggregate with EF configuration and a migration to an existing FSH module. Use when adding a new database-backed entity. Pairs with add-feature and create-migration.","argument-hint":"[ModuleName] [EntityName]"},"isInternal":false,"tokens":1162,"sizeBytes":4849},{"name":"SKILL.md","path":".agents/skills/add-feature/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/add-feature/SKILL.md","title":"add-feature","category":"anthropic-skill","format":"markdown","content":"---\nname: add-feature\ndescription: Add a vertical-slice feature (command/query + handler + validator + endpoint) to an existing FSH module. Use when adding an API endpoint or business operation to a module that already exists.\nargument-hint: \"[ModuleName] [Area] [FeatureName]\"\n---\n\n# Add Feature\n\nA feature is a vertical slice **split across two projects**: the request/response types live in the\nmodule's `.Contracts` project (public API); the handler, validator, and endpoint live in the runtime\nproject. Full conventions: `.agents/rules/api-conventions.md`.\n\n## Layout (real)\n\n```\nsrc/Modules/{X}/Modules.{X}.Contracts/v1/{Area}/{Feature}Command.cs   # ICommand<T>/IQuery<T>\nsrc/Modules/{X}/Modules.{X}.Contracts/Dtos/{Entity}Dto.cs             # response DTOs (if any)\nsrc/Modules/{X}/Modules.{X}/Features/v1/{Area}/{Feature}/\n├── {Feature}CommandHandler.cs    # public sealed, injects the DbContext directly\n├── {Feature}CommandValidator.cs  # required for commands + paginated queries\n└── {Feature}Endpoint.cs          # internal static extension\n```\n\n## Step 1 — Command/Query (Contracts project)\n\n`Mediator` interfaces (`using Mediator;`). Records. A create command can return the raw `Guid`.\n\n```csharp\nnamespace FSH.Modules.{X}.Contracts.v1.{Area};\n\npublic sealed record Create{Entity}Command(string Name, decimal PriceAmount, string PriceCurrency)\n    : ICommand<Guid>;\n```\n\nRead/list DTOs go in `Modules.{X}.Contracts/Dtos/`. Paginated queries return `PagedResponse<T>`\n(`FSH.Framework.Shared.Persistence`) — see `query-patterns`.\n\n## Step 2 — Handler (runtime `Features/`) — inject the DbContext, NOT a repository\n\nThere is **no generic `IRepository<T>`**. Inject the module's `{X}DbContext`. `public sealed`, primary\nctor, `ValueTask<T>`, `.ConfigureAwait(false)`, guard first. Tenant/audit fields are auto-stamped — only\ninject `ICurrentUser` if you need the acting user (`GetUserId()` / `GetTenant()`).\n\n```csharp\npublic sealed class Create{Entity}CommandHandler(CatalogDbContext dbContext)\n    : ICommandHandler<Create{Entity}Command, Guid>\n{\n    public async ValueTask<Guid> Handle(Create{Entity}Command command, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(command);\n\n        var entity = {Entity}.Create(command.Name, new Money(command.PriceAmount, command.PriceCurrency));\n        dbContext.{Entities}.Add(entity);\n        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);\n        return entity.Id;\n    }\n}\n```\n\nThrow `NotFoundException` / `CustomException(msg, errors, HttpStatusCode)` (`FSH.Framework.Core.Exceptions`) — the global handler maps them to ProblemDetails.\n\n## Step 3 — Validator (required; same folder)\n\n```csharp\npublic sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command>\n{\n    public Create{Entity}CommandValidator()\n    {\n        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);\n        RuleFor(x => x.PriceCurrency).NotEmpty().Length(3);\n    }\n}\n```\n\n`Architecture.Tests` fails the build if a command/paginated-query handler has no `{Name}Validator`.\n\n## Step 4 — Endpoint (same folder)\n\n```csharp\npublic static class Create{Entity}Endpoint\n{\n    internal static RouteHandlerBuilder MapCreate{Entity}Endpoint(this IEndpointRouteBuilder endpoints) =>\n        endpoints.MapPost(\"/{entities}\",\n                async (Create{Entity}Command command, IMediator mediator, CancellationToken ct) =>\n                    Results.Ok(await mediator.Send(command, ct)))\n            .WithName(\"Create{Entity}\")\n            .WithSummary(\"Create a {entity}\")\n            .RequirePermission({X}Permissions.{Entities}.Create)\n            .WithIdempotency();   // on replay-safe POSTs\n}\n```\n\n## Step 5 — Wire it in `{X}Module.MapEndpoints`\n\n```csharp\ngroup.MapCreate{Entity}Endpoint();   // group = endpoints.MapGroup(\"api/v{version:apiVersion}/{x}\") …\n```\n\n## Step 6 — Verify\n\n```bash\ndotnet build src/FSH.Starter.slnx          # 0 warnings (TreatWarningsAsErrors)\ndotnet test src/Tests/{X}.Tests            # + add a handler/validator test (see testing-guide)\n```\n\n## Checklist\n\n- [ ] Command/Query in the **Contracts** project (`using Mediator;`), DTOs in `Contracts/Dtos/`\n- [ ] Handler `public sealed`, injects `{X}DbContext` (no repository), `ValueTask<T>` + `.ConfigureAwait(false)`\n- [ ] `{Name}Validator` exists\n- [ ] Endpoint `internal static …Map{Feature}Endpoint`, `.RequirePermission(...)`, `.WithName/.WithSummary`\n- [ ] Wired in `{X}Module.MapEndpoints`\n- [ ] Build 0 warnings; test added\n","frontmatter":{"name":"add-feature","description":"Add a vertical-slice feature (command/query + handler + validator + endpoint) to an existing FSH module. Use when adding an API endpoint or business operation to a module that already exists.","argument-hint":"[ModuleName] [Area] [FeatureName]"},"isInternal":false,"tokens":1072,"sizeBytes":4570},{"name":"SKILL.md","path":".agents/skills/add-full-slice/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/add-full-slice/SKILL.md","title":"add-full-slice","category":"anthropic-skill","format":"markdown","content":"---\nname: add-full-slice\ndescription: Build a capability end-to-end — backend vertical slice (Contracts→handler→validator→endpoint) AND the React page wired to it. Use when delivering a user-facing feature across API + UI. Composes add-feature + add-react-page.\nargument-hint: \"[ModuleName] [admin|dashboard] [FeatureName]\"\n---\n\n# Add Full Slice (backend → frontend)\n\nThe kit's accelerator: deliver a feature from database to UI in one pass. This skill **composes**\n`add-feature` (backend) and `add-react-page` (frontend) — follow each for the detailed code; this file is\nthe order of operations and the **contract** that keeps the two halves in sync.\n\n## Order of operations\n\n1. **Backend slice** — `add-feature` (and `add-entity` first if a new entity is needed):\n   - Command/Query + response DTO in `Modules.{X}.Contracts/v1/{Area}/` (+ `Contracts/Dtos/`).\n   - Handler (`public sealed`, injects `{X}DbContext`), Validator, Endpoint (`internal static Map…Endpoint`, `.RequirePermission(...)`).\n   - Wire in `{X}Module.MapEndpoints`. Build + test backend green.\n2. **Lock the contract** — note the final **route path**, HTTP method, request shape, and response DTO field names/casing. The React side must match these exactly.\n3. **Frontend page** — `add-react-page` in the chosen app:\n   - API module calls the **same path**; hand-write TS types mirroring the **response DTO** (the API serializes C# records as camelCase JSON — TS fields are camelCase even though admin *query params* are PascalCase).\n   - Page (`useQuery`/`useMutation`), route (`RouteGuard` for admin / `withSuspense` for dashboard).\n4. **Permission** — if the endpoint is gated, mirror the constant into admin's `lib/permissions.ts` and gate the route (`add-permission`). Dashboard relies on the server 403.\n5. **Tests both sides** — backend handler/validator test (xUnit/Shouldly/NSubstitute) + frontend Playwright spec (route-mocked).\n\n## The contract (the thing that breaks if you're sloppy)\n\n| Backend | Frontend must match |\n|---|---|\n| Endpoint route `api/v{n}/{module}/{resources}` | `apiFetch` path |\n| Request: the `Command`/`Query` record fields | request body / query-param keys (admin params PascalCase; **body JSON camelCase**) |\n| Response: the DTO record (camelCase JSON) | the hand-written TS `type` |\n| `.RequirePermission({X}Permissions...)` | (admin) `RouteGuard perms` + the mirrored constant |\n| Paginated → `PagedResponse<T>` | `PagedResponse<T>` (admin: `@/lib/api-types`; dashboard: inline) |\n\n## Verify end-to-end\n\n```bash\ndotnet build src/FSH.Starter.slnx && dotnet test src/Tests/{X}.Tests\ncd clients/{app} && npm run lint && npm run test:e2e\n# optional manual check: dotnet run --project src/Host/FSH.Starter.AppHost  (brings up API + both apps)\n```\n\n## Checklist\n\n- [ ] Backend slice complete + green (`add-feature`)\n- [ ] Contract locked: route, request shape, response DTO field names\n- [ ] Frontend api module path + TS types match the contract (body JSON camelCase)\n- [ ] Page + route added (`add-react-page`); admin permission mirrored + gated (`add-permission`)\n- [ ] Backend test + Playwright test added; both suites green\n","frontmatter":{"name":"add-full-slice","description":"Build a capability end-to-end — backend vertical slice (Contracts→handler→validator→endpoint) AND the React page wired to it. Use when delivering a user-facing feature across API + UI. Composes add-feature + add-react-page.","argument-hint":"[ModuleName] [admin|dashboard] [FeatureName]"},"isInternal":false,"tokens":793,"sizeBytes":3160},{"name":"SKILL.md","path":".agents/skills/add-integration-event/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/add-integration-event/SKILL.md","title":"add-integration-event","category":"anthropic-skill","format":"markdown","content":"---\nname: add-integration-event\ndescription: Publish a cross-module integration event via the Outbox and handle it idempotently in another module. Use when one module must react to something that happened in another. See .agents/rules/eventing.md.\nargument-hint: \"[SourceModule] [EventName] [ConsumerModule]\"\n---\n\n# Add Integration Event\n\nCross-module communication goes through **integration events + the Outbox** (transactional, crash-safe) —\nnever a direct in-process call into another module's runtime, and never `IEventBus.PublishAsync` from a\nhandler. Full model: `.agents/rules/eventing.md`.\n\n## Step 1 — Define the event (source module's Contracts)\n\n`Modules.{Source}.Contracts/Events/{Event}IntegrationEvent.cs` — implement `IIntegrationEvent`:\n\n```csharp\npublic sealed record {Event}IntegrationEvent(\n    Guid Id,\n    DateTime OccurredOnUtc,\n    string? TenantId,\n    string CorrelationId,\n    string Source,\n    Guid {Entity}Id,\n    string SomePayload) : IIntegrationEvent;\n```\n\n⚠️ Don't rename/move this type later — the outbox stores its assembly-qualified name; a rename makes\n`Type.GetType()` return null and the message dead-letters. Keep the type name + namespace stable.\n\n## Step 2 — Publish via the Outbox (source handler)\n\nPublishing needs **no module registration** — the outbox is framework-owned and the host wires it once. Inject `IOutboxWriter` (from `FSH.Framework.Eventing.Abstractions`) and add the event in the same unit of work:\n\n```csharp\npublic sealed class Do{Thing}CommandHandler({Source}DbContext db, IOutboxWriter outbox)\n    : ICommandHandler<Do{Thing}Command, Unit>\n{\n    public async ValueTask<Unit> Handle(Do{Thing}Command command, CancellationToken cancellationToken)\n    {\n        // … mutate entities, db.SaveChangesAsync …\n        var evt = new {Event}IntegrationEvent(\n            Id: Guid.CreateVersion7(),\n            OccurredOnUtc: DateTime.UtcNow,\n            TenantId: /* current tenant */,\n            CorrelationId: Guid.NewGuid().ToString(),\n            Source: \"{Source}\",\n            {Entity}Id: entity.Id,\n            SomePayload: \"…\");\n        await outbox.AddAsync(evt, cancellationToken).ConfigureAwait(false);\n        return Unit.Value;\n    }\n}\n```\n\nThe `OutboxDispatcherHostedService` later publishes it via `IEventBus`.\n\n## Step 3 — Handle it (consumer module)\n\n`Modules.{Consumer}/IntegrationEventHandlers/{Event}IntegrationEventHandler.cs` — `sealed`, implement `IIntegrationEventHandler<T>`:\n\n```csharp\npublic sealed class {Event}IntegrationEventHandler({Consumer}DbContext db /*, IHubContext<AppHub> hub */)\n    : IIntegrationEventHandler<{Event}IntegrationEvent>\n{\n    public async Task HandleAsync({Event}IntegrationEvent @event, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(@event);\n        // … write to the consumer's tables / push a notification …\n        await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);\n    }\n}\n```\n\nRegister the consumer's handlers in its `ConfigureServices`:\n\n```csharp\nbuilder.Services.AddIntegrationEventHandlers(typeof({Consumer}Module).Assembly);\n```\n\n## Gotchas\n\n- **Idempotency is free** with the in-memory bus (the Inbox dedups by `{eventId, handlerName}`) — don't hand-roll it.\n- The in-memory bus runs handlers **synchronously in the publisher's scope** — keep the handler lean; a throw surfaces to the originating request. Published via the outbox, that scope belongs to the dispatcher, so the consumer runs on the next cycle and its failures never reach the caller. Don't let a caller (or a test) assume the side effect already happened; integration tests drain with `OutboxDrain.DrainAsync`.\n- If the handler reads a **tenant-filtered** DbContext from a background path (open-generic handler, Hangfire job), restore Finbuckle context first via `IMultiTenantContextSetter` (see `WebhookFanoutHandler`).\n- **Module load order:** the consumer must load before the publisher if it must react (`Order` in `[assembly: FshModule]`) — e.g. Notifications (750) before Chat (800).\n\n## Checklist\n\n- [ ] Event in source Contracts, implements `IIntegrationEvent`, stable type name\n- [ ] Published via `IOutboxWriter.AddAsync` (not the bus); no per-module eventing registration needed\n- [ ] Consumer handler `sealed : IIntegrationEventHandler<T>`; `AddIntegrationEventHandlers(assembly)` registered\n- [ ] Background readers restore tenant context; module `Order` lets the consumer load first\n- [ ] Build + tests green\n","frontmatter":{"name":"add-integration-event","description":"Publish a cross-module integration event via the Outbox and handle it idempotently in another module. Use when one module must react to something that happened in another. See .agents/rules/eventing.md.","argument-hint":"[SourceModule] [EventName] [ConsumerModule]"},"isInternal":false,"tokens":1003,"sizeBytes":4503},{"name":"SKILL.md","path":".agents/skills/add-module/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/add-module/SKILL.md","title":"add-module","category":"anthropic-skill","format":"markdown","content":"---\nname: add-module\ndescription: Create a new module (bounded context) — runtime + Contracts projects, IModule, DbContext, permissions, migrations, and the four registration sites. Use when adding a distinct business domain. For a feature in an existing module, use add-feature.\nargument-hint: \"[ModuleName]\"\n---\n\n# Add Module\n\nHigh-ceremony. The part people get wrong is **registration — a module must be wired in FOUR places**\n(see Step 6). Architecture rules: `.agents/rules/architecture.md`.\n\n## Projects\n\n```\nsrc/Modules/{Name}/\n├── Modules.{Name}/            ← runtime (internal): Domain/, Data/, Features/v1/, {Name}Module.cs\n└── Modules.{Name}.Contracts/  ← public API: v1/ (commands/queries), Dtos/, Authorization/, Events/\n```\n\n**Copy an existing module's two `.csproj` files** (e.g. `Modules.Catalog`) and rename — don't hand-write\nproject references. The runtime project references its Contracts project + the BuildingBlocks it needs;\nthe Contracts project references `Mediator` + shared contracts.\n\n## Step 1 — `[FshModule]` is an ASSEMBLY attribute (not class-level)\n\nIn `{Name}Module.cs`, above the namespace:\n\n```csharp\n[assembly: FshModule(typeof(FSH.Modules.{Name}.{Name}Module), 900)]   // (Type, order)\n\nnamespace FSH.Modules.{Name};\n\npublic sealed class {Name}Module : IModule\n{\n    public void ConfigureServices(IHostApplicationBuilder builder)\n    {\n        ArgumentNullException.ThrowIfNull(builder);\n        PermissionConstants.Register({Name}Permissions.All);\n        builder.Services.AddHeroDbContext<{Name}DbContext>();\n        builder.Services.AddScoped<IDbInitializer, {Name}DbInitializer>();\n\n        // Only if the module HANDLES integration events:\n        // builder.Services.AddIntegrationEventHandlers(typeof({Name}Module).Assembly);\n        //\n        // Publishing needs no registration at all — the outbox is framework-owned\n        // (host calls AddEventingCore once). Inject IOutboxWriter and publish.\n        // Never register a per-module outbox store; see .agents/rules/eventing.md.\n\n        builder.Services.AddHealthChecks()\n            .AddDbContextCheck<{Name}DbContext>(name: \"db:{name}\");\n    }\n\n    public void ConfigureMiddleware(IApplicationBuilder app) { }   // optional, runs after auth\n\n    public void MapEndpoints(IEndpointRouteBuilder endpoints)\n    {\n        ArgumentNullException.ThrowIfNull(endpoints);\n        var versionSet = endpoints.NewApiVersionSet().HasApiVersion(new ApiVersion(1)).ReportApiVersions().Build();\n        var group = endpoints.MapGroup(\"api/v{version:apiVersion}/{name}\")\n            .WithTags(\"{Name}\").WithApiVersionSet(versionSet).RequireAuthorization();\n        // group.MapCreate{Entity}Endpoint();  …\n    }\n}\n```\n\n`Order` controls load sequence (Auditing 300, Files 350, Webhooks 400, Billing 500, Catalog 600, Tickets 700, Notifications 750, Chat 800). If your module consumes another's events, load after it.\n\n## Step 2 — Permissions (Contracts/Authorization)\n\n`{Name}Permissions` with nested resource classes and an `All` collection registered via `PermissionConstants.Register({Name}Permissions.All)`. Mirror the shape of `CatalogPermissions`.\n\n## Step 3 — DbContext (extends `BaseDbContext`)\n\n```csharp\npublic sealed class {Name}DbContext : BaseDbContext\n{\n    public const string Schema = \"{name}\";\n\n    public {Name}DbContext(\n        IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor,\n        DbContextOptions<{Name}DbContext> options,\n        IOptions<DatabaseOptions> settings,\n        IHostEnvironment environment)\n        : base(multiTenantContextAccessor, options, settings, environment) { }\n\n    public DbSet<{Entity}> {Entities} => Set<{Entity}>();\n\n    protected override void OnModelCreating(ModelBuilder modelBuilder)\n    {\n        ArgumentNullException.ThrowIfNull(modelBuilder);\n        modelBuilder.HasDefaultSchema(Schema);\n        modelBuilder.ApplyConfigurationsFromAssembly(typeof({Name}DbContext).Assembly);\n        base.OnModelCreating(modelBuilder);   // MUST be last — applies tenant + soft-delete filters\n    }\n}\n```\n\n## Step 4 — Solution + project references\n\n```bash\ndotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}/Modules.{Name}.csproj\ndotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}.Contracts/Modules.{Name}.Contracts.csproj\n```\n\nAdd a `<ProjectReference>` to the runtime module from **both** `FSH.Starter.Api` and `FSH.Starter.DbMigrator`, and reference the runtime project from `FSH.Starter.Migrations.PostgreSQL`.\n\n## Step 5 — Migrations folder\n\nAdd a `{Name}/` folder in `src/Host/FSH.Starter.Migrations.PostgreSQL`, then create the initial migration (see **create-migration**) with `--context {Name}DbContext`.\n\n## Step 6 — ⚠️ Register in ALL FOUR places (the footgun)\n\nIdentical edits in **both** `FSH.Starter.Api/Program.cs` **and** `FSH.Starter.DbMigrator/Program.cs`:\n\n1. Mediator `o.Assemblies` — add **two** markers: a Contracts type (e.g. `typeof(FSH.Modules.{Name}.Contracts.{Name}ContractsMarker)`) **and** the module type (`typeof({Name}Module)`).\n2. `moduleAssemblies` array — add `typeof({Name}Module).Assembly`.\n\nMiss the Mediator marker → handlers silently undiscovered. Miss the assembly entry → module never loads. Miss the DbMigrator pair → migrate/seed skips the module.\n\n## Step 7 — Verify\n\n```bash\ndotnet build src/FSH.Starter.slnx                  # 0 warnings\ndotnet test src/Tests/Architecture.Tests           # boundary + tenant-isolation rules must pass\ndotnet test src/FSH.Starter.slnx\n```\n\n## Checklist\n\n- [ ] Two projects (copied csproj), added to `.slnx`, referenced from Api + DbMigrator (+ Migrations)\n- [ ] `[assembly: FshModule(typeof({Name}Module), order)]` (assembly-level, positional)\n- [ ] `IModule`: `AddHeroDbContext<T>()`, `PermissionConstants.Register`, version-set group, eventing trio if needed\n- [ ] `{Name}DbContext : BaseDbContext`, 4-arg ctor, `base.OnModelCreating` last\n- [ ] `{Name}Permissions` in Contracts/Authorization\n- [ ] Migrations folder + initial migration (`--context {Name}DbContext`)\n- [ ] **Registered in all four places** (Api + DbMigrator × Mediator + moduleAssemblies)\n- [ ] Build + Architecture.Tests green\n","frontmatter":{"name":"add-module","description":"Create a new module (bounded context) — runtime + Contracts projects, IModule, DbContext, permissions, migrations, and the four registration sites. Use when adding a distinct business domain. For a feature in an existing module, use add-feature.","argument-hint":"[ModuleName]"},"isInternal":false,"tokens":1462,"sizeBytes":6226},{"name":"SKILL.md","path":".agents/skills/add-permission/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/add-permission/SKILL.md","title":"add-permission","category":"anthropic-skill","format":"markdown","content":"---\nname: add-permission\ndescription: Add a new permission end-to-end — server constant + endpoint gate, and (admin app) mirror it into the permissions catalog + route guard. Use when a new endpoint needs authorization. See modules/identity.md + frontend/admin.md.\nargument-hint: \"[ModuleName] [Resource] [Action]\"\n---\n\n# Add Permission\n\nA permission spans server + the admin app. The dashboard app does **not** mirror permissions — it reads\nthem from the JWT and relies on the server's 403.\n\n## Step 1 — Server constant (`Modules.{X}.Contracts/Authorization/{X}Permissions.cs`)\n\nAdd the constant to the resource group and ensure it's in the module's `All` collection. Convention:\n`Permissions.{Resource}.{Action}`.\n\n```csharp\npublic static class {X}Permissions\n{\n    public static class {Resources}\n    {\n        public const string View   = \"Permissions.{Resources}.View\";\n        public const string Create = \"Permissions.{Resources}.Create\";   // ← new\n    }\n    public static IReadOnlyList<FshPermission> All { get; } = [ /* … include the new one … */ ];\n}\n```\n\nThe module already calls `PermissionConstants.Register({X}Permissions.All)` in `ConfigureServices`, so a new entry in `All` is picked up automatically.\n\n## Step 2 — Gate the endpoint\n\n```csharp\n.RequirePermission({X}Permissions.{Resources}.Create);\n```\n\n⚠️ `RequiredPermissionAttribute` implements `IRequiredPermissionMetadata`. **Never let a second/duplicate of that interface exist** — it silently disables **all** `.RequirePermission()` gates app-wide. (See `.agents/rules/modules/identity.md`.)\n\n## Step 3 — (admin only) mirror it\n\n`clients/admin/src/lib/permissions.ts` — add the matching string to the frozen tree (no runtime catalog endpoint exists; mirror by hand):\n\n```ts\nexport const {Module}Permissions = Object.freeze({\n  {Resources}: { View: \"Permissions.{Resources}.View\", Create: \"Permissions.{Resources}.Create\" },\n} as const);\n```\n\nIf it should appear in the Role editor UI, add a `PERMISSION_CATALOG` entry (`{ name, description, root?, basic? }` under the right category group).\n\n## Step 4 — (admin only) gate the route\n\n```tsx\n{ path: \"{resources}/new\",\n  element: <RouteGuard perms={[{Module}Permissions.{Resources}.Create]}><Create{Resource}Page /></RouteGuard> },\n```\n\n## Step 5 — (admin only) seed it in tests\n\nSo `RouteGuard` passes on first paint, add the new permission to the test seed set (`ADMIN_PERMS` in `clients/admin/tests/helpers/shell-mocks.ts`, used by `seedAuthedSession`).\n\n## Dashboard\n\nNo mirror, no `RouteGuard`. The JWT carries only role names — the app fetches the permission list from `GET /api/v1/identity/permissions` at hydration and the server enforces access; a missing permission yields a 403 the UI surfaces. Routes aren't permission-gated; to hide a nav entry, set `perm`/`anyPerm` on the item in `src/components/layout/nav-data.ts`. Permission-gated specs mock `GET /identity/permissions` with the grants they need (shell mocks stub it to `[]`).\n\n## Checklist\n\n- [ ] Server constant added to `{X}Permissions` **and** its `All` collection\n- [ ] Endpoint gated with `.RequirePermission(...)`; no duplicate `IRequiredPermissionMetadata`\n- [ ] (admin) mirrored in `lib/permissions.ts` (+ `PERMISSION_CATALOG` if role-editor-visible)\n- [ ] (admin) route wrapped in `<RouteGuard perms={[…]}>`; permission added to `ADMIN_PERMS` test seed\n- [ ] Build green; admin `test:e2e` green\n","frontmatter":{"name":"add-permission","description":"Add a new permission end-to-end — server constant + endpoint gate, and (admin app) mirror it into the permissions catalog + route guard. Use when a new endpoint needs authorization. See modules/identity.md + frontend/admin.md.","argument-hint":"[ModuleName] [Resource] [Action]"},"isInternal":false,"tokens":823,"sizeBytes":3427},{"name":"SKILL.md","path":".agents/skills/add-react-page/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/add-react-page/SKILL.md","title":"add-react-page","category":"anthropic-skill","format":"markdown","content":"---\nname: add-react-page\ndescription: Add a list+create page to a React app (clients/admin or clients/dashboard) — API module, page, lazy route, (admin) permission gate, Playwright test. Use when adding any frontend screen. See .agents/rules/frontend/.\nargument-hint: \"[admin|dashboard] [Area] [Resource]\"\n---\n\n# Add React Page\n\nThe frontend slice. Read `.agents/rules/frontend/shared.md` plus the app file (`frontend/admin.md` /\n`frontend/dashboard.md`) — the two apps **deliberately diverge**:\n\n| | **admin** (operator) | **dashboard** (tenant) |\n|---|---|---|\n| Query params | PascalCase (`PageNumber`, `Search`) | camelCase (`pageNumber`, `search`) |\n| `PagedResponse<T>` | import from `@/lib/api-types` | re-declare inline in the api module |\n| Path constant | `const BASE = \"/api/v1/...\"` | inline the full path per call |\n| Forms | **react-hook-form + zod** | **hand-rolled** controlled inputs (no RHF/zod) |\n| List + create | separate routed pages (`list.tsx`, `create.tsx`) | one file with `<Dialog>` editors |\n| Route wrapper | `<RouteGuard perms={[…]}>` | `withSuspense(<X/>)` (no permission gate) |\n| Permissions | mirror in `src/lib/permissions.ts` | fetched from `GET /identity/permissions` (not JWT); nav gating via `perm`/`anyPerm` in `nav-data.ts`; no route guard — server 403 backstops |\n\nShared everywhere: types are **hand-written** (no codegen); `apiFetch<T>` from `@/lib/api-client`; `cn()` from `@/lib/cn`; `env.apiBase` from runtime `/config.json`; CVA `components/ui` + `components/list` primitives; Tailwind v4 CSS-first (tokens in `src/styles/globals.css`); `toast` from `sonner`; pages are **named exports**; `placeholderData: keepPreviousData` (v5).\n\n## Step 1 — API module (`src/api/{resource}.ts`)\n\nHand-write the DTO/param/input types and thin `apiFetch` functions.\n\n```ts\n// admin\nimport { apiFetch } from \"@/lib/api-client\";\nimport type { PagedResponse } from \"@/lib/api-types\";\nconst BASE = \"/api/v1/{module}/{resources}\";\n\nexport type {Resource}Dto = { id: string; name: string; /* … */ };\n\nexport async function search{Resources}(p: { pageNumber?: number; search?: string } = {}) {\n  const q = new URLSearchParams();\n  q.set(\"PageNumber\", String(p.pageNumber ?? 1));\n  q.set(\"PageSize\", \"10\");\n  if (p.search?.trim()) q.set(\"Search\", p.search.trim());\n  return apiFetch<PagedResponse<{Resource}Dto>>(`${BASE}/search?${q}`);\n}\nexport async function create{Resource}(input: Create{Resource}Input) {\n  return apiFetch<{ id: string }>(BASE, { method: \"POST\", body: JSON.stringify(input) });\n}\n```\n\n(dashboard: inline `type PagedResponse<T> = …`, inline the path, camelCase params, mutations often return `Promise<string>`.)\n\n## Step 2 — Page (`src/pages/{area}/...tsx`, named export)\n\n```tsx\nexport function {Resource}ListPage() {\n  const [search, setSearch] = useState(\"\");           // debounce → reset page to 1 on change\n  const [pageNumber, setPage] = useState(1);\n  const query = useQuery({\n    queryKey: [\"{resources}\", { pageNumber, search }],   // hierarchical; params object last\n    queryFn: () => search{Resources}({ pageNumber, search: search || undefined }),\n    placeholderData: keepPreviousData,\n  });\n  // render with components/ui/* + components/list/* (admin: PageHeader/Field…; dashboard: Entity* family)\n}\n```\n\n## Step 3 — Mutation (race-safe `mutate(arg)`)\n\nPass per-call data through `mutate(arg)`; read it from the callback `variables` — never from a closed-over render variable.\n\n```tsx\nconst qc = useQueryClient();\nconst createMut = useMutation({\n  mutationFn: (input: Create{Resource}Input) => create{Resource}(input),\n  onSuccess: () => { toast.success(\"Created\"); qc.invalidateQueries({ queryKey: [\"{resources}\"] }); },\n  onError: (e) => toast.error(e instanceof ApiRequestError ? e.message : \"Failed\"),\n});\n// admin: const form = useForm({ resolver: zodResolver(schema) });  form.handleSubmit(v => createMut.mutate(v))\n// dashboard: controlled useState fields; onSubmit(e){ e.preventDefault(); createMut.mutate(payload); }\n```\n\nIf you need to track the in-flight item (e.g. a per-row busy state), use `onMutate: (arg) => setBusyId(arg)` reading the `mutate(arg)` value (pattern: `admin/src/pages/settings/sessions.tsx`).\n\n## Step 4 — Register the route (`routes.tsx`)\n\n```tsx\nconst {Resource}ListPage = lazyNamed(() => import(\"@/pages/{area}/list\"), \"{Resource}ListPage\");\n// admin — under AppShell.children, gated:\n{ path: \"{resources}\", element: <RouteGuard perms={[{Module}Permissions.{Resources}.View]}><{Resource}ListPage /></RouteGuard> },\n// dashboard — under AppShell.children, suspense only:\n{ path: \"{area}/{resources}\", element: withSuspense(<{Resource}ListPage />) },\n```\n\n## Step 5 — (admin only) mirror the permission\n\nAdd the constant to `src/lib/permissions.ts` (`{Module}Permissions.{Resources}.View` = `\"Permissions.{Resources}.View\"`), and a `PERMISSION_CATALOG` entry if it belongs in the Role editor. See `add-permission`.\n\n## Step 6 — Playwright test (`tests/{area}/{resource}.spec.ts`)\n\n```ts\ntest.beforeEach(async ({ page }) => {\n  // admin: seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); await installAdminShellMocks(page);\n  // dashboard: await seedAuthedSession(page, TEST_USER); await installShellMocks(page);\n  await mockJsonResponse(page, \"**/api/v1/{module}/{resources}**\", paged([SAMPLE]));   // page mocks AFTER shell mocks\n});\n```\n\nUse `mockProblemDetails(...)` for error states. Dashboard: scope row assertions with `.last()` / dialog scoping (lists render mobile + desktop copies → strict-mode double match).\n\n## Step 7 — Verify\n\n```bash\ncd clients/{app} && npm run lint && npm run test:e2e\n```\n\n## Checklist\n\n- [ ] API module: hand-written types, `apiFetch`, correct param casing per app (Pascal=admin, camel=dashboard)\n- [ ] Page is a **named export**; `useQuery` key hierarchical + `placeholderData: keepPreviousData`\n- [ ] Mutation passes data via `mutate(arg)`, invalidates in `onSuccess`\n- [ ] Route via `lazyNamed`; admin wraps in `<RouteGuard perms>`, dashboard in `withSuspense`\n- [ ] (admin) permission mirrored in `lib/permissions.ts`\n- [ ] Playwright test: seed + shell mocks + page mocks; `lint` + `test:e2e` green\n","frontmatter":{"name":"add-react-page","description":"Add a list+create page to a React app (clients/admin or clients/dashboard) — API module, page, lazy route, (admin) permission gate, Playwright test. Use when adding any frontend screen. See .agents/rules/frontend/.","argument-hint":"[admin|dashboard] [Area] [Resource]"},"isInternal":false,"tokens":1613,"sizeBytes":6192},{"name":"SKILL.md","path":".agents/skills/create-migration/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/create-migration/SKILL.md","title":"create-migration","category":"anthropic-skill","format":"markdown","content":"---\nname: create-migration\ndescription: Create and apply an EF Core migration for a module's DbContext the FSH way (central Migrations project, per-module folder, correct --context). Use after changing entities/EF config. See .agents/rules/database.md.\nargument-hint: \"[ModuleName] [MigrationName]\"\n---\n\n# Create Migration\n\nAll migrations live in **one** project — `src/Host/FSH.Starter.Migrations.PostgreSQL` — but are foldered\n**per module/context** (`Catalog/`, `Identity/`, …), each with its own `{X}DbContextModelSnapshot`. The DB\nis **not** migrated at API startup; the `DbMigrator` host applies it.\n\n## Step 0 — restore the pinned tool (first time)\n\n```bash\ndotnet tool restore     # dotnet-ef is pinned in .config/dotnet-tools.json\n```\n\n## Step 1 — BUILD FIRST (snapshot footgun)\n\n`dotnet ef migrations add` reads the **current snapshot**, which is regenerated from a build. If you skip\nthe build after editing entities/config, you can generate against a stale snapshot and lose changes. Also,\n`migrations remove` rewrites the snapshot — only remove the latest, and rebuild after.\n\n```bash\ndotnet build src/FSH.Starter.slnx\n```\n\n## Step 2 — add the migration\n\nSpecify **all three** of `--project` (the Migrations project), `--startup-project` (the API host), and\n`--context {X}DbContext`. Use `--output-dir {X}` so it lands in that module's folder (match the existing\nfolder for the context).\n\n```bash\ndotnet ef migrations add {MigrationName} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {X}DbContext \\\n  --output-dir {X}\n```\n\n## Step 3 — review the generated SQL before applying\n\n```bash\ndotnet ef migrations script --idempotent \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {X}DbContext\n```\n\nCheck for: unintended table/column drops, non-nullable columns added without a default to an existing\ntable, and renames surfacing as drop+add (data loss). Adjust the model or hand-edit the migration if needed.\n\n## Step 4 — apply\n\nPreferred (the canonical path — migrates the tenant catalog then each tenant's per-module schema):\n\n```bash\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- apply\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending   # to preview first\n```\n\n(Or, single-context local dev, `dotnet ef database update --context {X}DbContext --project … --startup-project …`.)\n\n## Notes\n\n- A **new module** also needs a `{X}/` folder in the Migrations project and the runtime project referenced from it — see `add-module`.\n- `dotnet ef` against a `BaseDbContext` works because the 4-arg ctor is satisfied by the startup host's DI.\n\n## Checklist\n\n- [ ] `dotnet tool restore` done (first time)\n- [ ] Built **before** `migrations add`\n- [ ] `--context {X}DbContext` + `--output-dir {X}` (lands in the right folder)\n- [ ] Reviewed the generated SQL for data loss\n- [ ] Applied via DbMigrator `apply` (or `ef database update` for one context locally)\n","frontmatter":{"name":"create-migration","description":"Create and apply an EF Core migration for a module's DbContext the FSH way (central Migrations project, per-module folder, correct --context). Use after changing entities/EF config. See .agents/rules/database.md.","argument-hint":"[ModuleName] [MigrationName]"},"isInternal":false,"tokens":780,"sizeBytes":3054},{"name":"SKILL.md","path":".agents/skills/mediator-reference/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/mediator-reference/SKILL.md","title":"mediator-reference","category":"anthropic-skill","format":"markdown","content":"---\nname: mediator-reference\ndescription: CQRS interface reference for FSH. This project uses the Mediator source generator, NOT MediatR. Reference when implementing commands, queries, and handlers.\nuser-invocable: false\n---\n\n# Mediator Reference\n\n⚠️ **FSH uses the `Mediator` source-generator package (`using Mediator;`), NOT `MediatR`.** Different\ninterfaces — MediatR types won't compile. The CQRS interfaces below are the library's own (no FSH wrapper).\n\n## Interfaces\n\n| Purpose | ✅ Mediator (use) | ❌ MediatR (don't) |\n|---|---|---|\n| Command | `ICommand<TResponse>` | `IRequest<TResponse>` |\n| Query | `IQuery<TResponse>` | `IRequest<TResponse>` |\n| Command handler | `ICommandHandler<TCommand, TResponse>` | `IRequestHandler<…>` |\n| Query handler | `IQueryHandler<TQuery, TResponse>` | `IRequestHandler<…>` |\n| Notification / domain event | `INotification` (`IDomainEvent : INotification`) | `INotification` |\n\n## Pattern\n\n```csharp\n// Command/Query → the Contracts project (Modules.{X}.Contracts/v1/{Area}/)\npublic sealed record Create{Entity}Command(string Name) : ICommand<Guid>;\n\n// Handler → the runtime project (Modules.{X}/Features/v1/{Area}/{Feature}/), public sealed\npublic sealed class Create{Entity}CommandHandler({X}DbContext db)\n    : ICommandHandler<Create{Entity}Command, Guid>\n{\n    public async ValueTask<Guid> Handle(Create{Entity}Command command, CancellationToken cancellationToken)\n    {\n        // …\n    }\n}\n```\n\nRules: handlers return **`ValueTask<T>`** (not `Task<T>`); parameter named `command`/`query` (not `request`);\n`public sealed`; `.ConfigureAwait(false)` on awaits. Send via `mediator.Send(command, ct)` (the `IMediator`\ninterface name matches MediatR's — that part is fine).\n\n## Registration — the four places\n\nThe source generator only scans assemblies listed in `o.Assemblies`, and that list exists in **two host\nfiles**. A new module needs **two markers** (a Contracts type **and** the module type) added to the Mediator\nlist **plus** an entry in the `moduleAssemblies` array — in **both** `FSH.Starter.Api/Program.cs` **and**\n`FSH.Starter.DbMigrator/Program.cs`:\n\n```csharp\nbuilder.Services.AddMediator(o =>\n{\n    o.ServiceLifetime = ServiceLifetime.Scoped;\n    o.Assemblies = [\n        /* … */\n        typeof(FSH.Modules.{X}.Contracts.{X}ContractsMarker),   // Contracts assembly\n        typeof(FSH.Modules.{X}.{X}Module)];                     // runtime assembly\n});\n```\n\nSee `add-module` for the full procedure.\n\n## Common errors\n\n| Symptom | Cause → fix |\n|---|---|\n| `IRequest<T>` / `IRequestHandler<,>` not found | MediatR interface → use `ICommand`/`IQuery` + `ICommandHandler`/`IQueryHandler` |\n| `Task<T>` vs `ValueTask<T>` mismatch | Handler must return `ValueTask<T>` |\n| Handler not invoked at runtime | Assembly missing from `o.Assemblies` (in one or both Program.cs files) |\n","frontmatter":{"name":"mediator-reference","description":"CQRS interface reference for FSH. This project uses the Mediator source generator, NOT MediatR. Reference when implementing commands, queries, and handlers.","user-invocable":false},"isInternal":false,"tokens":738,"sizeBytes":2866},{"name":"SKILL.md","path":".agents/skills/query-patterns/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/query-patterns/SKILL.md","title":"query-patterns","category":"anthropic-skill","format":"markdown","content":"---\nname: query-patterns\ndescription: Implement read queries — paginated lists, search/filter/sort, and single-entity fetches — the FSH way (DbContext LINQ + PagedResponse). Use when adding GET endpoints. See also add-feature.\n---\n\n# Query Patterns\n\nThe dominant pattern is **raw `IQueryable` on the module DbContext** (`AsNoTracking`) with manual\npagination. There is **no generic repository** and **no `PaginatedListAsync`/`EntitiesByPaginationFilterSpec`/\n`PaginationFilter`**. Paged results are `PagedResponse<T>` (`FSH.Framework.Shared.Persistence`) — there is no `PagedList<T>`.\n\n## Paginated search query\n\n```csharp\n// Contracts/v1/{Area}/\npublic sealed record Search{Entities}Query(\n    string? Search = null,\n    bool? IsActive = null,\n    int PageNumber = 1,\n    int PageSize = 20,\n    string? SortBy = null,\n    string? SortDir = null) : IQuery<PagedResponse<{Entity}Dto>>;\n```\n\n```csharp\n// Features/v1/{Area}/Search{Entities}/\npublic sealed class Search{Entities}QueryHandler({X}DbContext dbContext)\n    : IQueryHandler<Search{Entities}Query, PagedResponse<{Entity}Dto>>\n{\n    public async ValueTask<PagedResponse<{Entity}Dto>> Handle(\n        Search{Entities}Query query, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(query);\n        int page = query.PageNumber < 1 ? 1 : query.PageNumber;\n        int size = Math.Clamp(query.PageSize, 1, 100);\n\n        var q = dbContext.{Entities}.AsNoTracking().AsQueryable();\n        if (!string.IsNullOrWhiteSpace(query.Search))\n            q = q.Where(x => EF.Functions.ILike(x.Name, $\"%{query.Search}%\"));\n        if (query.IsActive is { } active)\n            q = q.Where(x => x.IsActive == active);\n\n        q = ApplySort(q, query.SortBy, query.SortDir);\n\n        long total = await q.LongCountAsync(cancellationToken).ConfigureAwait(false);\n        var items = await q.Skip(PaginationExtensions.GetOffset(page, size)).Take(size) // overflow-safe offset\n            .ToListAsync(cancellationToken).ConfigureAwait(false);\n\n        return new PagedResponse<{Entity}Dto>\n        {\n            Items = items.Select(x => x.ToDto()).ToList(),\n            PageNumber = page, PageSize = size,\n            TotalCount = total,\n            TotalPages = (int)Math.Ceiling(total / (double)size)\n        };\n    }\n\n    private static IQueryable<{Entity}> ApplySort(IQueryable<{Entity}> q, string? by, string? dir)\n    {\n        bool desc = string.Equals(dir, \"desc\", StringComparison.OrdinalIgnoreCase);\n        return by?.ToLowerInvariant() switch\n        {\n            \"name\" => desc ? q.OrderByDescending(x => x.Name) : q.OrderBy(x => x.Name),\n            _      => q.OrderByDescending(x => x.CreatedOnUtc)\n        };\n    }\n}\n```\n\nTenant + soft-delete filters apply automatically — don't re-filter them. Project to a DTO (`.ToDto()` mapper); never return entities.\n\n## Single-entity query\n\n```csharp\npublic sealed record Get{Entity}Query(Guid Id) : IQuery<{Entity}Dto>;\n\npublic sealed class Get{Entity}QueryHandler({X}DbContext dbContext)\n    : IQueryHandler<Get{Entity}Query, {Entity}Dto>\n{\n    public async ValueTask<{Entity}Dto> Handle(Get{Entity}Query query, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(query);\n        var entity = await dbContext.{Entities}.AsNoTracking()\n            .FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken).ConfigureAwait(false)\n            ?? throw new NotFoundException($\"{Entity} {query.Id} not found\");\n        return entity.ToDto();\n    }\n}\n```\n\n## Endpoints\n\n```csharp\n// list — bind the query with [AsParameters]\nendpoints.MapGet(\"/{entities}\", async ([AsParameters] Search{Entities}Query query,\n        IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(query, ct)))\n    .WithName(\"Search{Entities}\").RequirePermission({X}Permissions.{Entities}.View);\n\n// single\nendpoints.MapGet(\"/{entities}/{id:guid}\", async (Guid id,\n        IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(new Get{Entity}Query(id), ct)))\n    .WithName(\"Get{Entity}\").RequirePermission({X}Permissions.{Entities}.View);\n```\n\nA paginated query **needs a validator** (`Search{Entities}QueryValidator`: `PageNumber >= 1`, `PageSize` in `[1,100]`) — enforced by `Architecture.Tests`.\n\n## When to use a Specification instead\n\n`Specification<T>` (`src/BuildingBlocks/Persistence/Specifications/`) is for **composing reusable query\nshapes** (`protected Where(...)`/`Include(...)`/`OrderBy(...)` in a derived spec's ctor; `AsNoTracking`\ndefaults true; specs never paginate). Reach for it when the same filter/include set is shared across\nhandlers; otherwise inline LINQ is the norm here.\n","frontmatter":{"name":"query-patterns","description":"Implement read queries — paginated lists, search/filter/sort, and single-entity fetches — the FSH way (DbContext LINQ + PagedResponse). Use when adding GET endpoints. See also add-feature."},"isInternal":false,"tokens":1080,"sizeBytes":4691},{"name":"SKILL.md","path":".agents/skills/testing-guide/SKILL.md","rawUrl":"https://raw.githubusercontent.com/fullstackhero/dotnet-starter-kit/HEAD/.agents/skills/testing-guide/SKILL.md","title":"testing-guide","category":"anthropic-skill","format":"markdown","content":"---\nname: testing-guide\ndescription: Write tests for an FSH feature — xUnit + Shouldly + NSubstitute + AutoFixture, with naming and AAA conventions. Use when adding unit/handler/validator/entity tests. Full rules in .agents/rules/testing.md + integration-testing.md.\n---\n\n# Testing Guide\n\nStack: **xUnit** + **Shouldly** (`.ShouldBe`) + **NSubstitute** (`Substitute.For<>`) + **AutoFixture**\n(`new Fixture()`). **Not** Moq, **not** FluentAssertions. Detailed conventions + integration-test gotchas\nlive in `.agents/rules/testing.md` and `.agents/rules/integration-testing.md`.\n\n## Conventions\n\n- Test class: `public sealed class {Sut}Tests`; SUT field named `_sut`.\n- Method name: **`MethodName_Should_ExpectedBehavior[_When_Condition]`**.\n- Arrange-Act-Assert with `// Arrange` / `// Act` / `// Assert`; group with `#region` (Happy Path / Guards / Edge Cases).\n- Mocks via `Substitute.For<IService>()`; assert calls with `.Received(1).X(arg, Arg.Any<CancellationToken>())`.\n- When asserting a forwarded `CancellationToken`, assert the **specific** token, not the default (NSubstitute fills optional params with `default`).\n\n## Handler test\n\n```csharp\npublic sealed class Create{Entity}CommandHandlerTests\n{\n    private readonly {X}DbContext _db;            // or Substitute.For<IService>() for service deps\n    private readonly Create{Entity}CommandHandler _sut;\n    private readonly IFixture _fixture = new Fixture();\n\n    public Create{Entity}CommandHandlerTests()\n    {\n        _db = /* in-memory or test DbContext */;\n        _sut = new Create{Entity}CommandHandler(_db);\n    }\n\n    [Fact]\n    public async Task Handle_Should_PersistEntity_And_ReturnId()\n    {\n        // Arrange\n        var command = new Create{Entity}Command(_fixture.Create<string>(), 9.99m, \"USD\");\n\n        // Act\n        var id = await _sut.Handle(command, CancellationToken.None);\n\n        // Assert\n        id.ShouldNotBe(Guid.Empty);\n    }\n}\n```\n\nService-dependency example (NSubstitute):\n\n```csharp\n_userService = Substitute.For<IUserService>();\n// Act … then:\nawait _userService.Received(1).ToggleStatusAsync(true, command.UserId, Arg.Any<CancellationToken>());\n```\n\n## Validator test\n\n```csharp\npublic sealed class Create{Entity}CommandValidatorTests\n{\n    private readonly Create{Entity}CommandValidator _sut = new();\n\n    [Theory]\n    [InlineData(\"\")]\n    public void Validate_Should_Fail_When_NameInvalid(string name)\n    {\n        var result = _sut.Validate(new Create{Entity}Command(name, 1m, \"USD\"));\n        result.IsValid.ShouldBeFalse();\n        result.Errors.ShouldContain(e => e.PropertyName == nameof(Create{Entity}Command.Name));\n    }\n}\n```\n\n## Entity / domain test (no mocks)\n\n```csharp\n[Fact]\npublic void Create_Should_RaiseCreatedEvent()\n{\n    var entity = {Entity}.Create(\"Test\", Money.Zero());\n    entity.Id.ShouldNotBe(Guid.Empty);\n    entity.DomainEvents.ShouldContain(e => e is {Entity}CreatedDomainEvent);\n}\n```\n\n## Architecture tests (guardrails — keep green)\n\n`Architecture.Tests` (NetArchTest) enforce: module boundaries (cross-module refs only via `.Contracts`),\ntenant-isolation rules, handlers `sealed`, and **every command/paginated-query handler has a validator**.\nDon't weaken these to make a change pass — fix the code.\n\n## Integration tests\n\n`Integration.Tests` runs over real Postgres/Redis/RustFS via Testcontainers — **Docker required**. Set the\nFinbuckle tenant context inline, rewire `IStorageService` post-registration for RustFS (S3), force long-polling\nfor SignalR. All detailed in `.agents/rules/integration-testing.md`.\n\n## Run\n\n```bash\ndotnet test src/Tests/{X}.Tests\ndotnet test src/Tests/Architecture.Tests\ndotnet test src/FSH.Starter.slnx --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\n","frontmatter":{"name":"testing-guide","description":"Write tests for an FSH feature — xUnit + Shouldly + NSubstitute + AutoFixture, with naming and AAA conventions. Use when adding unit/handler/validator/entity tests. Full rules in .agents/rules/testing.md + integration-testing.md."},"isInternal":false,"tokens":897,"sizeBytes":3749}],"systemPromptSnippet":"<yakaai_skills repo=\"fullstackhero/dotnet-starter-kit\">\n<!-- File: AGENTS.md (Tokens: ~2510 | Category: root-instruction) -->\n# FullStackHero .NET Starter Kit\n\n> A production-ready modular .NET 10 monolith + two React 19 apps, built for enterprise SaaS.\n\nThis file is the canonical guide for **all** AI coding tools (Claude Code, Gemini CLI, Cursor, Codex, …).\n`CLAUDE.md` and `GEMINI.md` are thin bridges that import this file — edit conventions **here**, not there.\n\nThis file is the map. Detailed conventions live in `.agents/rules/` and are read on demand — **read the\nrelevant rule file before working in that area** (see the index below). Keep this file lean.\n\n## What this is\n\nA **modular monolith** (Vertical Slice Architecture) backend that ships with two **React + Vite**\nfront-ends and a CLI. Multitenancy, auth, auditing, billing, files, chat and more are first-class.\n\n- **Backend** — .NET 10, EF Core 10, PostgreSQL, Redis, JWT + ASP.NET Identity, Finbuckle multitenancy,\n  Hangfire, OpenAPI/Scalar, Serilog + OpenTelemetry, .NET Aspire.\n- **Frontends** — `clients/admin` (operator-facing) and `clients/dashboard` (tenant-facing): React 19,\n  Vite 7, TypeScript, TanStack Query v5, React Router 7, Radix + Tailwind v4 (shadcn-style), SignalR/SSE.\n\n## Repo map\n\n| Path | What |\n|------|------|\n| `src/BuildingBlocks/` | Shared framework libraries (Core, Persistence, Web, Caching, Eventing, Storage, Quota…). **Protected — see below.** |\n| `src/Modules/{Name}/` | Bounded contexts. Each has a runtime project + a `.Contracts` project (its only public API). |\n| `src/Host/FSH.Starter.Api` | Composition-root Web API host. |\n| `src/Host/FSH.Starter.AppHost` | .NET Aspire orchestrator (Postgres, Redis, RustFS, migrator, API, **both React apps**). |\n| `src/Host/FSH.Starter.DbMigrator` | One-shot migrate/seed runner. DB is **not** migrated at API startup. |\n| `src/Host/FSH.Starter.Migrations.PostgreSQL` | All EF migrations, organized per-module by folder. |\n| `src/Tests/` | Per-module tests, `Architecture.Tests` (NetArchTest), `Integration.Tests` (Testcontainers). |\n| `clients/admin`, `clients/dashboard` | The two React apps. |\n| `deploy/` | Infra (docker, terraform, dokploy). |\n\n<!--#if (includeTools) -->\nThe `fsh` CLI (Spectre.Console) lives in `src/Tools/CLI`.\n\n<!--#endif -->\n## Tech stack\n\n| Backend | | Frontend | |\n|---|---|---|---|\n| Framework | .NET 10 / C# latest | Framework | React 19 + Vite 7 + TS 5.x |\n| CQRS | Mediator 3.x (source-gen) | Data | TanStack Query v5 |\n| Validation | FluentValidation 12.x | Routing | React Router 7 |\n| ORM / DB | EF Core 10 / PostgreSQL (Npgsql) | UI | Radix + Tailwind v4 + CVA (shadcn) |\n| Auth | JWT Bearer + ASP.NET Identity | Forms | react-hook-form + zod (**admin only**) |\n| Multitenancy | Finbuckle 10.x | Realtime | `@microsoft/signalr`, SSE (dashboard) |\n| Cache / Jobs | Redis, Hangfire | Tests | Playwright (route-mocked) |\n| Docs | OpenAPI + Scalar | API client | hand-written `apiFetch` (no codegen) |\n| Hosting | .NET Aspire | Env | runtime `/config.json` (not `VITE_*`) |\n| Testing | xUnit, Shouldly, NSubstitute, AutoFixture, NetArchTest, Testcontainers | | |\n\n## Build & run\n\n```bash\n# Whole stack (Postgres + pgAdmin + Redis + RustFS + migrator + API + both React apps)\ndotnet run --project src/Host/FSH.Starter.AppHost   # one-time: npm install in clients/admin & clients/dashboard\n\ndotnet build src/FSH.Starter.slnx                   # build backend\ndotnet run --project src/Host/FSH.Starter.Api       # API only → https://localhost:7030 (/scalar)\ndotnet test src/FSH.Starter.slnx                    # tests — integration tests REQUIRE Docker\n\ncd clients/admin && npm install && npm run dev       # → http://localhost:5173\ncd clients/dashboard && npm install && npm run dev   # → http://localhost:5174\n```\n\nMigrations / seed (DbMigrator, separate step):\n```bash\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- apply [--seed]\ndotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending\n```\n\n**Ports:** API 7030 (https)/5030 (http) · admin 5173 · dashboard 5174 · Postgres 5432 · pgAdmin 5050 · Valkey 6379 · RustFS 9000/9001 (S3 API/console).\n\n<!--#if (includeTools) -->\n## Branching & PRs\n\nSingle long-lived branch: **`main`** (the default) — there is **no `develop`**. Branch from and target `main`; stable releases are cut from `v*` tags. CI is split into path-scoped **Backend CI** (`src/**`) and **Frontend CI** (`clients/**`) workflows; branch protection requires only those two gate checks — never the individual jobs, which are skipped on the other side's PRs.\n\n<!--#endif -->\n## Golden rules (do not break)\n\n1. **Module boundaries** — a module references another module only through its `.Contracts` project, never its runtime project. Enforced by `Architecture.Tests`.\n2. **Registering a module touches FOUR places** — `Program.cs` Mediator `o.Assemblies` (two markers each) + `moduleAssemblies` array, **and the identical pair in `DbMigrator/Program.cs`**. A missing Mediator marker = handlers silently undiscovered. See `architecture.md`.\n3. **Tenant isolation is default-ON** via `BaseDbContext`. Opt out only via `IGlobalEntity`. Subclass DbContexts call `base.OnModelCreating` **last**. See `database.md`.\n4. **Do NOT modify `src/BuildingBlocks`** without explicit approval — shared by every module, wide blast radius.\n5. **Mediator handlers must be `public sealed`**, return `ValueTask<T>`, and `.ConfigureAwait(false)` every await.\n6. **Structured logging only** — no string interpolation in log messages; use message templates / `[LoggerMessage]`.\n7. **Propagate `CancellationToken`** into every EF/IO call; add as `= default` on public service methods.\n8. **Every command handler + paginated query handler needs a validator** (`{Name}Validator`). Enforced by `Architecture.Tests`.\n9. **Frontend: pass per-call data through `mutate(arg)`**, never via state the mutation callbacks close over (execute-time race). See `frontend/shared.md`.\n<!--#if (includeTools) -->\n10. **Docs + changelog travel with the change** — a user-facing change (feature, endpoint, config, infra, breaking change) isn't done until the **separate docs repo** (`github.com/fullstackhero/docs`, the Astro site) is updated to match **and** a changelog entry is added (`src/content/docs/changelog/`). Don't let the docs drift from the code.\n<!--#endif -->\n\n## Rules index — read the relevant file before you work\n\n**Backend / cross-cutting** (`.agents/rules/`)\n\n| Working on… | Read |\n|---|---|\n| Module structure, boundaries, registration, DI, middleware order, config | `architecture.md` |\n| Endpoints, CQRS, validation, exceptions, permissions, versioning | `api-conventions.md` |\n| EF Core, entities, migrations, tenant isolation, query filters | `database.md` |\n| Cross-module events, Outbox/Inbox, idempotent handlers | `eventing.md` |\n| Caching (HybridCache/Redis), keys, invalidation | `caching.md` |\n| Background jobs (Hangfire), recurring jobs | `jobs.md` |\n| Outbound HTTP resilience (Polly) | `resilience.md` |\n| Files/blobs, presigned uploads, providers | `storage.md` |\n| CORS, security headers, rate limiting, idempotency, quotas, front-end link origins | `security.md` |\n| SignalR / SSE backend | `realtime.md` |\n| Logging, correlation, OpenTelemetry | `logging.md` |\n| Localization (i18n), request culture, resource catalogs, localized exceptions | `localization.md` |\n| Unit test conventions, NetArchTest | `testing.md` |\n| Integration tests (Testcontainers harness + gotchas) | `integration-testing.md` |\n| **Modifying `src/BuildingBlocks`** (read first — it's protected) | `buildingblocks-protection.md` |\n| A specific module's quirks | `modules/{module}.md` (identity, multitenancy, chat, files, webhooks, auditing, billing, catalog, tickets, notifications) |\n\n**Frontend** (`.agents/rules/frontend/`)\n\n| Working on… | Read |\n|---|---|\n| Any React work (shared stack, API client, Query, Tailwind, design language) | `frontend/shared.md` |\n| The operator app (`clients/admin`) | `frontend/admin.md` |\n| The tenant app (`clients/dashboard`) | `frontend/dashboard.md` |\n\n## Coding style (backend)\n\nFile-scoped namespaces · 4-space indent · explicit types (`var` only when RHS-obvious) · `is null` /\n`is not null` · pattern matching + switch expressions · `ArgumentNullException.ThrowIfNull` guards ·\nrecords for DTOs/events/value objects · `default!` for required non-nullable strings. Build runs with\n`TreatWarningsAsErrors` — warnings fail the build.\n\n## Adding things (quick pointers)\n\n- **Feature** — Contracts command/query → handler → validator → endpoint → wire in module `MapEndpoints()` → tests. Details: `api-conventions.md`.\n- **Module** — new `Modules.{Name}` + `.Contracts`, implement `IModule` w/ assembly-level `[assembly: FshModule(typeof(XModule), order)]`, register in **all four places**, add migration folder + tests. Details: `architecture.md`.\n- **React page** — API module (`src/api/`) → page → register lazy route → (admin) mirror permission + RouteGuard → Playwright test. Details: `frontend/shared.md`.\n\n## AI tooling resources\n\n- **Rules** — `.agents/rules/*.md` (indexed above). Read on demand.\n- **Skills** — `.agents/skills/*/SKILL.md`: step-by-step task recipes. Scaffolders: `add-feature`, `add-entity`, `add-module`, `add-react-page`, `add-full-slice`. Ops: `create-migration`, `add-integration-event`, `add-permission`. Reference: `query-patterns`, `testing-guide`, `mediator-reference`.\n- **Workflows** — `.agents/workflows/*.md`: task playbooks (`code-reviewer`, `feature-scaffolder`, `module-creator`, `architecture-guard`, `migration-helper`).\n\n<!-- File: CLAUDE.md (Tokens: ~37 | Category: claude-rule) -->\n# Claude Code\n\nThe canonical project guide is **`AGENTS.md`** (tool-neutral). It is imported below; edit conventions there, not here.\n\n@AGENTS.md\n\n<!-- File: .agents/rules/api-conventions.md (Tokens: ~782 | Category: anthropic-skill) -->\n# API conventions\n\nRead before adding endpoints, commands/queries, validators, or error handling.\n\n## Endpoints\n\nStatic extension methods on `IEndpointRouteBuilder`, returning `RouteHandlerBuilder`. The handler delegates to Mediator. Gate with `.RequirePermission(...)`.\n\n```csharp\npublic static class RegisterUserEndpoint\n{\n    internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) =>\n        endpoints.MapPost(\"/register\", (RegisterUserCommand command,\n                IMediator mediator, CancellationToken cancellationToken) =>\n                mediator.Send(command, cancellationToken))\n            .WithName(\"RegisterUser\")\n            .WithSummary(\"Register user\")\n            .RequirePermission(IdentityPermissionConstants.Users.Create);\n}\n```\n\n- **Always accept and forward `CancellationToken`** to `mediator.Send`. ASP.NET injects it.\n- Wire each endpoint in the module's `MapEndpoints()`. Endpoints group under `api/v{version:apiVersion}/{module}`.\n- Use `TypedResults` / `.Produces<T>(...)` for accurate OpenAPI. Add `.WithIdempotency()` on POSTs that must be replay-safe.\n\n## CQRS\n\n- **Commands/Queries** live in `Modules.{Name}.Contracts` — implement `ICommand<TResponse>` / `IQuery<TResponse>`. Records preferred.\n- **Handlers** live in `Modules.{Name}/Features/` — `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on awaits.\n- Paginated queries implement `IPagedQuery` (`PageNumber`, `PageSize`, `Sort`) and return `PagedResponse<T>`.\n\n## Validation\n\nFluentValidation, auto-registered by `ModuleLoader`. Name `{Command}Validator`. Live in the same feature folder.\n\n- **Every command handler needs a validator; every paginated query handler needs one too.** Enforced by `Architecture.Tests` (`HandlerValidatorPairingTests`). A handler legitimately without rules can be added to that test's known-missing allowlist, but prefer writing the validator.\n- Validators run via the `ValidationBehavior<,>` Mediator pipeline before the handler.\n\n## Exceptions → ProblemDetails\n\nThrow framework exception types; the global handler converts to RFC 9457 `ProblemDetails`:\n\n| Throw | HTTP |\n|---|---|\n| `NotFoundException` | 404 |\n| `ForbiddenException` | 403 |\n| `UnauthorizedException` | 401 |\n| `CustomException(msg, errors?, HttpStatusCode)` | as specified (default 400) |\n\nDon't catch broadly to swallow. Background loops may `catch (Exception)` to stay alive, but must **log with context** and exclude `OperationCanceledException` (filtered catch or a preceding `catch (OperationCanceledException)`).\n\n## Permissions\n\nConstants in `Shared/Identity/*Permissions.cs` (e.g. `IdentityPermissionConstants`). Apply with `.RequirePermission(...)` on the endpoint. `RequiredPermissionAttribute` implements `IRequiredPermissionMetadata` — never let a duplicate of that interface appear; it silently disables **all** `.RequirePermission()` gates.\n\n## Specifications\n\nUse `Specification<T>` (`src/BuildingBlocks/Persistence/Specifications/`) for query composition. Default `AsNoTracking = true` — see `database.md` for when tracking is required instead.\n\n## Adding a feature (checklist)\n\n1. Command/query + response in `Modules.{Name}.Contracts/v1/{Area}/{Feature}/`.\n2. Handler in `Modules.{Name}/Features/v1/{Area}/{Feature}/`.\n3. Validator in the same folder.\n4. Endpoint in the same folder; wire in module `MapEndpoints()`.\n5. Tests in `Tests/{Name}.Tests/` (+ integration test if it touches DB/IO).\n\n<!-- File: .agents/rules/architecture.md (Tokens: ~1503 | Category: anthropic-skill) -->\n# Architecture rules\n\nModular Monolith + Vertical Slice Architecture (VSA). Read this before adding/moving modules or touching wiring.\n\n## Layers & dependency direction\n\n```\nHost (composition root)  →  Modules.{Name} (runtime)  →  Modules.{Name}.Contracts (public API)\n                         →  BuildingBlocks (shared framework)\n```\n\n- **BuildingBlocks** (`src/BuildingBlocks/`) — Core, Persistence, Web, Caching, Eventing, Storage, Quota, Jobs, Mailing, Shared. Consumed by all modules. **Do not modify without explicit approval.**\n- **Modules** (`src/Modules/{Name}/`) — bounded contexts. Each = a runtime project (internal) + a `.Contracts` project (public API: commands, queries, events, DTOs, service interfaces).\n- A module **MUST NOT** reference another module's runtime project — only its `.Contracts`. Enforced by `Architecture.Tests` (NetArchTest).\n\n## Module = runtime + Contracts\n\n```\nModules.Identity/            ← runtime (internal): handlers, services, domain, data\nModules.Identity.Contracts/  ← public: ICommand/IQuery types, DTOs, events, service interfaces\n```\n\nCross-module communication: through Contracts service interfaces or integration events only.\n\n## Feature folder layout (VSA)\n\nEach feature is a vertical slice in `Features/v{version}/{Area}/{Feature}/`:\n\n```\nFeatures/v1/Users/RegisterUser/\n├── RegisterUserEndpoint.cs          # minimal API endpoint\n├── RegisterUserCommandHandler.cs    # CQRS handler (public sealed)\n└── RegisterUserCommandValidator.cs  # FluentValidation\n```\n\nModule support folders: `Domain/`, `Data/`, `Services/`, `Events/`, `Authorization/`.\n\n## IModule registration\n\nEach module implements `IModule`, declared via an **assembly-level** `[FshModule]` attribute (positional `(Type moduleType, int order = 0)`) — **not** a class-level `[FshModule(Order = n)]`:\n\n```csharp\n[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 1)]   // above the namespace\n\nnamespace FSH.Modules.Identity;\n\npublic sealed class IdentityModule : IModule\n{\n    public void ConfigureServices(IHostApplicationBuilder builder) { ... }\n    public void ConfigureMiddleware(IApplicationBuilder app) { ... }   // optional, runs AFTER UseAuthentication\n    public void MapEndpoints(IEndpointRouteBuilder endpoints) { ... }\n}\n```\n\n`ModuleLoader.AddModules` (`src/BuildingBlocks/Web/Modules/ModuleLoader.cs`) discovers `[FshModule]` attributes, orders by `Order` then name, instantiates each, and calls `ConfigureServices`. Endpoints map under `api/v{version:apiVersion}/{module}`.\n\n## ⚠️ The four-place registration footgun\n\nAdding a module requires editing **four** lists. Miss one and it fails *silently*:\n\n| Place | File | Symptom if missed |\n|---|---|---|\n| Mediator `o.Assemblies` (two markers: Contracts type **and** module type) | `src/Host/FSH.Starter.Api/Program.cs` | Handlers silently undiscovered |\n| `moduleAssemblies` array | `src/Host/FSH.Starter.Api/Program.cs` | Module never loaded |\n| Mediator assemblies (same pair) | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |\n| module assemblies array | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |\n\nAfter wiring, the fastest sanity check is: build, hit the endpoint, confirm the handler runs.\n\n## DI & handler conventions\n\n- Mediator handlers: `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on every await. `ServiceLifetime.Scoped`.\n- Validators auto-register via `ModuleLoader` (`AddValidatorsFromAssemblies`). Name them `{Command}Validator`.\n- Prefer constructor injection / primary constructors. Watch DI lifetimes: stateful singletons must be thread-safe (use `ConcurrentDictionary` / immutable snapshots).\n\n## Middleware ordering (critical)\n\nIn `src/BuildingBlocks/Web/Extensions.cs` (`UseHeroPlatform`):\n\n1. ExceptionHandler → ResponseCompression\n2. **CORS before HTTPS redirect** (so OPTIONS preflight isn't 307-redirected)\n3. HttpsRedirection → SecurityHeaders → static files → Routing\n4. **`UseAuthentication`**\n5. **`UseHeroLocalization`** — request localization, sits **between `UseAuthentication` and `UseAuthorization`** so the user-`locale`-claim culture provider can read `HttpContext.User`\n6. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth\n7. RateLimiting → Quotas → `UseAuthorization` → `MapModules`\n\n`app.UseHeroMultiTenantDatabases()` (Finbuckle `UseMultiTenant()`) runs in `Program.cs` **before** `UseHeroPlatform`, i.e. **before `UseAuthentication`**. Finbuckle's `ClaimStrategy` authenticates on its own, so authenticated requests resolve by tenant claim and anonymous ones by the `tenant` header. See `modules/multitenancy.md`.\n\n## Static/global state\n\nNo global mutable static collections enumerated under concurrency. `Audit` (Auditing module) swaps an immutable `IAuditEnricher[]` atomically; `ModuleLoader` guards with a lock. Follow that pattern if you must hold process-global state.\n\n## Configuration & options\n\n- `appsettings.json` (+ `.Development`/`.Production`) live in `src/Host/FSH.Starter.Api/`. DbMigrator links the same files.\n- Bind config with the Options pattern: `AddOptions<T>().BindConfiguration(nameof(T))`, section name == type name (e.g. `JwtOptions`, `DatabaseOptions`, `CachingOptions`, `CorsOptions`, `QuotaOptions`, `RateLimitingOptions`; **storage section is `Storage`**, not `StorageOptions`). Add `.ValidateDataAnnotations().ValidateOnStart()` for fail-fast.\n- Validate critical options via `IValidatableObject` — `JwtOptions` requires `SigningKey` ≥32 chars and **rejects placeholder strings containing `\"replace-with\"`**; `DatabaseOptions` rejects empty connection strings.\n- **Production fail-fast** (`Program.cs`, before service registration): missing `DatabaseOptions:ConnectionString`, `CachingOptions:Redis`, or `JwtOptions:SigningKey` throws. Dev secrets via `dotnet user-secrets` (AppHost has a `UserSecretsId`); RustFS (S3) creds are Aspire secret parameters.\n- Platform composition is one call each: `builder.AddHeroPlatform(o => { o.Enable... })` (DI) and `app.UseHeroPlatform(...)` (middleware). Feature flags toggle Caching/Jobs/Mailing/Quotas/Sse/Realtime/OpenTelemetry/CORS/Idempotency.\n\n<!-- File: .agents/rules/buildingblocks-protection.md (Tokens: ~220 | Category: anthropic-skill) -->\n---\npaths:\n  - \"src/BuildingBlocks/**/*\"\n---\n\n# ⚠️ BuildingBlocks Protection\n\n**STOP. You are modifying BuildingBlocks.**\n\nChanges to BuildingBlocks affect ALL modules across the entire framework. These are core abstractions that many projects depend on.\n\n## Before Proceeding\n\n1. **Confirm explicit approval** - Has the user specifically approved this change?\n2. **Consider alternatives** - Can this be done in the module instead?\n3. **Assess impact** - What modules will this affect?\n\n## If Approved\n\n- Make minimal, focused changes\n- Ensure backward compatibility\n- Update all affected modules\n- Run full test suite: `dotnet test src/FSH.Starter.slnx`\n- Document the change\n\n## Alternatives to Consider\n\n| Instead of... | Consider... |\n|---------------|-------------|\n| Modifying Core | Extension method in module |\n| Changing Persistence | Custom repository in module |\n| Updating Web | Module-specific middleware |\n\n## If Not Approved\n\nDo not proceed. Suggest alternatives that don't require BuildingBlocks modifications.\n\n<!-- File: .agents/rules/caching.md (Tokens: ~475 | Category: anthropic-skill) -->\n# Caching\n\n`src/BuildingBlocks/Caching/`. Read before adding cached reads or invalidation.\n\n## What's registered\n\n`AddHeroCaching(config)` always registers **`HybridCache`** (L1 in-memory + optional L2 Redis). Inject `HybridCache`, not `IDistributedCache`.\n\n- `CachingOptions.Redis` empty → in-memory only (dev fallback). Set → a **single shared `ConnectionMultiplexer`** (singleton `IConnectionMultiplexer`) backs both the L2 cache and the DataProtection key ring.\n- Defaults: total expiration 1h, L1 (local) expiration 2min (`CachingOptions`).\n- `ObservableHybridCache` transparently decorates `HybridCache` to emit OpenTelemetry (hits/misses/factory-duration/invalidations). You don't reference it — just inject `HybridCache`.\n\n## Pattern\n\n```csharp\nvar perms = await cache.GetOrCreateAsync(\n    CacheKeys.UserPermissions(userId),\n    async ct => await LoadPermissionsAsync(userId, ct),\n    tags: [CacheKeys.Tags.Permissions, CacheKeys.Tags.User(userId)],\n    cancellationToken: ct);\n```\n\n- **Keys & tags live in `CacheKeys.cs`** — add new keys/tags there, don't inline strings. Existing: `UserPermissions(userId)`, `TenantTheme(tenantId)`, `IdempotencyEntry(tenantId,key)`, `ImpersonationGrantStatus(jti)`; tags `Permissions`, `Themes`, `Idempotency`, `Tenant(id)`, `User(id)`.\n- Invalidate with `RemoveAsync(key)` or `RemoveByTagAsync(tag)` in the relevant mutation handler.\n- `GetOrCreateAsync` gives **stampede protection** for free (factory runs once per key).\n\n## Gotchas\n\n- **No L1 backplane.** `RemoveByTagAsync` on one node does **not** evict L1 on peer nodes — cross-node staleness is bounded only by the 2-min local expiration. Don't rely on instant cross-node invalidation; keep local expiration short for hot, mutable data.\n- Don't reach for `IDistributedCache` directly except where the framework already does so deliberately (idempotency probe-read) — prefer `HybridCache`.\n\n<!-- File: .agents/rules/database.md (Tokens: ~822 | Category: anthropic-skill) -->\n# Database & EF Core conventions\n\nRead before touching entities, DbContexts, migrations, or query filters.\n\n## Entities\n\n- `BaseEntity` — `Id`, `CreatedAt`, `UpdatedAt`, `TenantId`.\n- `AggregateRoot` — `BaseEntity` + domain events (`IHasDomainEvents`, `_domainEvents` list).\n- Marker interfaces: `IHasTenant`, `IAuditableEntity`, `ISoftDeletable`, `IGlobalEntity`.\n- Domain events inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Integration events implement `IIntegrationEvent`; handlers `IIntegrationEventHandler<T>`.\n\n## Tenant isolation (default-ON)\n\n- `BaseDbContext` auto-applies a tenant query filter to every entity. **Isolation is on by default.**\n- Opt out **only** via `IGlobalEntity` (e.g. `BillingPlan`, `ImpersonationGrant`, `Outbox`/`InboxMessage`).\n- A subclass DbContext that overrides `OnModelCreating` **must call `base.OnModelCreating(modelBuilder)` LAST**, or the auto-applied filters are lost.\n- Cross-tenant reads use `IgnoreQueryFilters()` **plus an explicit re-filter** — never rely on the absence of the filter.\n- **Query-filter naming:** SoftDelete filter is *named*; the tenant filter stays *anonymous* (Finbuckle owns it). Don't rename the tenant filter.\n\n## AsNoTracking — and when NOT to\n\n- Read-only queries: add `.AsNoTracking()` (Specifications default to it).\n- **Do NOT add `AsNoTracking()` to a read-then-mutate-then-`SaveChanges` query** — the entity must stay tracked or your changes won't persist. The analyzer (AP010) flags these as a smell, but for mutate-and-save flows it is a false positive — leave them tracked.\n- `AnyAsync(...)` materializes no entity, so `AsNoTracking()` there is a no-op — skip it.\n\n## Value generation for nav-collection children\n\nA child entity reached **only** through a parent's navigation collection needs `Property(x => x.Id).ValueGeneratedNever()` in its EF config — otherwise EF treats it as `Modified` instead of `Added` and the insert silently misbehaves.\n\n## Migrations\n\nAll migrations live in **one** project, `src/Host/FSH.Starter.Migrations.PostgreSQL`, organized **per-module by folder** (`Identity/`, `Catalog/`, `Chat/`, …), each with its own `{Module}DbContextModelSnapshot`.\n\n```bash\ndotnet ef migrations add {Name} \\\n  --project src/Host/FSH.Starter.Migrations.PostgreSQL \\\n  --startup-project src/Host/FSH.Starter.Api \\\n  --context {Module}DbContext\n```\n\n- **`migrations remove` operates on the snapshot** — run a full build *before* `migrations add` so the snapshot is current, or you can lose the previous migration.\n- The DB is **not** migrated at API startup. The `DbMigrator` host is a separate step: `apply` (default), `seed`, `seed-demo` (dev only), `list-pending`; flags `--tenant <id>`, `--catalog-only`, `--seed`. It migrates the tenant catalog first, then each tenant's per-module schema, serialized by a Postgres advisory lock.\n- `dotnet-ef` is pinned in `.config/dotnet-tools.json` — run `dotnet tool restore` first.\n\n## Tests + EF\n\n- Integration tests use Testcontainers (real PostgreSQL) — **Docker must be running**.\n- In integration tests, set the Finbuckle tenant context **inline in the same method** as the `UserManager`/`DbContext` call; an awaited-helper set is lost (AsyncLocal) and the tenant query filter NREs.\n\n<!-- File: .agents/rules/eventing.md (Tokens: ~1687 | Category: anthropic-skill) -->\n# Eventing — domain events, integration events, Outbox/Inbox\n\nRead before publishing/handling cross-module events. `src/BuildingBlocks/Eventing/`.\n\n## Two tiers\n\n- **Domain events** (in-process, pre-commit) — inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Raised on aggregates (`IHasDomainEvents`).\n- **Integration events** (cross-module, async) — implement `IIntegrationEvent` (`Id`, `OccurredOnUtc`, `TenantId`, `CorrelationId`, `Source`). Handlers implement `IIntegrationEventHandler<T>` (single `HandleAsync(T, ct)`), are `sealed`, live in `Events/` or `IntegrationEventHandlers/`.\n\n## The Outbox is the only way to publish\n\n**Do not call `IEventBus` directly from a handler.** Publish via the outbox so the event commits with the business write and survives a crash:\n\n```csharp\nawait _outbox.AddAsync(integrationEvent, ct).ConfigureAwait(false);   // IOutboxWriter\n```\n\nInject **`IOutboxWriter`** (`FSH.Framework.Eventing.Abstractions`) — the publish-side contract, and all a module ever needs. `IOutboxStore` is the full dispatcher-side surface and lives in the eventing runtime, which modules don't reference.\n\n`EfCoreOutboxStore.AddAsync` serializes + `SaveChanges` immediately, joining the caller's transaction when there is one. `OutboxDispatcherHostedService` polls every `OutboxDispatchIntervalSeconds` (default 10), `OutboxDispatcher` **claims** a batch (`OutboxBatchSize`, default 100), publishes via `IEventBus`, and dead-letters after `OutboxMaxRetries` (default 5) → `IsDead`. Failures back off exponentially (`NextRetryAt`); `RedriveDeadLettersAsync` recovers dead rows. `OutboxMessage`/`InboxMessage` are `IGlobalEntity` (no tenant filter — the dispatcher has no tenant context; `TenantId` is an explicit column).\n\n**Publishing is asynchronous.** The consumer runs on the next dispatch cycle, not inside the request. Don't write a caller — or a test — that assumes the side effect already happened. Integration tests drain explicitly via `OutboxDrain.DrainAsync`.\n\n**The one documented exception:** Chat `SendMessageCommandHandler` still publishes mentions on the bus, because the Notifications handler pushes over SignalR and a delayed mention badge reads as broken. Adding a second exception needs the same kind of reason, in a comment at the call site.\n\n## One store, owned by the framework\n\n`OutboxMessages`/`InboxMessages` live in schema `framework`, owned by `EventingDbContext` (`src/BuildingBlocks/Eventing/Persistence/`) — **not** by any module's context. That is what keeps `IOutboxStore`/`IInboxStore` to a single, non-keyed DI registration: registering them per module DbContext made .NET DI resolve whichever module registered last for the whole application, so a second module publishing broke every module's outbox (issue #1349). `EventingRegistrationTests` guards the registration count; don't add a second one.\n\n`EventingDbContext` derives from `BaseDbContext`, so a tenant with a dedicated database gets its outbox rows in that database, next to the business data they accompany.\n\n## Dispatch across tenant databases\n\nBecause rows follow the tenant connection, the dispatcher can't just poll one database. Each cycle it asks `IEventingDrainTargetProvider` for the drain targets and runs one pass per target inside `IEventingDrainScope`, which installs the tenant context **before** the scope's `EventingDbContext` is built (it captures `TenantInfo`, and with it the connection string, at construction).\n\nDefaults in BuildingBlocks are single-database (`SingleDatabaseDrainTargetProvider`, `NullEventingDrainScope`); the multitenancy module replaces them with `TenantStoreDrainTargetProvider` (default DB + one target per distinct **active** per-tenant connection string; tenants sharing a database collapse to one target) and `FinbuckleEventingDrainScope`. One unreachable tenant database is logged and skipped, not fatal to the cycle.\n\n## Multi-instance safety\n\n`ClaimBatchAsync` leases rows with `FOR UPDATE SKIP LOCKED` in a single `UPDATE … RETURNING`, so several API instances partition a batch instead of all publishing the same message. `ClaimedUntilUtc` is an expiry (`OutboxClaimLeaseSeconds`, default 300), so a dispatcher that dies mid-batch has its rows recovered rather than stranded — raise it if a batch can take longer than the lease, or a second instance re-claims rows still in flight. Completing or failing a message releases the lease. Non-Postgres providers have no portable `SKIP LOCKED`: they fall back to an unclaimed read and log a warning that only one instance is safe.\n\n## Atomicity\n\n`IScopedDbConnectionProvider` gives every DbContext in a DI scope the same `DbConnection`, which is the only way EF Core can enlist a second context in a transaction another one opened (Npgsql has no distributed-transaction promotion). `AmbientDbTransactionRegistry` — an `IDbTransactionInterceptor` on every Hero context — records open transactions, since `DbConnection` can't be asked. `AddAsync` joins the ambient transaction when there is one, so the outbox row commits or rolls back with the business data; with none, it commits on its own exactly as before.\n\n## Idempotency is free (in-memory bus)\n\n`InMemoryEventBus` resolves handlers in a fresh DI scope and applies the **Inbox**: skips if `IInboxStore.HasProcessedAsync(eventId, handlerName)`, marks processed after success. Composite key `{Id, HandlerName}`; concurrent-insert race is swallowed. Don't hand-roll dedup.\n\n## Wiring\n\nThe **host** bootstraps eventing once (`FSH.Starter.Api/Program.cs` and `FSH.Starter.DbMigrator/Program.cs`, before `AddModules` so `EventingDbInitializer` migrates the `framework` schema first):\n\n```csharp\nbuilder.Services.AddEventingCore(builder.Configuration);   // serializer + bus + dispatcher + EventingDbContext + stores\n```\n\nA **module** only registers its handlers:\n\n```csharp\nservices.AddIntegrationEventHandlers(typeof(MyModule).Assembly);        // scans IIntegrationEventHandler<>\n```\n\nThere is no per-module store registration — `AddEventingForDbContext<T>` was removed in #1349. A module publishes by injecting `IOutboxWriter`; nothing else is needed.\n\nBus = `EventingOptions.Provider`: `\"RabbitMQ\"` → `RabbitMqEventBus` (durable topic exchange); else `InMemoryEventBus` (default).\n\n## Gotchas\n\n- **Renaming/moving an integration event type breaks deserialization** — the outbox stores the assembly-qualified type name; `Type.GetType()` returns null → the message dead-letters. Keep event type names/namespaces stable, or migrate dead rows.\n- **Background handlers carry no HTTP/tenant context.** An open-generic or background handler that reads a tenant-filtered DbContext must restore Finbuckle context first via `IMultiTenantContextSetter` (see `WebhookFanoutHandler`, `modules/webhooks.md`).\n- In-memory bus runs handlers **synchronously in the publisher's scope** — keep handler work minimal; exceptions surface to the originating request (relevant for Notifications consuming Chat events). Via the outbox that scope is the dispatcher's, not the request's.\n- Set `UseHostedServiceDispatcher=false` to drive the outbox via Hangfire instead of the hosted service.\n- A background publisher must set the tenant context **before** `AddAsync` — otherwise, with per-tenant databases, the row lands in the wrong one (see `TenantExpiryScanJob`).\n\n<!-- File: .agents/rules/frontend/admin.md (Tokens: ~645 | Category: anthropic-skill) -->\n# Frontend — admin app (`clients/admin`)\n\nOperator/SuperAdmin-facing console. Read `frontend/shared.md` first; this file is only the divergences.\n\n- **Port** 5173 · dev proxy target `http://localhost:5030` (HTTP) · localStorage prefix `fsh.admin.*` · login header `X-FSH-App: admin`.\n- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, dashboardUrl }`. `dashboardUrl` is used for the one-way impersonation handoff into the dashboard app.\n\n## Forms — react-hook-form + zod\n\nAdmin is the **only** app with `react-hook-form` + `zod` + `@hookform/resolvers`. Use them:\n\n```ts\nconst form = useForm<Schema>({ resolver: zodResolver(schema) });\n```\n\nForm layout primitives live in `src/components/list/` (`PageHeader`, `Field`, `FormShell`, `FormSection`, `FormActions`, `Pagination`, `ErrorBand`).\n\n## Permissions — fetched, hydrated, gated\n\n- The JWT carries only role names. Admin fetches the permission set separately: `GET /api/v1/identity/permissions` (`getMyPermissions`), cached under `fsh.admin.permissions`.\n- `AuthProvider` hydrates them in an effect keyed on subject change and exposes `permissionsHydrated` to avoid a 403 flash on first paint.\n- **Route gating:** wrap gated route elements in `<RouteGuard perms={[IdentityPermissions.Users.View]}>…</RouteGuard>`. It renders a \"Resolving permissions\" state while `!permissionsHydrated`, else `<ForbiddenView missing={…}/>`. (`ProtectedRoute` also accepts a `permissions?` prop.)\n- **Mirror server permissions by hand** in `src/lib/permissions.ts` (`IdentityPermissions`, `MultitenancyPermissions`, … frozen objects + `PERMISSION_CATALOG` driving the role editor). There is intentionally **no** runtime catalog fetch — when the server adds a permission, mirror the constant here.\n\n## Routing & realtime\n\n- Routes wrap elements in `<RouteGuard perms={…}>` (no per-route Suspense wrapper).\n- `RealtimeProvider` is mounted in `App.tsx` and wires only `[\"NotificationCreated\"]`.\n\n## Theme\n\nCool-cast neutrals (hue 240, small non-zero chroma — **not** chroma 0), a single fixed chartreuse \"signal\" accent (`--accent-signal` / `--signal-500`), Geist / Geist Mono fonts. Defined in `src/styles/globals.css`.\n\n## Add-a-page deltas (on top of shared steps)\n\n- Use RHF + zod for any form.\n- If the endpoint requires a permission, mirror the constant in `src/lib/permissions.ts` (and `PERMISSION_CATALOG` if it belongs in the role editor) and wrap the route in `<RouteGuard perms={[…]}>`.\n- Playwright: `seedAuthedSession` here also pre-seeds `fsh.admin.permissions` so `RouteGuard` passes on first paint.\n\n<!-- File: .agents/rules/frontend/dashboard.md (Tokens: ~890 | Category: anthropic-skill) -->\n# Frontend — dashboard app (`clients/dashboard`)\n\nTenant-facing application. Read `frontend/shared.md` first; this file is only the divergences.\n\n- **Port** 5174 · dev proxy target `https://localhost:7030` (HTTPS, with `ws: true` for the SignalR hub) · localStorage prefix `fsh.dashboard.*` · login header `X-FSH-App: dashboard`.\n- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, demoMode }`.\n- Dev-proxy is HTTPS on purpose: routing the bearer token through an HTTP→HTTPS 307 redirect stripped the `Authorization` header.\n\n## No RHF/zod — hand-rolled forms\n\nThe dashboard does **not** depend on react-hook-form or zod. Use controlled inputs + local state. Don't add those deps to match admin.\n\n## Permissions — fetched, not in the JWT\n\nThe JWT carries **only role names**. `auth-context.tsx` fetches the effective permission list from `GET /api/v1/identity/permissions` (`getMyPermissions()` in `src/api/identity.ts`), caches it in `tokenStore` under `fsh.dashboard.permissions`, and exposes a `permissionsHydrated` flag so gated UI doesn't flash while the fetch is in flight (re-fetched on login/impersonation swaps; `refreshPermissions()` for role changes). Nav items are gated via `perm`/`anyPerm` in `src/components/layout/nav-data.ts`. `ProtectedRoute` is still **auth-only** (no per-route permission gating) — don't add `RouteGuard`-style gating here.\n\n## Routing & realtime/SSE\n\n- Every route element is wrapped in `withSuspense(node)` (per-route skeleton fallback). No per-route permission guards.\n- `RealtimeProvider` **and** `SseProvider` are mounted inside `AppShell` (authenticated routes only), under a `CommandPaletteProvider` (cmdk).\n- SignalR provider pre-wires ~11 chat/notification events.\n- **SSE** (`src/sse/`, dashboard-only): two-step token — `POST /api/v1/sse/token`, then `GET /api/v1/sse/stream?token=<guid>` consumed via fetch streaming (`parseSseStream` async generator; EventSource can't send auth headers). **Two split contexts:** `useSseStatus()` (stable, for status dots) vs `useSseEvents()` (mutates per event) to avoid cascading re-renders; `useSse()` is the composite.\n\n## Impersonation\n\n`token-store.ts` has `beginImpersonation` / `endImpersonationWithFreshTokens` / `restoreStashedActor` that stash the operator's tokens under `fsh.dashboard.impersonation.*`. `AuthProvider` exposes `beginImpersonation`/`stopImpersonation` and derives `ImpersonationInfo` from `act_sub` / `act_tenant` / `act_name` claims. Admin triggers the handoff one-way via its `dashboardUrl`.\n\n## Performance\n\n- `@tanstack/react-virtual` for long lists — use it for any large collection (chat history, big tables).\n- `cmdk` powers the command palette.\n\n## Theme\n\n**Chroma-0 neutrals** (`--neutral-*: oklch(L 0 0)` — untinted; the warm-paper tint was deliberately removed). Rose default brand with **swappable accent themes** via `.accent-{rose,indigo,violet,sky,emerald,amber}` classes that override the `--brand-*` oklch stops; saffron secondary; Figtree font. Defined in `src/styles/globals.css`. Keep neutrals at chroma 0.\n\n## Add-a-page deltas (on top of shared steps)\n\n- Hand-roll forms (no RHF/zod).\n- Wrap the route element in `withSuspense(<X/>)`; no permission guard.\n- If it consumes pushes: `useRealtimeEvent(\"EventName\", handler)` (register the name in `realtime-context.tsx`) or `useSseEvents()` for SSE.\n- Use `react-virtual` for long lists; keep neutrals chroma 0.\n\n<!-- File: .agents/rules/frontend/shared.md (Tokens: ~2019 | Category: anthropic-skill) -->\n# Frontend — shared conventions\n\nApplies to **both** `clients/admin` and `clients/dashboard`. Read this for any React work, then read\nthe app-specific file (`admin.md` / `dashboard.md`) for divergences.\n\nStack: React 19 · Vite 7 · TypeScript · TanStack Query v5 · React Router 7 · Radix UI · Tailwind v4 ·\nclass-variance-authority (shadcn-style) · `@microsoft/signalr`. Path alias `@` → `src` (`vite.config.ts`).\n\n## API client (`src/lib/api-client.ts`, `src/api/*`)\n\n- One fetch wrapper: `apiFetch<T>(path, init)`. No axios.\n- **Types are hand-written**, not generated. (`openapi-typescript` is declared in admin's devDeps but unused — there is no codegen step.) Define DTO `type`s and a `const BASE = \"/api/v1/...\"` in each `src/api/{feature}.ts`, with thin async functions calling `apiFetch`.\n- Auth header `Authorization: Bearer <access>` from `tokenStore.getAccessToken()` (unless `skipAuth:true`).\n- Tenant header (lowercase `tenant`) = `tokenStore.getTenant() ?? env.defaultTenant`, unless overridden per request.\n- Errors: non-OK parses RFC 9457 `application/problem+json` and throws `ApiRequestError(status, message, problem)`. 204/empty → `undefined`.\n- **Single-flight refresh:** on 401 with a refresh token, `POST /api/v1/identity/token/refresh` with `{token, refreshToken}`; a module-level `refreshPromise` dedupes concurrent refreshes; rotated token returns on `token` (not `accessToken`); the original request retries once.\n- Search endpoints: build `URLSearchParams` with **PascalCase** keys (`PageNumber`, `PageSize`, …) to match the API.\n\n## Env (`src/env.ts`) — runtime, not build-time\n\n`loadRuntimeConfig()` fetches `/config.json` once at boot (awaited in `main.tsx` before React mounts); `env` is a getter that throws if read too early. One built image promotes across environments (operator writes `config.json`). The only `VITE_*` var, `VITE_API_BASE_URL`, configures the **Vite dev proxy target only** — it is not the runtime apiBase (`config.json` ships `apiBase: \"\"`, relative).\n\n## Data fetching (TanStack Query v5)\n\n- Shared `queryClient` (`src/lib/query-client.ts`): `staleTime: 30_000`, `refetchOnWindowFocus:false`, no retry on 401/403 else `failureCount < 2`.\n- **Query keys are inline literal arrays**, hierarchical, params object last: `[\"users\", {pageNumber, searchTerm}]`, `[\"user\", id]`, `[\"user\", id, \"roles\"]`. No central key factory.\n- `useQuery`/`useMutation` live inline in page components. Invalidate in `onSuccess`: `queryClient.invalidateQueries({ queryKey: [\"users\"] })`. Pagination: `placeholderData: keepPreviousData`.\n\n### ⚠️ The `mutate(arg)` race-safe pattern (golden rule #9)\n\n`useMutation` reads its options at execute time, so values produced at call time (e.g. a fresh\n`crypto.randomUUID()` client id) must ride **through `mutate(arg)`** and be read from the `variables`\nargument of `onMutate`/`onSuccess`/`onError` — never from component state the callbacks close over,\nor two rapid calls collide.\n\n```ts\nmutation.mutate({ text, clientId: crypto.randomUUID() });\n// onMutate: ({ clientId }) => insert optimistic `temp:${clientId}`\n// onSuccess: (real, { clientId }) => swap temp → real\n// onError:   (_e, { clientId }) => rollback\n```\n\n## Routing (`routes.tsx`, `App.tsx`)\n\n- `createBrowserRouter`, flat config. Pages are **named exports** loaded via a `lazyNamed(importer, name)` helper (adapts named → `React.lazy`'s default contract). No default exports.\n- Nesting: public auth routes → `<ProtectedRoute/>` → `<AppShell/>` → page children. `errorElement: <RouteError/>`.\n- Provider tree: `ThemeProvider > QueryClientProvider > AuthProvider > … > RouterProvider` + `sonner` `<Toaster/>`.\n\n## Auth (`src/auth/`)\n\n`token-store.ts` (localStorage + pub/sub), `jwt.ts` (`decodeJwt`), `AuthProvider`/`useAuth()`, `ProtectedRoute`.\nLogin `POST /api/v1/identity/token/issue` with header `X-FSH-App: \"admin\"|\"dashboard\"`. **localStorage keys are namespaced per app** (`fsh.admin.*` / `fsh.dashboard.*`) so both run side-by-side. Permission *source* differs per app — see the app files.\n\n## Design system (Tailwind v4, shadcn-style)\n\n- **`cn()` is at `src/lib/cn.ts`** (`twMerge(clsx(...))`) — not `lib/utils.ts`. `components.json`: `style:new-york`, `baseColor:slate`, `cssVariables:true`, `iconLibrary:lucide`.\n- UI primitives in `src/components/ui/` are cva-based: `cva(base, { variants, defaultVariants })` + Radix `Slot`/`asChild` + `cn(buttonVariants({...}))`. Layout primitives in `src/components/list/` (admin) / similar (dashboard), re-exported from `index.ts`.\n- **Tailwind v4 is CSS-first — there is NO `tailwind.config`.** Configured via the `@tailwindcss/vite` plugin and one entrypoint `src/styles/globals.css` (imported in `main.tsx`). Tokens: `:root` oklch primitives → semantic vars → an `@theme inline { --color-*: var(--…) }` block exposing them as utilities. `@custom-variant dark (&:is(.dark *))`.\n- Add a new token in `globals.css` (primitive → semantic → `@theme inline`), then use the utility. Don't hard-code colors in components.\n\n### Design language (both apps differ on purpose)\n\n| | admin (operator) | dashboard (tenant) |\n|---|---|---|\n| Neutrals | cool-cast, hue 240, small non-zero chroma | **chroma 0** (untinted — the warm tint was removed) |\n| Accent | single fixed chartreuse \"signal\" (`--accent-signal`) | rose brand + **swappable** `.accent-{rose,indigo,violet,sky,emerald,amber}` |\n| Font | Geist / Geist Mono | Figtree (+ saffron secondary) |\n\nSo \"neutrals must be chroma 0\" is a **dashboard** rule. Admin neutrals are intentionally cool — match the file you're editing.\n\n## Realtime (SignalR)\n\n`src/realtime/realtime-context.tsx`: one `HubConnection` to `/api/v1/realtime/hub`. `@microsoft/signalr` is **dynamically imported** (lazy ~37KB) only when an authed session opens the hub. Auth via `accessTokenFactory`; a `tokenEpoch` (bumped by `tokenStore.subscribe`) forces reconnect on login/refresh/impersonation. Consume with `useRealtimeEvent(\"EventName\", handler, deps)` (handler kept in a ref). Pre-register new event names in the provider's event list.\n\n## Testing (Playwright, route-mocked)\n\n- `playwright.config.ts`: `testDir: ./tests`, chromium, auto-boots `npm run dev`, no real backend.\n- Tests in `tests/{area}/{name}.spec.ts`; helpers in `tests/helpers/`.\n- **JWT seeding:** `seedAuthedSession(page, TEST_USER)` builds a fake JWT and `addInitScript`-writes `fsh.{app}.*` to localStorage before React boots (server isn't called, so signature is junk).\n- **Route mocking:** `mockJsonResponse(page, urlGlob, body)` / `mockProblemDetails(...)`. `installShellMocks(page)` stubs every call `AppShell` fires and **aborts** SSE/SignalR. Playwright matches most-recently-registered first → broad shell mocks in `beforeEach`, page-specific mocks after (they win).\n- `beforeEach`: `seedAuthedSession(page, TEST_USER)` → `installShellMocks(page)`.\n\n## Add a page/feature (shared steps)\n\n1. API: extend `src/api/{feature}.ts` — hand-written types + `apiFetch` calls.\n2. Page: `src/pages/{area}/{name}.tsx`, **named** export. `useQuery` with hierarchical key; `useMutation` invalidating in `onSuccess`, passing per-call data via `mutate(arg)`.\n3. Route: add `const X = lazyNamed(() => import(\"@/pages/area/name\"), \"XPage\")` and a child route under `AppShell`.\n4. Test: `tests/{area}/{name}.spec.ts` with seed + shell mocks + page mocks.\n\nThen apply the app-specific steps in `admin.md` / `dashboard.md` (forms, permission gating, suspense, etc.).\n\n<!-- File: .agents/rules/integration-testing.md (Tokens: ~433 | Category: anthropic-skill) -->\n# Integration testing\n\n`src/Tests/Integration.Tests/` + `Integration.Middleware.Tests/`. Read before writing tests that touch the DB/HTTP pipeline. See `testing.md` for unit conventions.\n\n## Harness\n\n`WebApplicationFactory` over **real** infra via Testcontainers — PostgreSQL + Redis + RustFS (S3-compatible, generic container). **Docker must be running**; if it isn't, tests fail fast with `DockerUnavailableException` (environmental, not a regression — run the unit projects instead).\n\n`FshWebApplicationFactory` (`Integration.Tests/Infrastructure/`) boots the containers, overlays in-memory config, swaps `IMailService` → `NoOpMailService`, and rewires storage to RustFS.\n\n## Must-know gotchas\n\n- **Tenant context is AsyncLocal — set it inline.** Set the Finbuckle tenant context **in the same method** as the `UserManager`/`DbContext` call. Setting it in an awaited helper loses it across the async boundary → NRE in the tenant query filter.\n- **Storage is wired eagerly.** `AddHeroStorage` reads `Storage:Provider` before the test config overlay, so it picks `LocalStorageService`. The factory **removes the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-registers the S3 stack** at RustFS. Follow that when a test needs real object storage. (See `storage.md`.)\n- **SignalR tests force long-polling** — TestServer has no WebSocket. Configure the client transport accordingly.\n- **Rate limiting is read eagerly** — `Integration.Middleware.Tests` sets `RateLimitingOptions:Enabled` via env var **before** host build, since flipping it after has no effect.\n\n## Coverage\n\n```bash\ndotnet test --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\nCobertura; includes `[FSH.Modules.*]` + `[FSH.Framework.*]`; excludes tests, the Migrations project, and `*HostedService`.\n\n<!-- File: .agents/rules/jobs.md (Tokens: ~477 | Category: anthropic-skill) -->\n# Background jobs (Hangfire)\n\n`src/BuildingBlocks/Jobs/`. Read before enqueuing or scheduling work.\n\n## Fire-and-forget / scheduled — `IJobService`\n\nInject `IJobService` (`Jobs/Services/IJobService.cs`) and use it; don't call Hangfire's `BackgroundJob` directly in feature code.\n\n```csharp\njobService.Enqueue(() => mailService.SendAsync(req, CancellationToken.None));   // default queue\njobService.Enqueue(\"email\", () => mailService.SendAsync(req, CancellationToken.None));\njobService.Schedule(() => DoLater(), TimeSpan.FromMinutes(5));\n```\n\nQueues: `default`, `email` (5 workers, 30s poll). Storage auto-selected from `DatabaseOptions.Provider` (Postgres/MSSQL).\n\n## Recurring jobs — `IRecurringJobManager`\n\n`IJobService` has **no** recurring API. Register recurring jobs in the module's `MapEndpoints` with `IRecurringJobManager.AddOrUpdate<T>(...)`, always `TimeZoneInfo.Utc`:\n\n```csharp\nrecurringJobs.AddOrUpdate<PurgeOrphanedFilesJob>(\"files:purge-orphaned\",\n    j => j.RunAsync(CancellationToken.None), Cron.Hourly(), new() { TimeZone = TimeZoneInfo.Utc });\n```\n\nExamples in the tree: `PurgeOrphanedFiles`/`PurgeDeletedFiles` (Files), `MonthlyInvoiceJob` (Billing), `AuditRetentionJob` (Auditing), `WebhookDispatchJob` (Webhooks).\n\n## Dashboard & config\n\n`/jobs` (default), behind `HangfireOptions.UserName`/`Password` basic auth — both `[Required]`, password `[MinLength(12)]`, so **startup fails in non-dev if unset**.\n\n## Gotchas\n\n- Jobs run on the server with **no HTTP/tenant context** — restore Finbuckle tenant context inside the job (fresh scope + `IMultiTenantContextSetter`) before touching a tenant-filtered DbContext.\n- The DbMigrator registers `NoOpJobService` whose methods **throw** — surfaces any accidental enqueue during migration. Don't enqueue from migration/seed paths.\n- A job class is a normal DI-resolved type (scope-per-job via `FshJobActivator`); inject what you need.\n\n<!-- File: .agents/rules/localization.md (Tokens: ~1961 | Category: anthropic-skill) -->\n# Localization (i18n)\n\n`src/BuildingBlocks/Core/Localization/` + per-module `Localization/` folders. Read before adding any user-facing message (exception, validation, API error). **The client's culture decides which words the client reads; it never decides how the API formats numbers or dates.**\n\n## Culture negotiation (already wired — don't re-add)\n\n`AddHeroLocalization()` / `UseHeroLocalization()` (`BuildingBlocks/Web/Localization/`) negotiate the request **UI** culture in this order: `?culture=` query → `locale` JWT claim (`UserLocaleRequestCultureProvider`) → `Accept-Language` → configured default → `en-US`. Supported tags live in `SupportedCultures.Tags`. The culture is set before endpoints and the exception handler run, so any `IStringLocalizer` resolved downstream picks up the request culture automatically.\n\n**`CultureInfo.CurrentUICulture` only — `CurrentCulture` stays invariant.** An API that emits JSON must not shift `ToString()`, `Parse()` or interpolation per request; both React apps format at the presentation layer. `RequestLocalizationMiddleware` assigns both cultures unconditionally, so the culture half is pinned rather than left alone: `DefaultRequestCulture` carries `(InvariantCulture, configured default)` and `SupportedCultures` is `null` so the middleware skips culture filtering. Do **not** \"fix\" this by adding `AddSupportedCultures(...)`; `Formatting_culture_stays_invariant_while_ui_culture_negotiates` fails if you do.\n\n`SupportedCultures.Tags` is **specific tags only**, no neutrals. A request asking for a bare `pt`, or for an unsupported variant like `pt-PT`, resolves to the configured default rather than being served a language it was not translated into. The React apps canonicalize variants onto supported tags (`CANON` in `clients/*/src/i18n.ts`) before calling the API, so app traffic is unaffected; a hand-rolled client sending bare `pt` gets the default. Adding a language means: add its specific tag to `Tags`, add a `*.{tag}.resx` per catalog, add its JSON catalogs to both apps, and remove it from `CANON` if it was being folded into another tag.\n\n## Catalogs — hybrid, one marker per catalog\n\n- **Core (`SharedResources`)** — generic / cross-cutting messages: ProblemDetails titles (`Error.*`), cross-module errors (`Error.TenantContextRequired`, `Error.NoCurrentUser`, …), and shared validation (`Validation.*`).\n- **Per module (`<Module>Resources`)** — domain-specific messages owned by the module: `src/Modules/<Module>/Modules.<Module>/Localization/<Module>Resources.cs` (marker `public sealed class <Module>Resources;`) + co-located `<Module>Resources.resx` (neutral / en-US) + an optional `<Module>Resources.pt-BR.resx`. `ResourcesPath = \"\"` (co-located), so the resx manifest name must equal the marker's full type name.\n\nCatalogs are named for **specific** cultures (`.pt-BR`, never a neutral `.pt`), matching the front-end catalog folders. The neutral, un-suffixed `.resx` is the en-US / ultimate-fallback catalog.\n\nKey naming: `Error.<Module>.<Case>` for domain messages (`Catalog.ProductNotFound`), `Error.<CrossCutting>` / `Validation.<Case>` for Core. PascalCase. Placeholders are `{0}`, `{1}` (`string.Format` via the localizer) — **not** the frontend's `{{name}}`.\n\n**Placeholder arguments must be culture-insensitive.** The localizer's indexer calls `string.Format` under `CurrentCulture`, which is invariant (above). Pass `int`/`long`/`string`/enum — never a `double`, `decimal`, `DateTime` or `TimeSpan.TotalX`, which would render with an invariant separator instead of the reader's. Where a count is conceptually whole, expose it as an `int` at the source rather than converting at the call site (see `GetAuditsQueryHandler.MaxWindowDays` next to `MaxWindow`). Money and dates belong in structured response fields formatted by the client, not interpolated into a message.\n\n## Exceptions — localize at the boundary, log stays English\n\nThrow with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site.\n\n```csharp\n// domain message -> module catalog\nthrow new NotFoundException($\"Product {id} not found.\")\n{\n    MessageKey = \"Catalog.ProductNotFound\",\n    MessageArgs = [id],\n    ResourceSource = typeof(CatalogResources),\n};\n\n// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources)\nthrow new UnauthorizedException(\"Tenant context is required.\")\n{\n    MessageKey = \"Error.TenantContextRequired\",\n};\n```\n\n`GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException(\"...\")` still renders its English literal.\n\n**Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body.\n\n## Validators — inject the localizer, defer resolution\n\n```csharp\npublic sealed class XCommandValidator : AbstractValidator<XCommand>\n{\n    public XCommandValidator(IStringLocalizer<SharedResources> localizer)\n    {\n        RuleFor(x => x.Name)\n            .NotEmpty().WithMessage(_ => localizer[\"Validation.NameRequired\"]);\n    }\n}\n```\n\nAlways the `.WithMessage(_ => localizer[\"Key\"])` lambda (resolution is deferred to `Validate()`, under the request culture) — never `.WithMessage(localizer[\"Key\"])`. **Catalog choice:** inject `IStringLocalizer<SharedResources>` for genuinely shared/generic validation (`Validation.*` already in Core, reuse them), or `IStringLocalizer<<Module>Resources>` for module-specific validation messages kept in the module's own catalog. DI provides the localizer automatically (`AddValidatorsFromAssembly` + `AddHeroLocalization` + the module's own `AddLocalization`); nested validators (`Include(new PagedQueryValidator<T>(localizer))`) receive it from the parent.\n\n## Known behaviour (documented, not bugs)\n\n- **The `locale` claim lags a language switch by one token.** The culture provider reads the JWT `locale` claim, so a switch does not reach the API until the next token issue. The front-end persists the choice to the profile and re-mints, so it converges; in the window between, the shell can be in the new language while an API error still comes back in the old one. Deliberate: the alternative is a per-request DB read on every authenticated call.\n- **Impersonation carries the operator's language, not the target's.** `StartImpersonationCommandHandler` strips the target's `locale` claim so the operator keeps reading in their own language, and the cross-app handoff URL carries `locale` because the dashboard is normally on a different origin and cannot read admin's `fsh.admin.lng`. During impersonation the switcher is client-side only — it must not PUT onto the impersonated user's profile.\n- **SignalR does not carry the app locale.** The hub client builds its own requests instead of going through `apiFetch`, so `Accept-Language` on the negotiate is the browser's. Applies to every session. `handoff-locale.spec.ts` names the exception explicitly so any *other* channel that stops carrying the locale fails the test.\n\n## Tests (required with every catalog change)\n\n- **Parity** — add every new key to the neutral (English) catalog; the `pt-BR` entry is optional, and a key it lacks falls back to English at runtime, so nobody has to write Portuguese to get a green build. The build fails only on a `pt-BR` key the neutral catalog does not have (an orphan, usually a renamed or deleted key) or on a `pt-BR` value whose `{n}` placeholders differ from the English one. Applies to Core and every `<Module>Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test.\n- **Code → resx guard** — every referenced key (`MessageKey`, `localizer[\"…\"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently).\n- Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub.\n\n## Emails / background handlers\n\nIntegration-event handlers run without an HTTP request, so there is no negotiated culture. Localizing outbound emails needs the recipient's stored locale propagated to the handler — **not yet implemented** (tracked for a future PR); email bodies stay English for now.\n\n<!-- File: .agents/rules/logging.md (Tokens: ~841 | Category: anthropic-skill) -->\n# Logging & observability\n\n`src/BuildingBlocks/Web/Observability/`. Read before adding logs, traces, or metrics.\n\n## Structured logging only\n\n**No string interpolation in log messages.** Use message templates with named placeholders, or `[LoggerMessage]` source-gen for hot paths.\n\n```csharp\n// good\n_logger.LogInformation(\"Cleaned up {Count} expired sessions for tenant {TenantId}\", count, tenantId);\n// also good (hot path) — see OutboxDispatcher, InMemoryEventBus, AppHub\n[LoggerMessage(Level = LogLevel.Warning, Message = \"Outbox message {MessageId} dead-lettered\")]\nprivate partial void LogDeadLettered(Guid messageId);\n// NEVER\n_logger.LogInformation($\"Cleaned up {count} sessions\");   // breaks structured logging + analyzers\n```\n\nBuild runs with `TreatWarningsAsErrors` — interpolated log calls won't even compile clean under analysis.\n\n## Serilog\n\n`AddHeroLogging()` reads the `Serilog` config section (Console sink by default), attaches `HttpRequestContextEnricher` (adds `RequestMethod`/`RequestPath`/`UserAgent` + `UserId`/`Tenant`/`UserEmail` when authenticated), overrides Microsoft/EF/Hangfire/Finbuckle to higher levels, and excludes the `ExceptionHandlerMiddleware` source (the global handler logs exceptions itself — don't double-log).\n\n## Correlation\n\n`X-Correlation-ID` request header (falls back to `HttpContext.TraceIdentifier`), surfaced in every ProblemDetails and pushed to the Serilog `LogContext`. `CurrentUserMiddleware` tags the current `Activity` with `fsh.user_id` / `fsh.tenant_id` / `fsh.correlation_id`.\n\n## OpenTelemetry\n\n`AddHeroOpenTelemetry()` no-ops unless `OpenTelemetryOptions.Enabled`. Metrics + traces for AspNetCore/HttpClient/Npgsql/EFCore/Redis/Runtime, plus caching + auditing meters and Mediator pipeline spans (`MediatorTracingBehavior`). Add a new meter/source name to `OpenTelemetryOptions` config, not by editing the extension.\n\n**OTLP export is auto-detected.** It turns on when **either** `Exporter.Otlp.Enabled=true` **or** the `OTEL_EXPORTER_OTLP_ENDPOINT` env var is present. Under .NET Aspire that env var is injected automatically, so traces/metrics flow to the Aspire dashboard with no config change (the SDK reads endpoint + protocol from the standard `OTEL_EXPORTER_OTLP_*` env vars; the config `Endpoint`/`Protocol` are only used when no env var is set — e.g. the docker-compose collector at `http://localhost:4317`). Plain `dotnet run` with no collector and `Enabled=false` exports nothing.\n\n**Logs ride the same OTLP detection.** Serilog owns the logging pipeline and does not forward to other `ILogger` providers, so the OTel SDK's log exporter can't see Serilog events — instead `AddHeroLogging` adds a `Serilog.Sinks.OpenTelemetry` sink under the same auto-detect rule (global `Enabled` + env-var-or-config endpoint). It also parses `OTEL_EXPORTER_OTLP_HEADERS` (the Aspire dashboard's OTLP receiver requires its `x-otlp-api-key`; the SDK reads this for traces/metrics, the Serilog sink does not) and stamps `service.name` = `OTEL_SERVICE_NAME ?? ApplicationName` so logs group under the same dashboard resource as the spans. Both `AddHeroOpenTelemetry` and the Serilog sink resolve the name that way: under an orchestrator that injects `OTEL_SERVICE_NAME` (Aspire sets it to the resource name, e.g. `fsh-starter-api`) the process adopts that identity; hardcoding the entry-assembly name (`FSH.Starter.Api`) instead would de-correlate the telemetry and list the process twice in the dashboard. Plain `dotnet run` with no env var falls back to `ApplicationName`.\n\n<!-- File: .agents/rules/modules/auditing.md (Tokens: ~579 | Category: anthropic-skill) -->\n# Module: Auditing\n\nAppend-only audit trail (entity changes, security events, exceptions, HTTP activity) with async channel-buffered persistence + DLQ. Module `Order = 300`.\n\n**Entities / DbContext:** `AuditRecord`, `AuditDbContext`. `AuditEnvelope` is the in-flight event. Rich Contracts surface: `IAuditClient`, `ISecurityAudit`, `IAuditPublisher`, `IAuditSink`, `IAuditDlqSink`, `IAuditEnricher`, `NoAuditAttribute`, payload records.\n**Areas:** read-only query side — GetAudits / ByCorrelation / ByTrace / Summary / Exception / Security. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Static `Audit` fluent API** — `Audit.ForSecurity(...).WithUser(...).WriteAsync(ct)` (also `ForEntityChange`/`ForActivity`/`ForException`). Configured once at startup via `Audit.Configure(publisher, serializer, enrichers)`. Enrichers are held in a **volatile immutable array swapped atomically** — never mutate a live enricher list (it'd race the enrich loop).\n- **Two interceptors, don't confuse them:** `AuditingSaveChangesInterceptor` (this module) captures EF entity diffs → EntityChange events and **skips `AuditDbContext`** (no recursive self-audit). `AuditableEntitySaveChangesInterceptor` (BuildingBlocks) stamps audit/soft-delete fields — different file, different job.\n- **Channel-buffered, never blocks the request** — `ChannelAuditPublisher` has two lanes: default (`DropOldest` under pressure) and a **security lane that back-pressures and never drops** (login/permission/impersonation ride here). `AuditBackgroundWorker` drains both (security first), batches, writes via `IAuditSink`; on sink failure it retries then spills to `IAuditDlqSink` (file) so events survive a Postgres outage.\n- `SqlAuditSink` groups a batch by `TenantId` and sets tenant context per group in a fresh scope (null → Root) — background writer has no ambient tenant.\n- **JSON masking** redacts fields by keyword (password/secret/token/apiKey/connectionString…) → `****`. Add sensitive keys there.\n- Exclude an endpoint from activity auditing with `[NoAudit]` / the `NoAudit` endpoint extension.\n- **Entity diffs mask sensitive values** in `EntityDiffBuilder` (property name contains password/secret/token/apikey/connectionstring/securitystamp → `****`, null stays null). `[NoAudit]` does **not** affect entity diffs — to keep an entity's values out of `AuditRecords` entirely, implement `IAuditExempt` (Contracts) on the entity.\n\n<!-- File: .agents/rules/modules/billing.md (Tokens: ~393 | Category: anthropic-skill) -->\n# Module: Billing\n\nPlans, subscriptions, usage metering, monthly invoicing. **Manual payment marking — no payment provider.** Module `Order = 500`.\n\n**Entities / DbContext:** `BillingPlan`, `Subscription`, `Invoice` (+ `InvoiceLineItem`), `UsageSnapshot`. **`BillingDbContext : DbContext`** (NOT `BaseDbContext`) — billing lives in the main DB with an explicit `TenantId` column for cross-tenant admin visibility, filtered in query services. Contracts = DTOs; `IBillingService`/`IUsageReporter` are internal.\n**Areas:** Plans, Subscriptions, Invoices (generate/issue/mark-paid/void), Usage (capture/get). Monthly invoice job (`5 0 1 * *`). Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **`BillingPlan` is `IGlobalEntity`** — platform-wide catalogue rows, **not tenant-scoped** (opts out of tenant isolation). A plan's `Key` matches the quota config key (e.g. `\"pro\"`): limits come from `QuotaOptions`, prices/overage from the plan.\n- **`BillingDbContext` is a plain `DbContext`** — tenant filtering is done explicitly in query services, not by the `BaseDbContext` auto-filter. Don't assume the global tenant filter applies here.\n- **Invoice state machine** — `Draft → Issued → Paid | Void`. Line items only addable in Draft; a Paid invoice can't be voided; totals recompute on add; Issue defaults due = +14 days.\n- **Usage metering is idempotent** — `IUsageReporter.CaptureForPeriodAsync` reads `IQuotaService` and persists one `UsageSnapshot` per `QuotaResource` per (tenant, period), so invoicing math is reproducible even after a mid-period plan change.\n\n<!-- File: .agents/rules/modules/catalog.md (Tokens: ~379 | Category: anthropic-skill) -->\n# Module: Catalog\n\nProduct catalog — products, categories (tree), brands — with soft-delete/restore/trash and search. Module `Order = 600`. This is the **reference module** for the soft-delete + image patterns; copy from here.\n\n**Entities / DbContext:** `Product` (aggregate, soft-deletable) + `ProductImage`, `Brand`, `Category` (self-referencing tree), `Money` (owned value object). `CatalogDbContext`. Domain events (`ProductCreated`/`PriceChanged`/`StockAdjusted`) are **internal**, not integration events.\n**Areas:** Products (+ price/stock/images), Categories (+ tree), Brands — each with Create/Update/Delete/Search/ListTrashed/Restore. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas / patterns to copy\n\n- **Soft-delete + restore + trashed-listing** is the standard pattern. Unique indexes are **filtered on `\"IsDeleted\" = FALSE`** so SKU/Slug stay unique-per-tenant among *live* rows only (a deleted SKU can be reused). Replicate this on any soft-deletable unique field.\n- **EF value-generation for nav children** — `ProductImageConfiguration` sets `Id.ValueGeneratedNever()` (same nav-child footgun as Chat — see `database.md`).\n- **Single-thumbnail invariant** is enforced by the **aggregate**, not a partial unique index (Postgres non-deferrable partial unique indexes can't handle the demote/promote ordering in one transaction). Enforce such invariants in the domain.\n- Registers `ProductFileAccessPolicy` (OwnerType `\"Product\"`) for product images via the Files module.\n- Route ordering: literal segments (`/trash`, `/tree`, `/restore`) are registered **before** `/{id:guid}` catch-alls.\n\n<!-- File: .agents/rules/modules/chat.md (Tokens: ~474 | Category: anthropic-skill) -->\n# Module: Chat\n\nSlack-style messaging: 1:1 DMs, group DMs, named channels, threads, reactions, mentions, pins. Module `Order = 800` (after Notifications 750, so Notifications' handlers register first).\n\n**Entities / DbContext:** `ChatChannel` (aggregate, soft-deletable) + `ChannelMember`; `Message` (aggregate) + `MessageAttachment`/`MessageMention`/`MessageReaction`. `ChatDbContext : BaseDbContext`, schema `chat`. Publishes `MentionedInChannelIntegrationEvent`.\n**Areas:** Channels, Messages (incl. pin/edit/delete/threads), Reactions, Search. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **EF value-generation for nav children** — `MessageConfiguration` sets `Property(x => x.Id).ValueGeneratedNever()` for child collections (attachments/mentions/reactions). The domain assigns `Guid.CreateVersion7()` in factories; without this EF treats nav-collection children as `Modified` → 0-row UPDATE instead of INSERT. See `database.md`.\n- `ChatDbContext` calls **`base.OnModelCreating` LAST** so tenant auto-apply sees the configured child types.\n- **`ChannelAuthorization`** (`Features/v1/Internal/`): `RequireMember` throws **NotFound (404)** (not 403) so non-members can't probe channel existence; `RequireAdmin` throws `ForbiddenException`. Use these in every channel/message handler.\n- **SignalR via `IHubContext<AppHub>`** (the shared hub in BuildingBlocks), groups `channel:{id}`. The hub reads the user via `Context.User`, not `ICurrentUser` (see `realtime.md`). Chat registers `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups.\n- SendMessage publishes `MentionedInChannelIntegrationEvent` **per distinct mentioned user**; Notifications consumes it.\n- DMs use a sorted `DirectKey` (`\"{lo}:{hi}\"`) for find-or-create; **threads are single-level only**.\n- Chat attachments register `ChatChannelFileAccessPolicy` (OwnerType `\"ChatChannel\"`): attach/read require membership, delete is uploader-only (see `modules/files.md`).\n\n<!-- File: .agents/rules/modules/files.md (Tokens: ~515 | Category: anthropic-skill) -->\n# Module: Files\n\nPresigned-URL file lifecycle (upload → finalize → serve → delete) shared by Catalog images, Chat attachments, avatars. Module `Order = 350` (loads before consumer modules).\n\n**Entities / DbContext:** `FileAsset` (aggregate, soft-deletable): status `PendingUpload → Available | Quarantined`, `Visibility` (Public/Private), `ScanStatus`. `FilesDbContext`. Publishes `FileFinalizedIntegrationEvent`.\n**Areas:** RequestUploadUrl, FinalizeUpload, GetFileDownloadUrl/Metadata, ChangeVisibility, Delete/Restore, ListMy/Shared/Trashed. Purge jobs (orphaned hourly, deleted daily). Full list: `Features/v1/` or `/scalar`. Storage mechanics: `storage.md`.\n\n## Gotchas\n\n- **Presigned flow** — never stream uploads through the API. RequestUploadUrl validates category/extension/size + quota **pre-check** and persists a `PendingUpload`; client uploads directly to storage; **FinalizeUpload debits the quota** (not at request time) and flips to Available/Quarantined.\n- **`FileAccessPolicyRegistry`** resolves `IFileAccessPolicy` by **OwnerType** — case-insensitive, **closed by default** (unknown OwnerType → forbidden), **last-write-wins** on duplicates (intentional, for test substitution). Each owning module registers its own policy in its `ConfigureServices` (Catalog/Tickets load after Files). Files ships `DefaultUploaderOnlyPolicy` for built-in OwnerTypes `\"MyFiles\"`/`\"User\"`.\n- **Visibility is in the storage key** (`public/…` vs `private/…`, built only by `StorageKeyBuilder`). Changing visibility moves the object via `FileStorageRelocator` and publishes `FileStorageKeyChangedIntegrationEvent`; a module that stores a FileAsset's public URL must handle it. `MigrateLegacyPublicFileKeysJob` (enqueued on each start) moves pre-#1410 public files. See `storage.md`.\n- `CanChangeVisibilityAsync` defaults to the delete rule (uploader-only); domain-bound files (e.g. product images) may override to forbid visibility flips.\n- Tenant scoping is implicit via `BaseDbContext` (no explicit `TenantId` on `FileAsset`).\n\nTo support uploads for a new owner type: implement `IFileAccessPolicy`, register it in the owning module, and use that OwnerType in RequestUploadUrl.\n\n<!-- File: .agents/rules/modules/identity.md (Tokens: ~549 | Category: anthropic-skill) -->\n# Module: Identity\n\nAuth (JWT + ASP.NET Identity), users, roles, permissions, sessions, impersonation, 2FA.\n\n## Service shape\n\n`IUserService` is a **facade** that delegates to focused single-responsibility services — change behavior in the specific service, not the facade:\n\n| Interface | Concern |\n|---|---|\n| `IUserRegistrationService` | register, external-principal create, email/phone confirm |\n| `IUserProfileService` | get/list/count, update profile, image, existence checks |\n| `IUserStatusService` | activate/deactivate (`DeleteAsync` == deactivate), audited toggles |\n| `IUserRoleService` | role assignment, admin-role guards |\n| `IUserPasswordService` | forgot/reset/change password, history + expiry |\n| `IUserPermissionService` | effective permissions, cache invalidation |\n\n`ChangePassword`/`Update`/`Delete` etc. flow facade → service → EF/UserManager. `CancellationToken` is `= default` on these interfaces and propagated into EF sinks (note: `UserManager`/`RoleManager` have no CT overloads, so private helpers that only call them don't take one).\n\n## Permission gating footgun\n\n`RequiredPermissionAttribute` implements `FSH.Framework.Shared.Identity.Authorization.IRequiredPermissionMetadata`. **Never let a second/duplicate `IRequiredPermissionMetadata` appear** — it silently disables **all** `.RequirePermission()` gates across the app. Permission constants live in `Shared/Identity/*Permissions.cs`.\n\n## Hosted services (background)\n\n- `RolePermissionSyncHostedService` — best-effort sync of the permission catalog; loops, catches `Exception` *with* an `OperationCanceledException` filter, logs and continues.\n- `SessionCleanupHostedService` — hourly expired-session purge; OCE handled by a preceding catch.\n\nThese are the model for background loops: stay alive, log with context, never swallow cancellation. See `api-conventions.md`.\n\n## Tokens / sessions\n\nLogin `POST /api/v1/identity/token/issue` (header `X-FSH-App` enforces the operator/tenant app boundary). Refresh `POST /api/v1/identity/token/refresh` cross-checks subject. Session rows are written best-effort during login — failures log a warning and login still succeeds. Admin can't demote/deactivate the last admin or the root-tenant seed admin (guards in `UserRoleService`/`UserStatusService`).\n\n## Tests\n\n`Identity.Tests` is the largest unit suite. When asserting a forwarded `CancellationToken`, assert the specific token (see `testing.md`).\n\n<!-- File: .agents/rules/modules/multitenancy.md (Tokens: ~524 | Category: anthropic-skill) -->\n# Module: Multitenancy\n\nTenant catalog, provisioning, activation/upgrade, per-tenant theming (Finbuckle.MultiTenant). Foundational — registered early.\n\n**Entities / DbContext:** `AppTenantInfo` (catalog), `TenantProvisioning` + `TenantProvisioningStep`, `TenantTheme`. `TenantDbContext` holds the tenant catalog in the main DB.\n**Areas:** CreateTenant, ChangeTenantActivation, UpgradeTenant, Get(Tenants/Status/Migrations), TenantProvisioning (status/retry), TenantTheme (get/update/reset). Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Finbuckle pipeline ordering** — strategy chain Claim → Header → `?tenant=` → DistributedCache → EFCoreStore. `UseMultiTenant()` runs **before `UseAuthentication()`**, but Finbuckle's `ClaimStrategy` runs the default auth scheme's handler itself, so an authenticated caller resolves to its **tenant claim** and a `tenant` header cannot override it. The header (`MultitenancyConstants.Identifier`) decides only for anonymous requests (login, refresh); the root-operator override is post-auth middleware in `MultitenancyModule.ConfigureMiddleware`.\n- **Root-operator cross-tenant override is a post-auth middleware** in `ConfigureMiddleware` (not a Finbuckle strategy). Gate: caller's JWT tenant claim == `MultitenancyConstants.Root.Id` **and** a `tenant` header != root; it re-resolves via `IMultiTenantContextSetter`. Claim-aware tenant logic must go here, never in a strategy.\n- **`ITenantInitialPasswordBuffer`** (singleton) — the tenant admin password is **operator-supplied**, not a constant. `CreateTenantCommandHandler` calls `Store(tenantId, password)` **before** kicking off provisioning; the background seed step `TryConsume`s it (`ConcurrentDictionary`, consume = remove).\n- **Provisioning** runs 4 steps (Database → Migrations → Seeding → CacheWarm) via a Hangfire `TenantProvisioningJob`, falling back to inline execution if Hangfire storage is unavailable. **Activation is gated on `Status == Completed`.**\n- `ITenantService.MigrateTenantAsync`/`SeedTenantAsync` create a fresh scope and set `IMultiTenantContext` **first**, then run the `IDbInitializer`s.\n\nTenant **isolation** mechanics (default-on filter, `IGlobalEntity` opt-out, `base.OnModelCreating` last) live in `database.md`.\n\n<!-- File: .agents/rules/modules/notifications.md (Tokens: ~339 | Category: anthropic-skill) -->\n# Module: Notifications\n\nPer-user in-app inbox (bell icon) driven by cross-module integration events, with live SignalR push. Module `Order = 750` (**before Chat 800** so its handlers are registered before Chat publishes).\n\n**Entities / DbContext:** `Notification` (aggregate: `UserId`, `Type`, `Title`/`Body`/`Link`, `Source`, `MetadataJson`, `ReadAtUtc`). `NotificationsDbContext`. Consumes integration events from other modules (e.g. Chat's `MentionedInChannelIntegrationEvent`).\n**Areas:** List, GetUnreadCount, MarkRead, MarkAllRead. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **It's a consumer.** New notification types come from **handling another module's integration event** (`AddIntegrationEventHandlers`), not from new endpoints. The handler writes an inbox row **and** pushes `\"NotificationCreated\"` to SignalR group `user:{userId}` via `IHubContext<AppHub>`.\n- **Order matters** — Notifications (750) must load before any publisher whose events it consumes (Chat 800). If a new module publishes events Notifications should react to, mind the `Order`.\n- In-memory bus runs handlers **synchronously in the publisher's request scope** — keep the handler minimal; an exception surfaces to the originating request. See `eventing.md`.\n- Inbox rows are **denormalized** (Title/Body/Link/MetadataJson copied in) so rendering never calls back into the source module. `MarkRead` is idempotent (`ReadAtUtc ??= now`).\n\n<!-- File: .agents/rules/modules/tickets.md (Tokens: ~264 | Category: anthropic-skill) -->\n# Module: Tickets\n\nSupport ticket lifecycle with comments. Module `Order = 700`.\n\n**Entities / DbContext:** `Ticket` (aggregate, soft-deletable, state machine) + `TicketComment`. `TicketsDbContext`. `TicketStatus`/`TicketPriority` enums in Contracts; domain events internal.\n**Areas:** Create, Assign, Resolve, Reopen, Restore, AddComment, ListComments, GetById, Search, ListTrashed. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **State machine** (`Domain/Ticket.cs`): `Open → InProgress → Resolved → Closed`. Illegal transitions throw **`CustomException` with `HttpStatusCode.Conflict` (409)** — not a generic 400. Assigning auto-starts (Open→InProgress); unassigning an InProgress ticket reverts to Open; creating with an assignee starts at InProgress. Closed tickets reject comments/resolve until reopened. Keep all transition guards in the aggregate.\n- Soft-delete/restore/trash pattern is identical to Catalog (filtered unique indexes — see `modules/catalog.md`).\n- Endpoints are mapped on the bare `api/v{version}` group (no `/tickets` sub-path); literal routes precede `{ticketId:guid}`.\n\n<!-- File: .agents/rules/modules/webhooks.md (Tokens: ~442 | Category: anthropic-skill) -->\n# Module: Webhooks\n\nTenant-scoped outbound webhook subscriptions with HMAC-signed delivery and retries. Module `Order = 400`.\n\n**Entities / DbContext:** `WebhookSubscription` (`Url`, `EventsCsv`, `SecretHash`, `IsActive`), `WebhookDelivery` (per-attempt log). `WebhookDbContext` (tenant-filtered). Contracts expose **DTOs only** — `IWebhookDispatcher`/`IWebhookDeliveryService` are internal.\n**Areas:** Create/Delete/Get subscriptions, GetDeliveries, Test. Full list: `Features/v1/` or `/scalar`.\n\n## Gotchas\n\n- **Fan-out is an open-generic handler** — `WebhookFanoutHandler<TEvent>` is registered as an open generic, so it handles **every** `IIntegrationEvent` with no per-event wiring. It skips events with null `TenantId` (subscriptions are tenant-only) and matches event-type name against each subscription's `EventsCsv` (`*` wildcard supported).\n- **Restore tenant context in the background** — both the fan-out handler and `WebhookDispatchJob` set `IMultiTenantContext` in a fresh scope before reading the tenant-filtered DbContext (background pumps/Hangfire carry no HTTP context). This is the canonical pattern for any background reader of tenant data — see `eventing.md`, `jobs.md`.\n- **HMAC signing** — `X-Webhook-Signature: sha256=<hex HMACSHA256>` (`WebhookPayloadSigner.Sign`), plus `X-Webhook-Event` and `X-Webhook-Delivery-Id` headers.\n- **Delivery** — `WebhookDispatcher.EnqueueAsync` enqueues a Hangfire `WebhookDispatchJob` per subscription; `[AutomaticRetry(Attempts=4, DelaysInSeconds={30,120,600,3600})]`. Transient (5xx/408/429) throws to reschedule; permanent 4xx completes silently. Each attempt persists a `WebhookDelivery` row. The `\"Webhooks\"` HttpClient uses `AddHeroResilience` (see `resilience.md`).\n\n<!-- File: .agents/rules/realtime.md (Tokens: ~677 | Category: anthropic-skill) -->\n# Realtime — SignalR & SSE (backend)\n\n`src/BuildingBlocks/Web/Realtime/` + `Sse/`. For the frontend side see `frontend/shared.md` + `frontend/dashboard.md`.\n\n## SignalR (`AppHub`)\n\n`[Authorize] AppHub` mapped at **`/api/v1/realtime/hub`**. Groups: `user:{userId}`, `tenant:{tenantId}`, `channel:{channelId}`.\n\n- **Channel-group join is connect-time + on-demand.** `OnConnectedAsync` auto-joins `user:{id}`, `tenant:{id}`, and every `channel:{id}` the user is *already* a member of. A channel that becomes relevant **after** the socket is live (a new DM, or being added to a channel) is **not** auto-joined — the client must call the membership-gated **`JoinChannel(channelId)`** hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs `OnConnectedAsync`. New-DM creation pushes `ChatChannelAdded` to each other participant's `user:{id}` group so their channel list refreshes.\n- **⚠️ Read the user from `Context.User`, NOT `ICurrentUser`.** `ICurrentUser` flows through `IHttpContextAccessor`, but the negotiate `HttpContext` isn't pinned to subsequent hub invocations → `ICurrentUser` returns nulls inside the hub. Use `Context.User` (the hub's `GetUserId()`/`GetTenantId()` helpers).\n- Broadcasts are **scoped to groups** (`tenant:{id}`, `user:{id}`, `channel:{id}`), never `Clients.All`. `PresenceChanged` goes to the tenant group.\n- Redis backplane is added automatically when `CachingOptions:Redis` is set (channel prefix `fsh-signalr`) — required for multi-replica.\n- Push to a user from a module via `IHubContext<AppHub>` to group `user:{userId}` (e.g. Notifications' `\"NotificationCreated\"`).\n- `IPresenceTracker` (in-memory, **per-host** — single-replica only for presence). Modules supply `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups without depending on Chat.\n\n## SSE (`Web/Sse/`) — two-step token\n\nEventSource can't send `Authorization`, so SSE uses a token handshake:\n1. `POST /api/v1/sse/token` (authorized) → opaque Guid, **single-use, 30s TTL** in `IDistributedCache`.\n2. `GET /api/v1/sse/stream?token={guid}` (anonymous, consumes the token) → `text/event-stream`, `X-Accel-Buffering: no`, 15s heartbeat.\n\n`SseConnectionManager` (singleton, `ConcurrentDictionary`, bounded channel cap 100 `DropOldest`): `TrySend(userId)` (all tabs), `Broadcast(tenantId)`, `BroadcastAll()`.\n\n## Tests\n\nSignalR hub tests force **long-polling** (TestServer has no WebSocket). See `integration-testing.md`.\n\n<!-- File: .agents/rules/resilience.md (Tokens: ~270 | Category: anthropic-skill) -->\n# HTTP resilience\n\n`src/BuildingBlocks/Web/HttpResilience/`. Uses `Microsoft.Extensions.Http.Resilience` (Polly v8).\n\n## Pattern — opt-in per HttpClient\n\n`AddHeroResilience(config)` is an `IHttpClientBuilder` extension that adds `AddStandardResilienceHandler` configured from `HttpResilienceOptions` (retry, total-request timeout, attempt timeout, circuit breaker). It is **NOT global** — chain it onto the specific outbound client that needs it:\n\n```csharp\nbuilder.Services.AddHttpClient(\"Webhooks\", ...)\n    .AddHeroResilience(builder.Configuration);\n```\n\nDefaults (`HttpResilienceOptions`): 3 retries, 30s total, 10s per attempt, 50% failure ratio, throughput 10. No-ops when `Enabled=false`.\n\n## Notes\n\n- Only outbound integrations need this. The only current caller is the Webhooks delivery client (`WebhooksModule.cs`). Add it to any new typed/named `HttpClient` that calls a flaky external service.\n- For internal ret/timeout of *background* work, prefer Hangfire's `[AutomaticRetry]` on the job (see `modules/webhooks.md`) — that's durable across restarts; the resilience handler only covers the in-flight HTTP call.\n\n<!-- File: .agents/rules/security.md (Tokens: ~1484 | Category: anthropic-skill) -->\n# Web security & request governance\n\nCORS, security headers, rate limiting, idempotency, quota enforcement. `src/BuildingBlocks/Web/` + `Quota/`.\nFor auth/JWT/permissions see `modules/identity.md`; for the global exception handler see `api-conventions.md`.\n\n## CORS (`Web/Cors/`) — the SignalR gotcha\n\nPolicy `FSHCorsPolicy`. When `CorsOptions.AllowAll=true` it uses **`SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod().AllowCredentials()`** — deliberately **NOT `AllowAnyOrigin()`**. `Access-Control-Allow-Origin: *` is illegal with credentialed requests, and **SignalR's negotiate always runs credentialed**, so `AllowAnyOrigin()` silently breaks SignalR while REST keeps working. Never \"simplify\" it to `AllowAnyOrigin()`. `UseHeroCors()` runs **before** `UseHttpsRedirection()` so OPTIONS preflight isn't 307-redirected.\n\n## Front-end origin for outbound links (`Web/Frontend/`)\n\n`IFrontendOriginResolver` builds the origin of user-facing links sent in e-mails (password reset, e-mail confirmation). Backed by **`FrontendOptions`**, deliberately separate from `CorsOptions`: CORS governs which browsers may *call* the API, this governs which origins may appear *inside an outbound link*. Never merge the two lists — coupling them breaks same-origin/reverse-proxy topologies and overloads a security boundary.\n\n- **`ResolveForCurrentRequest()`** — self-service flows only (forgot-password, self-register), where the caller *is* the recipient. Validates the request `Origin` against `FrontendOptions:AllowedOrigins` and returns the **canonical configured entry**, never the client's string. Present-but-unlisted against a non-empty list → `CustomException(HttpStatusCode.BadRequest)`: these endpoints are anonymous, so a forged header must never reach an e-mail. No `Origin` header, or an empty/all-unparseable list, falls through to the default.\n- **`ResolveDefault()`** — operator-driven flows (admin register, resend-confirmation) and background jobs, where the caller is **not** the recipient. Chain: `FrontendOptions:DefaultOrigin` → throw. Never the caller's `Origin`, or an operator would send a tenant user a link into the admin console; and never the request's `Host` either, which is the same string under a different name. There is deliberately no fallback to `OriginOptions:OriginUrl`: these links address SPA routes (`/confirm-email`, `/reset-password`), so the API's own origin would 404 them.\n\nPicking the wrong method is a silent bug — both compile and both return a plausible origin. Match the method to **who receives the link**, not to who sent the request.\n\nNo `ValidateOnStart` on `FrontendOptions`, on purpose: a deployment that never sends such a link must not be taken down by the setting. `UseHeroPlatform` logs at startup instead — `Warning` for an empty or all-typo allow-list (counted after normalization, so a list of nothing but typos reports as the empty list it effectively is), `Error` for a missing `DefaultOrigin`, since without it those flows return 500 rather than degrading. `OriginOptions:OriginUrl` keeps its own job — the API's own public base for back-end-served assets (avatars), read through `IRequestContext.Origin`.\n\n## Security headers (`Web/Security/`)\n\n`UseHeroSecurityHeaders()` sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, HSTS (HTTPS), and a CSP. `SecurityHeadersOptions.ExcludedPaths` defaults to `[\"/scalar\",\"/openapi\"]` (they manage their own scripts) — keep those excluded.\n\n## Rate limiting (`Web/RateLimiting/`)\n\nChained partitioned fixed-window limiter: **tenant → user → IP** (defaults 1000 / 200 / 300 per 60s) + a stricter named `\"auth\"` policy (10/60s). Health paths are unlimited. Rejection → 429 + ProblemDetails + `Retry-After`. `RateLimitingOptions.Enabled` is read **eagerly** — when false the middleware is skipped entirely (tests set it via env var before host build).\n\n## Idempotency (`Web/Idempotency/`)\n\nOpt-in per endpoint with **`.WithIdempotency()`** on **authenticated** POST/PUTs that must be replay-safe (e.g. CreateTenant). Reads `Idempotency-Key` (max 128 chars, `DefaultTtl` 24h); a replay returns the cached status + body + `Location`/`ETag` and `Idempotency-Replayed: true`.\n\n- **Key scope:** resolved tenant (never the raw `tenant` header) + operation (method + route pattern) + route values + caller (`GetUserId()`, else `\"anon\"`), via `CacheKeys.IdempotencyEntry`.\n- **Only 2xx is stored**, before the body is sent, on `CancellationToken.None`. Probe and write must share one `IDistributedCache` + key + serializer, or replay silently never engages.\n- **Concurrent duplicates:** in-flight reservation under a `lock:` prefix (Redis `SET NX`, else in-process, `ReservationTtl` default 1m); cache re-probed after the lock; duplicate in flight → **409** + `Retry-After: 1`. Probe, reserve and release all fail open.\n- **`RequestAborted` is detached** while the handler runs, so a client disconnect can't cancel a committed side effect. Keep these handlers short; **never** on streaming or large-file endpoints (the response is buffered).\n- **Never on `AllowAnonymous()`** endpoints — all anonymous callers share `\"anon\"`. `IdempotencyWiringTests` fails the build if one appears.\n\n## Quota enforcement (`Quota/`)\n\n`QuotaEnforcementMiddleware` charges 1 `ApiCalls` unit per request via `CheckAndRecordAsync`; over-limit → 429 + ProblemDetails + `Retry-After`, and sets `HttpContext.Items[QuotaRejected]` so auditing can tag it. Resources: `ApiCalls` (counter), `StorageBytes`, `Users`, `ActiveFeatureFlags` (gauges). Skips health/metrics, unresolved tenants, and the root tenant. **Pipeline:** runs after auth (needs tenant) and after the rate limiter. Inject `TimeProvider` (not `DateTimeOffset.UtcNow`) for any time math here — the subsystem is `TimeProvider`-based.\n\n`IQuotaService`: `CheckAsync` (no mutation), `RecordAsync` (increment), `CheckAndRecordAsync` (atomic — won't increment past the limit). Store: Redis (`RedisQuotaService`) or per-process `InMemoryQuotaService` (dev/test). `NoopQuotaService` when disabled.\n\n<!-- File: .agents/rules/storage.md (Tokens: ~1172 | Category: anthropic-skill) -->\n# Storage & file uploads\n\n`src/BuildingBlocks/Storage/`. Read before working with files/blobs.\n\n## `IStorageService`\n\n`UploadAsync<T>(FileUploadRequest, FileType, ct)`, `RemoveAsync(path, ct)`, `DownloadAsync`, `ExistsAsync`, `GetSizeAsync` (0 if absent), `GenerateUploadUrlAsync`/`GenerateDownloadUrlAsync` (presigned), `HeadObjectAsync`, `BuildPublicUrl(key)→string` (string, not Uri — local storage returns a server-relative path).\n\n`CopyAsync(source, destination, ct)` is a server-side copy that **throws** on failure (unlike `RemoveAsync`) — callers move objects as copy → update the reference → delete. The quota decorator charges the copy, so a move is net-zero.\n\n## Visibility lives in the key\n\nBucket policies in every stack (Aspire bootstrap, compose `rustfs-init`, Terraform `app_s3_public_read_prefix` + the CloudFront read statement) grant anonymous `s3:GetObject` on **`public/*` only** (`StorageVisibilityRoot`). So:\n- Files keys are `{public|private}/tenants/{tenant}/{owner}/{yyyy}/{MM}/{id:N}/{name}` — `StorageKeyBuilder` (Files module) is the **one** place that decides; never hand-build a key.\n- `S3StorageService.UploadAsync<T>` always returns a public URL, so it writes under `public/uploads/…`.\n- Moves go through `FileStorageRelocator.ApplyAsync`, never by hand: it takes the file's row lock (`SELECT … FOR UPDATE`, PostgreSQL), re-reads the row, then copy → commit row + `FileStorageKeyChangedIntegrationEvent` → delete old. The old object is deleted only when no row references it; the first attempt runs right after the commit, and Files' own `DeleteSupersededObjectHandler` retries it via the outbox until it is really gone (so a Public → Private flip can't leave a public copy behind).\n- Modules that persist a public URL must handle that event and use `ResolveUrl` (it returns the API's current `NewPublicUrl` for public files) — Catalog (product images) and Identity (avatars) do.\n- Keys from before #1410 (`tenants/…`, `uploads/…`) have no root: `MigrateLegacyPublicFileKeysJob` (enqueued via `IJobService` on every API start; keyset-paginated; retried by Hangfire) moves public FileAssets under `public/`; legacy private ones stay put. It reads the partial index `IX_FileAsset_LegacyKey`, which is empty once done, so repeat runs are nearly free. `FinalizeUpload` relocates uploads that were presigned before the upgrade. Legacy `UploadAsync<T>` objects (`uploads/…`) are **not** migrated.\n- `QuotaMeteredStorageService.RemoveAsync` refunds only when the object is gone afterwards (providers swallow delete errors).\n- With `Storage:S3:Prefix` set, keys become `{Prefix}/public/…` — the policy must match that, and the prefix must **not** be `public` or `private`.\n- The **Local** provider serves everything under wwwroot: it does not enforce `private/` at all. Use it for dev only.\n\n`FileType`: `Image` (5MB), `Document`, `Pdf` (10MB) — `FileTypeMetadata.GetRules` enforces extension + size. **Always propagate `CancellationToken`.**\n\n## Providers\n\n`AddHeroStorage(config)` reads `Storage:Provider` **eagerly at registration**: `\"s3\"` → `S3StorageService` (supports any S3-compatible store, e.g. RustFS, via `ServiceUrl` + `ForcePathStyle`), else `LocalStorageService`. When quotas are enabled the service is wrapped in `QuotaMeteredStorageService` (debits `StorageBytes`).\n\n`Storage:S3:PresignServiceUrl` (optional, validated at startup as an absolute http(s) URL): presigned PUT/GET URLs are signed for this host through a second, keyed `IAmazonS3` (`S3StorageService.PresignClientKey`); all real I/O stays on `ServiceUrl`. Needed when the API reaches the store on an internal address browsers can't resolve (compose: `http://rustfs:9000`). A test factory that re-registers the S3 stack must register that keyed client too (see `FshWebApplicationFactory`).\n\n## Presigned upload flow (preferred for user uploads)\n\nDon't stream large files through the API. The pattern (see Files module):\n1. `RequestUploadUrl` — server validates category/extension/size + quota pre-check, returns a presigned PUT URL, persists a `PendingUpload` record.\n2. Client uploads **directly** to storage.\n3. `FinalizeUpload` — flips to `Available`, **debits the quota here** (not at request time), publishes `FileFinalizedIntegrationEvent`.\n\nLocal/dev without an S3 store uses `LocalPresignTokenStore` (in-memory one-shot tokens).\n\n## Test gotcha\n\n`AddHeroStorage` reads `Storage:Provider` **before** a test factory's in-memory config overlay applies, so it wires `LocalStorageService`. Integration tests that need object storage must **remove the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-register the S3 stack** pointed at the RustFS container (see `FshWebApplicationFactory`). See `integration-testing.md`.\n\n<!-- File: .agents/rules/testing.md (Tokens: ~577 | Category: anthropic-skill) -->\n# Testing conventions\n\nRead before writing or changing tests.\n\n## Stack\n\nxUnit · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For<IService>()`) · AutoFixture (`_fixture.Create<T>()`) · NetArchTest (architecture rules) · Testcontainers (integration).\n\n## Naming & shape\n\n- Method name: `MethodName_Should_ExpectedBehavior_When_Condition`.\n- Arrange-Act-Assert, grouped with `#region` (Happy Path / Exception / Edge Cases).\n- Assert on observable behavior. When verifying a forwarded `CancellationToken`, assert the **specific** token (`Received(1).XAsync(arg, ct)`), not the implicit default — NSubstitute fills optional params with `default`, so `Received(1).XAsync(arg)` silently asserts `CancellationToken.None`.\n\n## Test projects (`src/Tests/`)\n\n| Project | Scope | Docker? |\n|---|---|---|\n| `{Module}.Tests` | Unit: handlers, services, domain | no |\n| `Framework.Tests`, `Generic.Tests`, `Caching.Tests` | BuildingBlocks units | no |\n| `Architecture.Tests` | NetArchTest: module boundaries + tenant-isolation rules + handler↔validator pairing | no |\n| `Integration.Tests` | `WebApplicationFactory` over real PostgreSQL/Redis/RustFS | **yes** |\n| `Integration.Middleware.Tests` | Real middleware wiring | **yes** |\n\n```bash\ndotnet test src/FSH.Starter.slnx                 # all (integration needs Docker)\ndotnet test src/Tests/{Module}.Tests             # one project\ndotnet test --collect \"XPlat Code Coverage\" --settings coverage.runsettings\n```\n\nIf Docker is down, integration tests fail fast with `DockerUnavailableException` — that is environmental, not a code regression. Run the unit projects to validate logic.\n\n## Architecture tests (must stay green)\n\n- Modules reference other modules only via `.Contracts`.\n- Tenant-isolation rules on entities.\n- Every command handler + paginated query handler has a `{Name}Validator` (`HandlerValidatorPairingTests`). Validator names accepted: `{Cmd}Validator`, `{Name}CommandValidator`, `{Name}Validator`.\n\n## Integration-test gotchas\n\n- Set the Finbuckle tenant context **inline** in the test method (AsyncLocal — an awaited helper loses it → NRE in the tenant filter).\n- `AddHeroStorage` reads config eagerly; rewire `IStorageService` **after** registration in the factory.\n- SignalR hub tests force **long-polling** (TestServer has no WebSocket).\n\n## Frontend tests\n\nPlaywright, route-mocked (no real backend) — see `frontend/shared.md`. `cd clients/{app} && npm run test:e2e`.\n\n<!-- ... +11 more modular skills available via /api/skills/fullstackhero/dotnet-starter-kit?path={path} -->\n</yakaai_skills>"}