Context map for AI agents and humans. Update this file whenever an architectural boundary or project configuration changes.
A personal Azure portfolio / ops dashboard. A Blazor WASM front-end shows App Service health, cost, SSL expiry, zombie-app detection, and downtime diagnosis. The backend is a minimal-API BFF that reads an Azure inventory report from Azure Table Storage (with a local JSON fallback).
There are exactly two pages: / (the app catalog) and /azure (the ops dashboard).
The home-page catalog in apps.json is authoritative: live Azure inventory decorates
catalog entries with health and deployment URLs, but probe failures never remove apps
from the portfolio. /api/diag/summary is the compact first-paint contract for /azure;
the full report and history load only when advanced diagnostics is opened.
The site is publicly browsable with no real login — no /auth/* routes, no
AuthenticationStateProvider, no Microsoft OAuth or any other real identity provider, and
no login UI (see the header deviation note under Cross-cutting conventions → Management
actions). Host/FakeAuthHandler.cs (NET_RULES §3) reads X-Fake-User/X-Fake-Roles
headers to build a ClaimsPrincipal — it exists purely to let a caller opt into the
management role server-side (gating /api/impersonate) and is registered only outside
Production, throwing in its own constructor if the environment is Production. Default
identity with no headers sent is the anonymous guest. Do not add a real identity
provider, session storage, or login UI unless the owner explicitly reverses the "no real
login" decision. /api/config deliberately exposes no auth flags — /api/whoami is the
one endpoint that exposes the current principal.
Source projects live under /src; tests under /tests.
| Project | Role |
|---|---|
src/PoPunkouterSoftware.API |
API host + BFF. Feature slices under Features/<Area>/*Endpoints.cs (Config, Diag, Portfolio), each a Map<Area>Endpoints extension called from Program.cs. Host-level plumbing that belongs to no slice lives in Host/ (FakeAuthHandler, GlobalExceptionHandler, HealthEndpoints + its IHealthChecks, ManagementActionFilter, PortfolioIdentity, RefreshHub, RefreshSessionManager, ReportRefreshRunner). Hosts the Blazor WASM app. Namespace is flat (PoPunkouterSoftware.API for every file regardless of which Host//Features/<Area>/ folder it physically lives in) — the folder split satisfies NET_RULES' VSA layout without the using-directive churn a matching namespace hierarchy would add. |
src/PoPunkouterSoftware.Client |
Blazor WASM (Radzen UI, mobile-first). wwwroot lives only here. The layout is flat — every component sits in the project root, named for what it is: shared UI (ErrorRetryBanner, LoadingSkeleton, DevErrorBoundary), page-specific blocks (AzurePriorityQueue, AzureResourceExplorer, AzureEvidenceDisclosures, AzureHistoryDisclosure, AzureAiSummary), relative-time formatting in RelativeTime.cs. |
src/PoPunkouterSoftware.Shared |
Shared DTOs and the domain vocabulary (DomainVocabulary.cs) only — no behavior beyond those helpers, and no PackageReferences at all. No server/browser-only deps. Ships in the WASM bundle (IsTrimmable). |
src/PoPunkouterSoftware.Infrastructure |
Azure services (Table Storage, ARM, pinger, incident, telemetry) plus cross-slice helpers: ReportFileCache (report file fallback + data dir), HistorySummaryMapper, AppServicePlanInventory, SecretMasking. Slices must not reference each other — shared logic goes here. |
- One type per file, named for the type. Two deliberate exceptions: a cohesive vocabulary of constants is named for the group (
DomainVocabulary.cs,TableStorageVocabulary.cs), and a group of related DTOs is named<Concern>Models.cs(CostModels.cs,SecurityModels.cs). - Partial-class aspect files are
<Type>.<Aspect>.csand the aspect names the concern, not the shape:AzureReportService.Cost.cs,AzureDashboard.DerivedViews.cs. A file called.Charts.csthat holds no charts is the failure mode to avoid. - A file name must be greppable. If the name does not appear as a type inside the file and is not one of the two exceptions above, the name is wrong.
- Test files mirror the source file under test (
DomainVocabularyTests.csholdsServiceHealthTests,SeverityLevelTests, …), not one file per test class. - Records crossing a component boundary are top-level types in their own file (
PriorityQueueItem.cs,ResourceExplorerItem.cs,SafeToRemoveItem.cs) — a private nested record cannot be a Blazor component parameter.
- Routing: slices register via
Map<Area>Endpoints(this WebApplication)./api/diag/*usesMapGroup("/api/diag")./health,/healthz,/diagare off the/apigroup./healthis the deep probe and/healthzthe static liveness ping; there is deliberately no/api/healthalias. - Every endpoint needs a consumer. An endpoint whose only caller is its own test is dead code with a green check mark next to it. Three whole slices (GitHub, Infra, Pinger) were deleted in 2026-07 for exactly this — all tested, none reachable from the UI. Before adding a route, know what calls it.
- Config:
Directory.Build.props(net10,Nullable,TreatWarningsAsErrors, LangVersion 14) andDirectory.Packages.props(Central Package Management). Add package versions only inDirectory.Packages.props. - Resilience: typed
HttpClientsgithubandazure-armuseAddStandardResilienceHandler.healthandazure-probedeliberately have no resilience — they must report real reachability, not retry through outages. - Named HttpClients are shared: never reassign
DefaultRequestHeaderson an instance fromCreateClient(name). Handler chains are pooled, so a per-call mutation leaks that header — including credentials — to every other consumer of the same named client. ThegithubPAT is bound once, inProgram.cs. Pass anything per-call on theHttpRequestMessage. - Caching: none beyond what the framework provides.
HybridCacheand the sizedIMemoryCachewere removed with the slices that used them; add them back deliberately if a read-through cache is needed again. - AI triage:
AiTriageService(Infrastructure) calls the Hugging Face Inference API (google/flan-t5-base, free tier) to turn the dashboard's attention-item list into a one-paragraph summary. Killed byFeatureFlags:EnableAiSummary(off by default) and by theai-hftypedHttpClienthaving deliberately no resilience pipeline — an AI outage degrades to a rule-based one-sentence fallback (AiTriageService.BuildTemplateFallback, pure/no-I/O), never a dead "unavailable" state, and never retry-storms the free quota. Consumer: the persisted per-scan summary is precomputed server-side —ReportRefreshRunnercallsAiTriageService.GenerateSummaryAsyncright afterAzureReportService.RunAsyncand attaches theAiSummaryResult(Shared) to the report beforeAzureReportStore.SaveAsync, so it round-trips through the existing report/file-cache paths with no new storage plumbing; it's projected onto bothAzureReport.AiSummaryand the compactOpsSummary.AiSummaryand rendered by the Client'sAzureAiSummarycomponent on/azure.POST /api/diag/aiitself is consumed directly by that component's "Regenerate now" button, an on-demand/unpersisted re-generation that bypasses the cache. The attention-item list itself is built once byAttentionItemsBuilder(Infrastructure) and shared betweenDiagEndpoints.BuildOpsSummary(read path) andReportRefreshRunner(scan-time precompute) so the two can never drift.GenerateSummaryAsyncalso skips the model call and reuses the previous scan's text (Source: "cached") when the attention items' SHA-256 hash is unchanged from a real (Source: "ai") previous generation. This replaces the 2026-07 "no AI integration" decision (the oldazure-openaiclient that backed it is still gone); do not re-add a second AI client speculatively. - Screenshots & disk:
AppScreenshotServicepinsPLAYWRIGHT_BROWSERS_PATHto the persistent%HOME%share and installs only the Chromium headless shell — the worker's ephemeral disk previously filled with a full Chromium download and broke every deployment ("not enough space on the disk", 2026-07-10). Kill switch:FeatureFlags:EnableScreenshots=false. - Retention: History/HistorySummary table rows are pruned after
Retention:HistoryDays(default 30) byAzureReportStoreon each save; blobs age out via the 30-day lifecycle policy ininfra/main.bicep. Incidents are deliberately never pruned. - Telemetry budget: fixed-rate trace sampling, default 10% (
ApplicationInsights:SamplingRatio). Exceptions are never lost to it —GlobalExceptionHandlerlogs them throughILoggerand the logs pipeline is not trace-sampled. - Telemetry: Serilog → Console + File; Azure Monitor via OpenTelemetry (the sole App Insights pipeline).
cloud_RoleNamecomes from the OTel resourceservice.name(set by reflection in the API only — never in WASM). High-frequency logs use[LoggerMessage]source-gen (seeServicePingerService). - Secrets: Azure Key Vault (
kv-poshared, prefixPoPunkouterSoftware--) loaded at startup via System-Assigned Managed Identity; skipped under theTestingenvironment. DECISION: the grant is a classic access policy (get/list secrets), not the RBAC "Key Vault Secrets User" role —kv-posharedhas RBAC authorization disabled and is shared across Po* apps, so switching models is an estate-wide change, not per-app. The App Service plan (asp-PoPunkouterSoftware-f1) is deliberately app-local, not in PoShared: F1 is free, and isolation keeps this app's cold starts and quota pressure away from the shared plan. - Storage: Azure Table Storage. Local dev runs Azurite in Docker (
docker-compose.yml, containerpopunkoutersoftware-azurite).UseDevelopmentStorage=trueinappsettings.Development.json. - Snoozes: the
snoozespartition (SnoozeStore) lets the operator hide a finding from the priority queue for N days. Findings have no server-side identity — the RowKey is a SHA-256 hash of the client's opaque key (Table Storage forbids|etc. in RowKey), with the raw key stored as a property so it round-trips. Unprivileged — no.RequireManagementActions(), same tier as/api/diag/ai— and expiry is enforced client-side-in-effect byGetActiveAsyncfilteringExpiresAtUtcin code rather than a TTL/cleanup job; expired rows are simply excluded from reads, not deleted. - Management actions: mutating / expensive endpoints (
/api/diag/refresh,/api/diag/cancel-refresh) are gated server-side byManagementActionFilter(.RequireManagementActions()). It enforces the sameFeatureFlags:EnableManagementActionsflag the UI reads from/api/config(on in Development/Testing, otherwise opt-in), plus an optionalSecurity:ManagementApiKeychecked against theX-Management-Keyheader.FakeAuthHandler(Host/, NET_RULES §3) additionally backs a[Authorize(Policy = "Management")]gate onPOST /api/impersonate(mapped only outside Production — with no scheme registered in Production, a failed policy must deny outright rather than challenge, orChallengeAsync()throws): readsX-Fake-User/X-Fake-Roles, throws in its constructor if instantiated underIsProduction(), and is registered inProgram.csonly for Development/Testing./api/whoamiexposes the resulting principal and/api/logoutis a no-op peer for the client to call (the scheme is stateless per-request; "logout" means the caller drops its ownX-Fake-*headers). - No login UI, by design (deviation from NET_RULES §3): the header ships no Session/Logout slot and no "MOCK DATA" banner, even though the auth plumbing above and
/api/config'sisMockModefield exist to back them.MainLayout.razoris statically server-rendered (<Routes />carries no render mode) — no@onclickfires and its injectedHttpClienthas noBaseAddressfor a relative fetch, so both were built once, could never actually render, and were deliberately deleted rather than kept as chrome that only looks functional (2026-08-03). Reinstating either correctly requires a smallInteractiveWebAssembly-rendered island component in the header, not a change toMainLayoutitself. This is intentional and permanent unless the owner asks for the interactive-island fix.
EnableTrimAnalyzer and PublishTrimmed are ON for .Client; the shared assembly is
trimmable. All client JSON now flows through
the source-generated AppJsonContext (AppJsonContext.cs), so the analyzer runs clean and
the previous IL2026/IL2104 WarningsNotAsErrors suppression has been removed — the
client builds trim-safe under the global TreatWarningsAsErrors. Add new client (de)serialised
types to AppJsonContext with a [JsonSerializable] attribute.
One project per tier, each named for the tier it is. The fast tier is the first two.
PoPunkouterSoftware.Unit— strictly no-I/O (HTTP stubs, in-memory caches). RULE: DTO-mapping tests live in.Integration, never here.PoPunkouterSoftware.Integration—WebApplicationFactory+ Testcontainers Azurite, including a fixture that runs Azurite INSIDE the factory with explicit teardown. Collections run sequentially (AssemblyInfo.cs) because concurrent entry-point boots race.ProductionBootTestsboots the entry point underProductionwith every external dependency blanked — the only coverage of that environment's hosting pipeline.PoPunkouterSoftware.E2EAPI—ApiSmokeTests, pure-HTTP against a live instance viaBASE_URL(defaulthttp://localhost:8000). Also the post-deploy smoke: pointBASE_URLat production. On demand, not in CI.PoPunkouterSoftware.E2EUI—PortfolioUiTests, Playwright. Every UI test runs BOTH mobile-portrait (390×844) and desktop-landscape (1440×1000) via the sharedViewportstheory data — visual parity is a hard rule. On demand, not in CI.
CI/CD: two workflows. deploy.yml is deliberately build-and-deploy only — no test
steps live in the YAML. screenshots.yml runs nightly (06:17 UTC) to capture the
portfolio screenshots on a Linux runner, because the production Windows F1 sandbox
cannot run Chromium. All test tiers run locally before pushing to master
(fast tier: dotnet test tests/PoPunkouterSoftware.Unit then
dotnet test tests/PoPunkouterSoftware.Integration — dotnet test takes ONE project per
invocation; E2E: on demand, headed via HEADED=1, installed Chrome via
BROWSER_CHANNEL=chrome).
- User-level plugins (
claude plugin list):dotnet-claude-kit(codewithmukesh),dotnet-skills@wshaddix-dotnet-skills(167 skills + 16 agents; local marketplace at~/.claude/local-marketplaces/wshaddix-dotnet-skills), and the officialdotnet/skillspluginsdotnet,dotnet-aspnetcore,dotnet-blazor,dotnet-test,dotnet-data,dotnet-diag(marketplacedotnet-agent-skills). - Project-level skills (
.claude/skills/): auto-managed by thedotnet-skillsglobal tool (managedcode). Refresh withdotnet skills install --auto --prune --agent claudeafter changing package references.
- Local (Development env): F5 in VS Code runs
f5-prep(kill stale dotnet → start Azurite → build) then launches onhttp://localhost:8000.SCRIPTS/setup.ps1provisions WinGet/Docker/Azure prerequisites. - Azure (Production env):
.github/workflows/deploy.ymlbuilds + deploys to App Serviceapp-popunkoutersoftware(OIDC login, no secrets in workflow, no test steps by design).