Commit Graph

1981 Commits

Author SHA1 Message Date
Oliver Kieselbach e2d78c6b06 chore(ci): rename SWA workflow to deploy-web.yml with neutral deploy-token secret
The brave-grass filename and secret name referred to the pre-migration
Static Web App hostname. Workflow now matches the deploy-backend/-mcp
naming scheme and uses AZURE_SWA_DEPLOY_TOKEN (already set to the new
SWA's token). The old secret is removed after the first green run.
2026-07-18 16:51:39 +02:00
Oliver Kieselbach 3d06a689cc feat(web): rename Known Issues to Service Announcements, drop maintenance banners
Menu/dashboard/tip labels now match the docs page title. The temporary
maintenance banners on landing and dashboard are removed — the completion
notice lives in Service Announcements. Also checks in the migration
follow-up plan and the runbook outcome addendum.
2026-07-18 16:43:50 +02:00
Oliver Kieselbach 2d5f87140f chore: retarget SignalR quota watcher to Standard S1 limits + CI build hygiene
- SignalR quota watcher/health card: defaults 20/20k (Free tier) -> 1000/1M
  (Standard S1 x1 unit); settings renamed SignalRFreeTier* -> SignalRConnectionLimit/
  SignalRDailyMessageLimit (no Azure overrides were set); all operator-facing
  texts tier-neutral (connections 429 hard, message overage is billed not throttled)
- web: drop obsolete edge runtime from opengraph/twitter/apple-icon image routes
  (next/og runs on Node since 14; images now statically generated at build)
- web: update caniuse-lite browser db (silences 6-months-old Browserslist warning)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:38:15 +02:00
Oliver Kieselbach ce1ca3dbf4 updated SignalR limits on system health 2026-07-18 16:22:49 +02:00
Oliver Kieselbach b1a8e371ad chore(build): retarget DecisionCore.Tests to net10.0, drop 8.0.x SDK from agent CI
Nothing required net8.0 -- the test runner just predated the .NET 10
migration. Agent projects stay net48 (Intune/OOBE constraint).
474/474 tests green on net10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:01:39 +02:00
Oliver Kieselbach b698a87bc8 chore(build): align local and CI builds on .NET 10 SDK
- global.json pins SDK 10.0.2xx (latestFeature) for local and CI parity
- build-agent.yml: SDK 10 as build toolchain, 8.0.x kept for the
  net8.0 DecisionCore.Tests runtime
- build.ps1: stale .NET 8 hints updated to .NET 10

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:59:23 +02:00
Oliver Kieselbach ac31ee980e feat(ci): mirror agent artifacts to legacy storage (transition until download.-alias migration; fail-soft, counter stays on eu only) 2026-07-18 15:57:40 +02:00
Oliver Kieselbach 1bf352542d fix(ci): agent version MERGE targets the eu table endpoint
The AdminConfiguration table SAS is issued for autopilotmonitoreu; the
MERGE URL still pointed at the legacy account and failed auth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.0.1359
2026-07-18 15:11:54 +02:00
Oliver Kieselbach ebfb00da3d updated command 2026-07-18 14:11:19 +02:00
Oliver Kieselbach e2d711dc30 Merge branch 'migration/net10' 2026-07-18 14:03:29 +02:00
Oliver Kieselbach dd39205486 feat(migration): pivot global resource names to eu suffix
Cross-tenant re-claim of recently deleted global names is denied by
Azure by design (dangling-DNS/subdomain-takeover protection). New
tenant resources therefore use eu-suffixed names; the old names live
on in the old subscription (storage 'autopilotmonitor' recreated as
legacy bootstrap-URL keepalive, FuncApp/ACR kept as tombstones).

- API base URL -> autopilotmonitor-api-eu.azurewebsites.net
  (Shared Constants, web CSP + /go fallback, MCP default)
- Agent blob container + build counter -> autopilotmonitoreu
- deploy-backend app-name, deploy-mcp ACR -> eu names
- Bootstrap script URLs stay on the legacy storage (customers
  migrate once to a domain alias later)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:02:55 +02:00
Oliver Kieselbach 7377eb9e4d fix(ci): use current repo slug in attestation verify hints
gh attestation verify only passes with the repo's current name
(okieselbach/AutopilotMonitor); the signed workflow identity carries it,
and the old dashed name fails identity matching even though git-remote
redirects still work. Verified against the first attested dry-run ZIP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:22:15 +02:00
Oliver Kieselbach b2b99e78b4 fix(ci): tolerate leading '?' in SAS token secrets
First real run of build-agent.yml failed the counter CAS write with
"AuthenticationFailed ... sp is mandatory": the AZURE_BLOB_AGENT_SAS_TOKEN
secret stores the query with its leading '?', and the workflow prepends
another one, so Azure saw '?sp' as the first parameter name. The pre-existing
upload step had the same latent bug -- it had just never run. Normalize with
TrimStart('?') at all three usage sites (counter, blob upload, table merge).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:00:10 +02:00
Oliver Kieselbach 6d7783566f feat(ci): shared build-counter blob, CI build parity, ZIP provenance attestation
Build numbers now come from a public counter blob (buildcounter-v2.txt,
reserve-before-build with ETag-CAS) shared by local build.ps1 and CI, so the
two paths can never mint the same number. The CI step that committed
buildcounter.txt bumps to main is gone; the repo file is now a gitignored
offline fallback for -SkipUpload smoke builds only.

CI parity fixes vs the local build:
- ship SummaryDialog.exe.config in the ZIP (WPF AppContextSwitchOverrides;
  without it the dialog dies with 0x8007045A when spawned from SYSTEM)
- verify EXE version == manifest == reserved build number before zipping
- update the AdminConfiguration hash-oracle only on publish_as_stable

CI-built release ZIPs now carry a GitHub Artifact Attestation (Sigstore
keyless provenance, actions/attest-build-provenance@v4).

Doc: docs/agent/build-counter-blob.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:49:18 +02:00
Oliver Kieselbach 2a327902b8 fix(web): keep GA tenant filter usable through the zero-session empty state
Two dashboard flow gaps for cross-tenant (GA/MSP) tenant drilling:

1. Emptying the tenant filter input by hand (backspace/delete) only widened
   the client-side view over the already-loaded filtered sessions; the
   backend scope stayed on the old tenant until Filter was pressed again.
   An emptied input now acts as an implicit clear and refetches all sessions.

2. When a filtered tenant had no sessions, the whole SessionTable — and with
   it the tenant filter bar — unmounted, leaving only the welcome view with
   no way to clear or change the filter without leaving the page. The filter
   bar is now extracted into TenantFilterBar and stays on screen (with a
   short hint) above the welcome message in cross-tenant mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:18:41 +02:00
Oliver Kieselbach 6a8358a2a3 fix(agent): case-insensitive Diagnostics\Autopilot registry lookups
Probe enrollment e7dbac7a (24H2) showed Windows writes IsAutoPilotDisabled
(capital P) while the troubleshooting FAQ documents IsAutopilotDisabled —
the missing-event flat evidence lookup was case-sensitive and would have
missed it. Registry value names are case-insensitive by contract, so the
dump dictionary now uses OrdinalIgnoreCase; original casing is preserved
in the emitted payload. Doc notes the casing caveat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:02:44 +02:00
Oliver Kieselbach 19e796820e feat(agent): backfill Autopilot-channel error records at startup
In the normal IME deployment model the agent installs only after MDM
enrollment, so TPM attestation and ZTD errors from earlier (retried)
attempts sat invisible in the event log. The tracker now replays
Autopilot-channel Level 1-2 records from the last 4h at startup through
the regular emission pipeline.

Spam containment (answering "spam or backfilled event?" — individual
events, but marked and bounded):
- errors only (Warning-level 100 heartbeat excluded by XPath)
- harmless-ID downgrade + burst rollup still applies to backfilled records
- cap of 25 records per scan, newest first (tail logged, not emitted)
- RecordId watermark persisted to autopilot-error-backfill.json, advanced
  by both backfill and live watcher -> restarts never re-emit
- events carry backfilled=true + original timeCreated; the event's own
  timestamp is emission time, so backend StartedAt alignment is not
  dragged backwards by pre-enrollment records

Docs: Observability matrix updated (TPM row now visible-via-backfill) +
new "Startup error backfill" section. Tests: 5 new (XPath, watermark
roundtrip/monotonicity, backfilled/live markers, rollup-on-backfill);
full V2 suite 1804 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 19:28:49 +02:00
Oliver Kieselbach 34edadf0db docs(rules): document observability limits of the ZTD known-issue rules
The agent is normally installed by an IME platform script AFTER MDM
enrollment succeeded, so pre-enrollment failures (enrollment blockers,
TPM attestation) are invisible in the normal flow — no enrollment means
no agent and no authenticable session. Await-enrollment mode does not
help either: the binary only polls for the MDM certificate and cannot
authenticate without it. Only bootstrap-token deployments (one pilot
customer) can report pre-MDM.

- New Observability section in docs/agent/autopilot-ztd-diagnostics.md
  with a per-rule visibility matrix (normal / bootstrap / await) and the
  follow-up option of backfilling Autopilot-channel error records
- ANALYZE-ENRL-003/004 descriptions state the limitation explicitly so
  portal users understand why these rules rarely fire

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 19:14:36 +02:00
Oliver Kieselbach cd870e639d feat(agent+rules): evidence-backed ZTD diagnostics for missing Autopilot profiles
Agent (autopilot_profile_missing evidence package):
- ZtdEvidence collector: one-shot all-level query of the ModernDeployment
  Autopilot event channel (807/809/815/908 errors + 100/153/160/161/163/164
  flow events) -> ztdVerdict field (device_not_registered /
  no_profile_assigned / download_attempted_no_profile_returned / ...);
  the continuous watcher can't supply this (filters out Info)
- Diagnostics\Autopilot registry dump (IsAutopilotDisabled, TenantMatched,
  CloudAssignedTenantDomain/Id) on every autopilot_profile event, flat
  evidence fields on the missing event
- ztd.dds.microsoft.com reachability probe (official endpoint per the
  requirements page - several blogs cite ztd.ssd.*, which is wrong);
  volatile probe fields excluded from the restart-dedup fingerprint;
  Intune diag-upload endpoint deliberately NOT probed (user decision)

Analyze rules (known-issue map from Microsoft Learn):
- ANALYZE-ENRL-003: TPM attestation known error codes (0x800705B4,
  0x801C03EA, 0x81039001/23/24, 0x80070490)
- ANALYZE-ENRL-004: enrollment-blocked known codes (0x80180014, 0x80180018,
  0xC1036501, 0x801C03F3, 0x80070774)
- ANALYZE-ENRL-005: hybrid-join 0x80004005 timeout with per-version KB fix
  builds (gated on isHybridJoin via confidence threshold)
- ANALYZE-DEV-007: NTP clock skew > 2min (documented TPM-attestation/ESP
  timeout cause) using existing ntp_time_check offsetSeconds

Docs: new OKF reference docs/agent/autopilot-ztd-diagnostics.md (event-ID
table, diagnostic registry, endpoints, error-code map, sources + re-check
RSS feed), linked from ZtdEvidence/ModernDeploymentTracker code comments,
indexed in docs/index.md + agent/index.md + log.md.

Tests: 11 ZtdEvidence verdict tests; full V2 suite 1799 green; rule schema
validated (ajv draft2020); backend rule tests 123 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 19:07:05 +02:00
Oliver Kieselbach 417983d688 feat(sessions): persist + surface which device-validation path admitted the device
The backend admits agent sessions via up to three device-validation
paths (Autopilot S/N lookup, Corporate Identifier, Device Association
once DevPrep becomes a hard gate) plus the pre-MDM Bootstrap token —
but the winning path was only returned to the agent, never stored, so
operators could not see HOW a device was accepted.

- SessionRegistration.ValidatedBy: server-populated in
  ProcessRegisterAsync from the SecurityValidationResult (agent-sent
  values are overwritten unconditionally)
- Sessions row + SessionsIndex mirror + MapToSessionSummary carry
  ValidatedBy; latest non-Unknown validation wins (Bootstrap sessions
  upgrade to the cert-path validator on re-register), Unknown preserves
  the earlier value (tenants with device validation off)
- Web: "Device Validation" item on the session detail Session Info card
  (hidden for legacy sessions / validation-off tenants)
- MCP: get_session overview exposes validatedBy; raw endpoints carry it
  verbatim automatically
- Tests: SessionIndexProjectionTests pin the new index column

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:45:09 +02:00
Oliver Kieselbach bfdcf3694d fix(agent): sharpen autopilot_profile_missing wording to assignment gap
Sessions only exist after the backend's device-registration validation
(Autopilot S/N or Corporate Identifier via Graph) passed, so a device
with ProfileAvailable=0 is known to the tenant — the actual gap is the
deployment-profile ASSIGNMENT (not assigned, or still propagating when
the user went through OOBE), not the registration. Reword agent event,
UI callout, and MCP field docs accordingly; likelyCauses now
profile_not_assigned/profile_assignment_not_propagated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:30:51 +02:00
Oliver Kieselbach 95126876ac feat(agent): surface missing Autopilot profile as explicit warning signal
Devices that reach OOBE without an Autopilot profile (ProfileAvailable=0
in AutopilotPolicyCache — not registered, assignment not propagated, or
deleted from Autopilot) still enroll via manual Entra join + MDM
auto-enrollment and previously looked like regular Autopilot sessions
with an inexplicably empty profile section (session 423b5360).

- Agent: CollectAutopilotProfile emits autopilot_profile_missing
  (Warning) on explicit ProfileAvailable=0 evidence, with ZeroTouch
  tenant-domain extraction as supporting data; EmitDeviceInfoEvent
  gains an optional severity parameter
- Web: DeviceDetailsCard shows a callout in the Autopilot Profile
  section (also retroactively for old sessions via ProfileAvailable=0)
  and renders Profile Available 0 as "No"
- MCP: resource-catalog gains the new event type + field docs
- Tests: 11 unit tests for the detection/extraction helpers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:09:40 +02:00
Oliver Kieselbach 3ebeb644eb feat(mcp): resolve tenants by name or id directly in list_tenants
list_tenants gains two optional filter params so the model resolves a
tenant in ONE tool call instead of paging manually:

- query: case-insensitive substring match on domainName (and tenantId
  for pasted partial GUIDs), e.g. query="rewe" -> rewe-group.com
- tenantId: exact case-insensitive point lookup; wins over query

In filter mode the tool auto-paginates /api/config/all internally
(pageSize 1000, 10-page runaway budget -> `truncated` + recallNote if
ever hit) and returns only the matches. Filtering runs AFTER
extractTenantList secret-stripping and delegatedTenantListView MSP
scoping, so it can only narrow — never widen — what the caller may see.

Pure filterTenantList helper exported + 7 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:36:24 +02:00
Oliver Kieselbach d02fa454a7 fix(status): honest verdicts across all misclassification audit findings
Misclassification audit 2026-07-16 (1000 non-succeeded sessions, 22 deep
dives) surfaced six systematic misdeclaration sources. This implements
all of them:

1. DecisionCore: HelloNeverObservedPromote in the esp-exit advisory
   handler — when the Hello policy read never committed and no wizard
   was ever seen, hello_resolution is structurally unresolvable; promote
   to AwaitingHello + hello_safety instead of hard-failing a provably
   finished enrollment (sessions 2dac8298/7635ab18). Placed after the
   enforcement-progress re-arm so active installs still win; advisory-
   anchor variant stays exempt.

2. Ingest: both max-lifetime shapes (enrollment_failed/agent_timeout and
   agent_shutting_down/max_lifetime) now route through the
   EnrollmentTimeoutClassifier instead of hard Failed (tenant a53e67ec:
   fully provisioned WG Part 2, user never logged in => AwaitingUser).
   agent_timeout no longer counts as HasExplicitFailure; failure
   webhooks fire only on actual failure verdicts.

3. Maintenance sweep: dropped the too-broad "IsPreProvisioned ne true"
   exclusion (sealed devices are protected by their Pending status);
   resumed WG Part-2 runs sweep normally with the window anchored to
   ResumedAt. New stage-2 silence guard also fixes a latent bug where an
   actively-reporting >5h enrollment could be terminalized mid-run.

4. Registration supersede: a fresh registration resolves older
   non-terminal sessions of the same device (serial, placeholder-guarded)
   as Incomplete("Superseded by ...") with live SignalR updates — closes
   the forever-Pending orphan gap when WG Part 2 runs under a new id.

5. Admin retro-reconcile: POST /api/maintenance/reclassify-legacy
   (GlobalAdminOnly, DRY-RUN BY DEFAULT) re-runs the classifier over the
   pre-classifier "Session timed out" Failed backlog (~495 rows, sample
   projected ~87% misdeclared) and resolves Pending orphans. Requires the
   new opt-in allowTerminalReclassification override in the status
   writer (admin-marked rows stay untouchable).

6. Polish: "0 of N" AccountSetup rollups keep their total (no more
   fabricated "0/0" reasons, "never observed" wording when absent);
   late-telemetry reconcile heals Incomplete/AwaitingUser to Succeeded
   when straggler evidence proves completion; SLA page knows
   AwaitingUser/Incomplete ordinals; script_failed on Success/exit-0 now
   names the stderr rule in its message.

Tests: backend 2965, DecisionCore 474 (+6 new), agent 1777, web 557 —
all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:24:21 +02:00
Oliver Kieselbach 7521158e65 backend: bump to .NET 10 (dotnet-isolated 10.0) for the Flex recreate
TargetFramework net8.0 -> net10.0 (Functions + Tests), setup-dotnet 8.0.x -> 10.0.x. JwtBearer 8.0.11 -> 10.0.10 (Microsoft.Identity.Web 4.3.0 requires >= 10.0.0 on net10). Drops the System.Formats.Asn1 / System.Security.Cryptography.Xml vuln-override pins - superseded by the net10 framework versions (NU1510). Build clean, 2937/2937 backend tests green. Merge on cutover Saturday BEFORE running deploy-backend (the new Flex app is created as dotnet-isolated 10.0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:34:22 +02:00
Oliver Kieselbach 87cf893b03 feat(reconcile): surface silence duration + decision time in timeout reconcile
The "Backend reconciled" badge showed only the reason text with no timestamps,
so operators/customers could not tell "user finished setup and powered the
device off" apart from "the mechanism declared success too early" (session
efbc17ff): last-contact, silence window and the exact decision moment were all
invisible, and the stage-1 "Agent silent for Nmin" note is wiped by the
Succeeded-write reconcile hygiene.

- EnrollmentTimeoutClassifier.ClassifyTimedOutSession takes an optional
  lastEventAtUtc and appends a timing clause to the two Succeeded-reconcile
  reasons (AccountSetup-complete + desktop/Hello): last agent contact, silence
  duration, and the sweep's declaration time. Lives in the reason string so it
  survives the hygiene and reaches badge/MCP/exports.
- MaintenanceService passes session.LastEventAt through.
- Web: add Session.stalledAt; reconcile banner renders structured UTC anchors
  (last agent contact + first-flagged-silent with the observed silence gap).

Verified: build 0 errors; classifier 36/36 (2 new timing tests);
maintenance/status/projection 160/160; web tsc clean; vitest 557/557.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:35:50 +02:00
Oliver Kieselbach f0a5e550d3 ci: switch Azure deploy login to secret-less OIDC (federated credential)
deploy-backend + deploy-mcp now authenticate via azure/login OIDC (client-id/tenant-id/subscription-id + id-token permission) instead of the AZURE_CREDENTIALS service-principal secret. Adds temporary oidc-login-test workflow to smoke-test the federated login. Federated credential 'github-main' (repo:okieselbach/Autopilot-Monitor:ref:refs/heads/main) created on the deploy app registration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:47:25 +02:00
Oliver Kieselbach c649d80c86 fix(agent): harden single hardware read + reuse it for the session row
The agent read device hardware twice: once at startup via HardwareInfo
(feeds the security headers + telemetry) and again at registration via
DeviceInfoProvider (feeds the Sessions row). When WMI was transiently
unavailable during OOBE, the body read returned "Unknown" while the header
read had already captured real values — producing session rows with
Manufacturer/Model/SerialNumber = "Unknown" even though the backend
validated (and passed) real values from the headers.

- HardwareInfo.GetHardwareInfo: retry once (~1.5s) when any field is still
  unresolved, per-field merge across attempts, and isolate each WMI query so
  a BIOS-serial failure no longer discards manufacturer/model. Healthy
  devices resolve on attempt 1 and never sleep.
- SessionRegistrationHelper.BuildRegistration: accept the already-read
  hardware tuple and use it instead of re-querying WMI (falls back to
  DeviceInfoProvider only when none supplied, e.g. unit tests).
- BackendSessionRegistration: pass the auth bundle's hardware through so the
  session row matches the headers the backend validated against.

Net: one hardened WMI read instead of two, and the Sessions row can no
longer diverge from the security headers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:32:10 +02:00
Oliver Kieselbach b0105b123a feat(maintenance): session-deletion run budget, live watchdog counters, manual trigger
- Watchdog fix: FanoutResult is caller-allocated and mutated live per
  tenant/session, so 30/60min LongRunning warnings report real progress
  instead of the always-zero counters (today's warning showed 0/0 while
  5 tenants / 54 sessions were already done).
- 50min run budget: the retention fanout stops cleanly at the deadline
  (tenant boundary + per-session check) and reports AbortedByBudget —
  no more risk of the 60min Flex functionTimeout hard-aborting mid-run.
  New Warning OpsEvent SessionDeletionMaintenanceBudgetExceeded;
  Completed gains abortedByBudget.
- Blob-lease serialization: dedicated sentinel
  _lock/session-deletion-maintenance.lock (deletion-manifests container)
  via SessionDeletionMaintenanceLockStore + reused MaintenanceLeaseHolder;
  contended runs emit SessionDeletionMaintenanceSkippedLocked and return.
- Manual trigger: POST /api/global/session-deletions/maintenance/trigger
  (GlobalAdminOnly, policy catalog) -> fail-hard queue producer ->
  SessionDeletionMaintenanceQueueWorker runs the same RunCoreAsync body
  with triggeredBy = requesting admin; lease probe returns 409 while a
  run is active. New Started OpsEvent carries triggeredBy.
- Web: MaintenanceStatusBanner on the Session Cleanup page derives
  active / budget-exceeded / failed / idle from the lifecycle ops events
  and offers a Run-now button (202/409 handling). New event types
  dual-registered in OPS_EVENT_TYPES.
- Tests: 7 new (budget abort immediate/mid-tenant, live counter
  increments, watchdog live-progress regression, BudgetExceeded +
  Completed flag, SkippedLocked, Started triggeredBy); suites green
  (backend 2935, web 557, tsc clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:22:31 +02:00
Oliver Kieselbach 8a824d73fc refactor(decision-core): EnrollmentScenarioObservations as record with with-expressions
The 8-parameter constructor plus 7 hand-threaded With* methods meant every new
observation field touched 8 copy sites - a threading-bug risk flagged in the
daily commit review (2026-07-14). Now a sealed record with init-only properties:
the With* methods keep the set-once guard but their bodies collapse to one-line
`this with { ... }` expressions, so the next field addition is one property plus
one With* method.

- IsExternalInit polyfill (internal) added: DecisionCore targets netstandard2.0,
  which lacks the C# 9 marker type for init setters. Consumers never need it -
  init/with usage stays inside the assembly.
- Snapshot compatibility unchanged: identical JSON shape (property names +
  SignalFact {Value, SourceSignalOrdinal}); the record deserializes via init
  setters instead of the removed constructor. Pinned by a new StateSerializer
  round-trip test covering facts, ordinals, and unset-stays-null.
- Semantics unchanged: set-once still returns `this` (Assert.Same tests hold);
  `==` is not used on the type anywhere, so record value equality is inert.

Verified: DecisionCore 468 tests, Agent V2 Core 1776 tests on net48 (the
consumer that mandated the netstandard2.0 target), Backend 2928 tests - all
passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:02:02 +02:00
Oliver Kieselbach 3a4d80cd7f hardening(auth): validate + audit EntraId:AdditionalClientIds trust-set expansion
Expanding the token audience trust set via EntraId:AdditionalClientIds was
completely silent: no format validation, no operator-visible logging. During
the tenant-move window a typo'd GUID would be indistinguishable from "the new
app registration doesn't work" (daily-review follow-up, 2026-07-14).

- ResolveConfiguredClientIds: additional entries must parse as GUIDs; malformed
  entries are dropped (fail-closed - they could never match a token aud) and
  reported via out param. Accepted entries are normalized to the canonical
  lowercase dashed GUID form so brace/uppercase paste variants still match the
  ordinal audience comparison. Primary ClientId handling stays verbatim.
- AuditClientIdTrustSet: one Warning per distinct config value per instance for
  (a) dropped malformed entries (truncated - never log a full pasted value in
  case it is a secret) and (b) trust-set expansion with the accepted ID list.
  Warning level because nothing below Warning reaches App Insights.
- Tests: existing ResolveConfiguredClientIds tests moved to GUID values
  (signature change, no compat alias) + rejection, primary-verbatim,
  normalization and TruncateForLog coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:01:43 +02:00
Oliver Kieselbach c02849bd99 added Maintenance Service Announcement 2026-07-14 13:14:31 +02:00
Oliver Kieselbach acc4823c9a section loading design changes 2026-07-13 21:03:37 +02:00
Oliver Kieselbach 9dfbe1e653 fix(web): eliminate Leaflet _leaflet_pos crash on geo-map unmount
The Geographic Performance map threw "Cannot read properties of undefined
(reading '_leaflet_pos')" during client-side navigation. An animated
fitBounds/setView schedules a deferred rAF/transitionend callback that reads
the map pane's _leaflet_pos; if the page unmounts before it fires, the pane is
torn down and the callback crashes.

- FitBounds: pass { animate: false } so the camera move applies synchronously
  and leaves no dangling animation callback.
- GeoMap: memoize mappableLocations so FitBounds' effect (and the camera move)
  only re-runs on real data changes, not on every parent re-render — closing
  the amplified race window.

MapCleanup's map.remove() stays as defense-in-depth for instance teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:01:15 +02:00
Oliver Kieselbach b18846a698 updated gitignore 2026-07-13 18:08:23 +02:00
Oliver Kieselbach 6ff49f7b93 fix(decision-engine): prevent premature device-only completion on WhiteGlove Part 2
Session 62e603c9 (user-driven, Hybrid Join, WhiteGlove Part 2, Classic ESP
with SkipUser=True) completed too early: the device_only_esp_detection 5-min
deadline classified the session SelfDeploying even though the registry probe
said isSelfDeployingProfile=false, Hello timed out and no desktop arrived.
Root cause: the Part-2 reseal-reboot archives the snapshot and re-seeds an
empty scenario profile (Mode=Unknown), so nothing blocked the behavioural
deadline from overriding the deterministic registry fact.

A - carry the Part-1 WhiteGlove classification across the reseal reboot:
RecoveryCoordinator captures rawSnapshot.ScenarioProfile before archiving and
re-seeds Part 2 with Mode=WhiteGlove/High, PreProvisioningSide=User
(reason whiteglove_part2_carryforward). The existing monotonic-mode guards +
deadline race guard C then dead-end the device-only deadline.

B - record the registry self-deploying fact for both true and false and veto
on explicit false: new set-once EnrollmentScenarioObservations
.RegistrySelfDeployingProfile, recorded in HandleEnrollmentFactsObservedV1,
plus a new race guard D in HandleDeviceOnlyEspDetectionDeadlineFired that
dead-ends on an explicit false (device_only_esp_detection_registry_not_self_deploying)
and cancels the deadline state-side. null (unobserved) never trips, so the
legacy behavioural completion and the kiosk case 320b3bf7 (registry true)
are unaffected. Covers non-WhiteGlove SkipUser=True flows too.

Observability - phase_transition events used to emit empty {} DataJson;
BuildPhaseTransitionEffect now attaches a lean typedPayload
{decisionSource, trigger, scenarioMode, scenarioConfidence} at all eight
finalizing/complete call sites, routed verbatim to EnrollmentEvent.Data.

Tests: DecisionCore +5 (guard D false/true/null, false-observation recording,
phase_transition payload), Agent V2 +1 (Part-2 carry-forward). No ConfigVersion
or table-serialization change; snapshot schema stays v1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:04:37 +02:00
Oliver Kieselbach bffbf35875 feat(auth): config-driven audience set — trust a second app registration
Make JWT audience validation accept the primary EntraId:ClientId PLUS an
optional comma/semicolon/space-separated EntraId:AdditionalClientIds. Each
client ID expands to {id} and api://{id}; the set is trimmed, empties dropped,
and de-duplicated case-insensitively (fail-closed on empty input).

This is the seam that lets the API trust a second/rotated app registration
during a future Entra app-reg tenant move: adding a client ID to
EntraId__AdditionalClientIds is the only backend step needed. When the setting
is unset the accepted audiences are exactly the primary id — today's behaviour,
zero change for production.

BuildTokenValidationParameters now takes params string?[] clientIds (backward
compatible), with two pure, unit-tested helpers ResolveConfiguredClientIds and
BuildValidAudiences. +12 tests (multi-audience accept, primary still valid,
foreign audience rejected, parsing/dedupe/fail-closed). 64/64 auth tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:44:53 +02:00
Oliver Kieselbach da7d663c3b fix(agent): admin-friendly hello_policy_detection_mismatch wording
"detector bug suspected" confused customer admins and is factually
wrong since sessions 772fe502/1696e416 proved the usual cause: the
user-scoped WHfB CSP value flip-flops during User-ESP while Intune sync
is still writing, so even a twice-confirmed "disabled" read can be
stale by the time the wizard launches.

New message explains what happened and that enrollment tracking is
unaffected (live Hello events take precedence). Adds a likelyCause
data field; event type, severity and detection logic unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:15:03 +02:00
Oliver Kieselbach 108c52be5e updated docs - moved customer graph permission to customer docs 2026-07-13 10:28:44 +02:00
Oliver Kieselbach 0da59a640b updated docs - moved customer graph permission to customer docs 2026-07-13 10:28:32 +02:00
Oliver Kieselbach 977ad37463 fix(agent): HelloWizardStarted signal — never complete while the Hello wizard is running (session 772fe502)
A flip-flopping user-scoped WHfB CSP read "disabled" once, the arm-C
completion synthesized HelloOutcome="Skipped" and the session completed
5s later while the user sat inside the Hello wizard that had started
230ms earlier (Shell-Core 62404, previously InformationalEvent-only).

Layer 1 — engine (DecisionCore):
- New DecisionSignalKind.HelloWizardStarted + set-once fact
  DecisionState.HelloWizardStartedUtc (additive-nullable, census +
  snapshot wired).
- Prevention: centralized HelloSatisfiedForCompletion /
  HelloPolicyDisabledWithoutWizard predicates veto the policy-disabled
  stand-in at all five Skipped-synthesis sites and in the
  completion_waiting prerequisites; the AdvisoryCompletion handler gets
  a HelloWizardObservedPromote branch (its fallthrough is
  re-arm-or-fail).
- Cure: HandleHelloWizardStartedV1 retracts an engine-synthesized
  "Skipped" (exact-case discriminator vs the lowercase tracker
  vocabulary; first fact-nulling reducer, stage-agnostic because the
  RealmJoin gate-defer parks synthetic skips pre-Finalizing), cancels
  finalizing_grace, returns to AwaitingHello and arms HelloSafety.
- Agent path: ShellCoreTracker raises HelloWizardStarted before
  FinalizingSetupPhaseTriggered; 62404 included in the startup backfill
  (fire-once); coordinator timestamp mirror; adapter emit (fire-once).

Layer 2 — HelloTracker confirmation read:
- ApplyPolicyRead state machine: "disabled" commits only after a second
  confirming read (~10s poll); "enabled" commits immediately;
  disabled→enabled flags flipFlopDetected=true; pending keeps the
  policy-unknown wait cadence. Audit fields confirmedReads /
  flipFlopDetected on hello_policy_detected.

Fixtures: signal-kinds/hello-wizard-started-v1.json +
enrollment-sessions/userdriven-hello-wizard-unskip-v1.jsonl (772fe502
ordering, ends Completed with HelloOutcome=completed).
Tests: DecisionCore 462/462, V2.Core 1775/1775.
Docs: OKF concept doc docs/agent/hello-wizard-unskip.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:16:10 +02:00
Oliver Kieselbach b5508965b6 feat(sessions): ReconcileReason — keep backend-declared successes explainable to operators
The Succeeded reconcile hygiene (c0585133) clears FailureReason/FailureSnapshotJson on
every Succeeded write, so a sweep-reconciled session (049b6e5b, session 294ab5b4) was
indistinguishable from an agent-reported completion. Operators must always be able to
tell whether WE declared a status or the device reported it.

- New Sessions column ReconcileReason, set only for backend-automatic Succeeded verdicts:
  the maintenance sweep's classifier reason is persisted verbatim; a reason-less late
  completion upgrading a prior Failed/Incomplete/AwaitingUser verdict synthesizes
  "Late completion report received — upgraded prior '<Status>' verdict".
- Admin flips stay attributed via AdminMarkedAction (no double badge); normal agent
  completions leave the field empty. Pure ComputeReconcileReason next to
  IsTerminalTransitionAllowed, pinned by 17 new facts.
- Survives re-registration (StoreSessionAsync preserve + fresh-terminal-tuple mirror)
  and StartedAt-shift reindex (SessionsIndex projection); force-merge path mirrors the
  normal path against the FRESH prior status.
- Web: "reconciled" chip in SessionStatusBadge (tooltip = justification) + info box on
  the session detail page; dashboard list passes the field through.
- MCP: get_session_summary overview carries reconcileReason.

Backend 2907/2907, web tsc + vitest 557/557, MCP 393 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 08:40:01 +02:00
Oliver Kieselbach ec5d5661ec docs: use relative links instead of OKF bundle-absolute links
GitHub resolves /-prefixed links against the repo root (missing /docs),
so cross-doc navigation broke. Switched all agent docs to standard
relative links and updated the bundle convention in index.md + CLAUDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:48:00 +02:00
Oliver Kieselbach 4ccb3b2317 docs(agent): OKF concept docs for the V2 agent
Three evidence-based documents under docs/agent/: runtime overview
(lifecycle, collector hosts, single-rail telemetry pipeline), decision
engine (DecisionCore reducer, signals/effects, A/B/C completion arms,
invariants, threading), and logs & persistence (artifact inventory,
signal log vs spool rationale, replay-based crash recovery, quarantine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:32:14 +02:00
Oliver Kieselbach 923a1c793a docs: convert /docs into an OKF v0.1 knowledge bundle
Adopt the Open Knowledge Format (GoogleCloudPlatform/knowledge-catalog)
for contributor/AI-facing technical docs: YAML frontmatter on the three
existing concept documents, new index.md (progressive disclosure, root
carries okf_version) and log.md, plus the maintenance convention in
.claude/CLAUDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:47:27 +02:00
Oliver Kieselbach 5b808f08de perf(apps): projected app-install scans behind one query core + progress UX for the apps surface
Third slice of the metrics-performance pass (after e63a0bc2 usage, 86f5b507
geo): the app endpoints ran unprojected full-column AppInstallSummaries
window scans behind the web app's default 30s fetch timeout.

Backend:
- All AppInstallSummaries scans (full, geo, app-metrics, apps-dashboard)
  now delegate to one private QueryAppInstallSummariesAsync core so filter
  semantics stay in lockstep
- New projections: AppMetricsProjection for metrics/app + global (ranking
  inputs, failure code and the DO counters) and AppsDashboardProjection for
  the App Dashboard list/analytics/sessions endpoints (drops the whole DO
  telemetry block, which those aggregations never read)
- AppsAnalyticsHelper.LoadSummariesAsync and both app-metrics functions
  switched to the projected variants
- AppsProjectionEquivalenceTests pins both projections, incl. the
  TenantId-from-PartitionKey fallback for legacy rows

Web:
- CalculatingInline: compact in-place variant of the calculating card
  (shared progress bar + elapsed caption extracted)
- Apps installs tab and the app detail page (analytics + sessions fetches)
  use 180s AbortSignals and show inline progress while aggregating

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:43:16 +02:00
Oliver Kieselbach 86f5b50778 perf(geo): parallel projected scans for geographic metrics + shared progress UX
Same pattern as the Platform Usage fix (e63a0bc2): the geographic endpoints
ran two sequential full-column window scans per request while the web app's
default 30s fetch timeout could abort the response client-side.

Backend:
- New column-projected queries: GetGeoWindowSessionsAsync ($select Geo*
  grouping fields + effective-duration inputs) and
  GetGeoAppInstallSummariesAsync ($select session join key, window filter,
  throughput inputs and all Delivery Optimization counters; returned
  AppInstallSummary objects carry only those fields by documented contract)
- All four geographic functions (tenant/global map + tenant/global location
  drilldown) run their scans concurrently via Task.WhenAll; the map
  endpoints use both projections, the drilldowns keep full-row sessions
  (LocationSessionRow returns nearly every column) but switch to the lean
  app scan
- GeoMetricsProjectionEquivalenceTests pins both projections, incl. the
  location grouping key and the DoDownloadMode=-1 "no DO telemetry" marker

Web:
- New shared useFetchProgress hook + CalculatingCard component (elapsed
  counter + progress bar sized from the last recorded fetch duration,
  capped at 95%)
- geographic-performance map + sessions drilldown: 180s AbortSignal and
  the calculating card instead of a bare loading line
- SectionPlatformUsage refactored onto the shared hook/component (drops
  its inline copy of the timer/estimate logic)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:28:17 +02:00
Oliver Kieselbach 556707bcd4 fix(metrics): platform stats are monotonic since-release counters — recompute may never regress them
The daily RecomputePlatformStatsAsync overwrote TotalEnrollments,
SuccessfulEnrollments, TotalEventsProcessed, TotalTenants and
UniqueDeviceModels with counts scanned from retention-pruned tables, so
the public "since release" figures regressed after every retention
cleanup. Only TotalUsers already had the high-water clamp.

- extract BuildMonotonicPlatformStats (internal, unit-tested): every
  cumulative counter is Math.Max(recomputed, persisted) — the recompute
  can raise a counter (self-heal for lost fire-and-forget increments
  while sessions are still within retention) but never lower it;
  IssuesDetected stays carried over verbatim (increment-only)
- deliberate exception: TotalSignedUpTenants remains current-state —
  TenantConfiguration is not retention-pruned, a drop there is real
  offboarding, not data loss
- PlatformStats model docs updated to pin the semantics

4 new tests (PlatformStatsMonotonicTests); backend suite 2887/2887.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:12:00 +02:00
Oliver Kieselbach e63a0bc2bd perf(metrics): fix Platform Usage client timeout — parallel projected scans, 15min cache, single-flight, progress UX
Fresh cross-tenant usage computes took 20-43s server-side but the web app's
default 30s fetch timeout aborted them client-side (HTTP 499), and every
retry stacked another concurrent full compute.

Backend:
- UsageMetricsService: cache 5→15min, single-flight per days-window (retries
  join the in-flight compute), the four source scans run concurrently via
  Task.WhenAll, statics converted to instance fields (singleton, testable)
- New column-projected queries for the usage compute only:
  GetUsageWindowSessionsAsync ($select 13 consumed columns, drops
  FailureSnapshotJson & co.) and GetAppInstallRefsAsync ($select
  SessionId+AppName, lean SessionAppRef DTO); GetSessionsByDateRangeAsync
  is untouched for its other consumers
- Projection equivalence pinned by UsageMetricsProjectionEquivalenceTests
  (incl. the WhiteGlove Part-2 duration branch via IsPreProvisioned/
  ResumedAt); single-flight/cache/failure-eviction pinned by
  UsageMetricsServiceSingleFlightTests

Web (SectionPlatformUsage):
- 180s AbortSignal for this call (backend keeps computing and caches)
- Calculating card with elapsed time and a progress bar sized from the last
  fresh-compute duration (localStorage, cache hits excluded, capped at 95%)
- Friendly timeout error explaining the retry will hit the server cache

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:10:56 +02:00
Oliver Kieselbach 860efbbd1b fix(metrics): gate platform TotalEnrollments increment on fresh registration + document totalAllTime in MCP
- IncrementPlatformStatAsync("TotalEnrollments") fired on EVERY
  re-register (agent restarts, WG Part 2 resumes) — the nightly
  recompute corrected it, but the public stat visibly overcounted in
  between. Now gated on isFreshRegistration alongside the per-tenant
  counter (one shared block)
- MCP get_usage_metrics: the tool passes the backend response through,
  so sessions.totalAllTime flows automatically — extend the tool
  description so callers know the field is cumulative since signup and
  retention-independent; bump MCP server to 1.5.1

Backend tests 2876/2876, MCP tsc clean + vitest 393 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:01:14 +02:00