Re-home agents to a new API base URL through the agent/config response
(backend move or per-tenant EU->US region move) - the lesson from the
2026-07-18 WEU->GWC cutover, where the compiled-in API hostname hard-cut
every in-flight agent (no DNS indirection possible: mTLS breaks behind a
proxy, Flex custom-domain TLS is broken).
Backend (ConfigVersion 33):
- AdminConfiguration.AgentMigrateApiBaseUrl (global) +
AgentMigrateTenantOverridesJson (tenantId->URL; empty value pins a
tenant against the global target; override wins)
- GetAgentConfigFunction.ResolveMigrateTarget serves the validated target;
AgentMigrateServed / AgentMigrateRejected warnings as delivery evidence
- TableConfigRepository Store+Map; admin portal Global Settings round-trip
Agent (kill-switch-mirrored hardening):
- Shared AgentEndpointMigrationRules: https-only, *.azurewebsites.net on a
label boundary, no path/port/userinfo - enforced on BOTH sides
- Live fetch only: RemoteConfigService cache strips the field on write AND
read; EndpointMigration.ResolveTarget re-checks the fetch outcome
- AgentRuntimeConfig.Resolve returns before merge/integrity/bootstrap
cleanup on a pending migration (no spurious IntegrityCheckFailed against
a stale migration stub), Program.RunAgent rebuilds Phase 3+4 against the
new URL - single hop, kill signal wins before and after the hop
Tests: AgentEndpointMigrationTests (backend rules + resolution),
EndpointMigrationTests + cache-hardening strip (agent). Full suites green
(Functions 2992, Agent Core 1822, vitest 557, tsc silent).
Doc: docs/agent/endpoint-migration.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
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.
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>