Commit Graph

1259 Commits

Author SHA1 Message Date
Oliver Kieselbach 939bc2ebc7 fixed platform metrics, ops and session reports fetching 2026-05-07 09:14:37 +02:00
Oliver Kieselbach e7e501d8cd added audit filter for unnecessary entries 2026-05-07 08:39:04 +02:00
Oliver Kieselbach 5a44a9e495 fix(web): advance dashboard pagination on first Next click
Driving loadMore() from the auto-load effect in page.tsx left a one-render
gap where loadingMore was still false, so the snap-back effect in
useDashboardFilters fired first and reverted currentPage from 2 back to
totalPages=1 before the fetch even started. The fetch then ran (the
auto-load effect saw the captured currentPage=2 from that render) and the
list grew to totalPages=2, but currentPage had already been snapped to 1
— giving the visible "Page 1 of 2+" state and forcing the user to click
Next a second time to actually see the new batch.

Now handleNextPage calls loadMore() in the same click handler as
setCurrentPage(prev+1), so React 18's automatic batching commits both
state changes in the same render. Snap-back's `if (loadingMore) return`
guard then correctly skips, currentPage stays at the overshoot, and once
the fetch resolves totalPages catches up and the new batch renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:29:14 +02:00
Oliver Kieselbach 9b4100f7d6 fix(web): unify dashboard pagination + bump fleet-health page size
Dashboard: removed the standalone "Load 100 more sessions..." button. The
session table now has a single Prev/Next surface that transparently fetches
the next server batch when the user pages past locally-loaded sessions —
the existing auto-load effect in page.tsx already drives loadMore() once
currentPage * sessionsPerPage exceeds sessions.length, but handleNextPage
was clamping to totalPages so currentPage could never overshoot, leaving
"Next" disabled until the user clicked the separate Load-More button.

  - useDashboardFilters now takes hasMore + loadingMore; handleNextPage
    advances past totalPages when hasMore && !loadingMore. A snap-back
    effect resets currentPage to totalPages once a load finishes that
    didn't grow totalPages (filter case: new batch yielded zero matches).
  - SessionTable renders the Prev/Next controls whenever there's somewhere
    to navigate (totalPages > 1 || hasMore), shows "Page X of Y+" while
    more is on the server, disables Next only at the true end, and shows
    an inline spinner inside Next during loadingMore. The empty tbody
    distinguishes "loading sessions..." from "no results".

Fleet Health: bumped FLEET_PAGE_SIZE from 200 to 1000 (backend MaxPageSize
is 1000). A 90-day window on a busy install now drains in ~1/5 the
round-trips, eliminating the 5x roundtrip-amplification users observed
on initial load.

Type-check clean, vitest 249 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:15:37 +02:00
Oliver Kieselbach 70382e991b feat(backend,web,mcp,shared): MS-Graph nextLink pagination rollout
Lands the planned 6-PR pagination rollout (project_mcp_pagination_rollout)
as one tested batch covering session_events, audit+ops, session_reports,
search_sessions/list, and query_raw_events. Backend builds clean; Backend
1024 / Agent V2 1007 / Web 249 tests green.

Primitives (Shared/Pagination):
- ContinuationToken with fingerprint binding (tenantId, dateFrom/To, filters)
  so a forged or cross-tenant token fails validation on the next page.
- PaginatedResult<T> + RawPage<TEntity> as the canonical wire shapes.

Backend (AutopilotMonitor.Functions/Pagination):
- AzureTablesPaginator drives single-partition + per-partition pages.
- DateWindowPagination sweeps day-buckets for global audit/ops queries
  (the backend resolves "last 30d" once and echoes dateFrom/dateTo on
  nextLink so follow-up calls don't recompute "now" and break the token).
- PerPartitionFanOutMerge stable-merges N partition cursors for cross-
  tenant session search.
- Per-feature plans (SessionEventsPagination, SearchSessionsPagination,
  SearchSessionsByEventPagination, SessionListPagination,
  SessionReportsPagination, QueryRawEventsPagination).
- Functions adopt nextLink: GetSessionEvents, GetAuditLogs (tenant +
  global), GetOpsEvents, GetSessionReports, SearchSessions,
  SearchSessionsByEvent, GetSessions, GetAllSessions, QueryRawEvents.
- Repository contracts (ISessionRepository, IOpsEventRepository,
  INotificationRepository, IMaintenanceRepository) gain *PageAsync
  variants returning PaginatedResult; legacy GetSessionsAsync becomes
  a drain-all helper used by maintenance/feedback/SLA paths that still
  need bounded full-list semantics. SessionPage DTO removed.
- PlatformMetricsService now drains all date-bounded sessions via
  GetAllSessionsAsync (previous maxResults:500 was a silent truncation).

Web:
- lib/paginationLink.ts + ?? api.ts wrappers expose follow-nextLink in
  hooks (useSessionEvents, useDashboardSessions) and pages (audit,
  fleet-health, session detail, OpsEvents, SessionReports admin).
- Session list uses Pattern B2 (load-more append); audit + ops + reports
  use Pattern B1 (click-next replace); session detail timeline keeps
  Pattern A (eager-fetch full set).

MCP:
- followNextLink helper accepts either an opaque continuation OR the
  whole "/api/?" path the backend emitted, and follows the latter
  verbatim so server-resolved query params (tenantId fingerprint,
  resolved dateFrom/dateTo, page size) round-trip correctly. Without
  this, get_audit_logs / get_ops_events / list_session_reports / sessions
  follow-ups would silently fail token validation.
- get_audit_logs, get_ops_events, list_session_reports, and the session
  tools migrate to pageSize + continuation/nextLink, with tool docs
  spelling out the round-trip rule so external MCP clients pass the
  full nextLink instead of stripping it down to "continuation".

Tests:
- New: ContinuationTokenTests, DateWindowPaginationTests,
  PerPartitionFanOutMergeTests, AuditLogRowKeyOrderingTests,
  SessionEventsPaginationTests, SessionListPaginationTests,
  SearchSessionsPaginationTests, SearchSessionsByEventPaginationTests,
  SessionReportsPaginationTests, QueryRawEventsPaginationTests, and
  TestSetup helpers.
- Web: paginationLink.test.ts + sessionListLoadMore.test.ts.
- PlatformMetricsServiceWindowTests adjusted for the drain-all change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:53:46 +02:00
Oliver Kieselbach 9a511bac29 fix(web): exempt static assets and well-known files from middleware
The previous matcher ran middleware on /.well-known/microsoft-identity-association.json,
BingSiteAuth.xml and other public/ assets at the root, which on www would
have 302-redirected them to portal — breaking publisher-domain
verification fetches and host-bound webmaster files.

Excluding any path containing a dot is the standard Next.js pattern: app
routes and route handlers have no extension, so they still flow through
middleware; static assets and well-known files are served directly by
the host they're requested on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:44:17 +02:00
Oliver Kieselbach 576ea02b92 feat(web,backend): public/portal split via host-based middleware
Adds host-based routing middleware so www.autopilotmonitor.com keeps the
public/marketing surface (SEO-indexed) and portal.autopilotmonitor.com
hosts the authenticated app. Apex is handled by Strato HTTP forwarder
(301 to www, path-preserved); the apex branch in middleware is
defense-in-depth for direct DNS hits.

Sign-In on the public host now hands off cross-origin to portal so MSAL
fires on the right origin and the token lands in portal's sessionStorage
directly. This avoids a second login round-trip after the post-auth
redirect, which would not be silent given prompt:"select_account".
ProtectedRoute now triggers MSAL login itself for never-authed users so
the cross-origin nav lands cleanly on /dashboard.

Backend deeplinks (session notifications, Teams alert) now point at
portal. Bootstrap URL switches to the apex shortform
(autopilotmonitor.com/go/{code}) since Strato 301-redirects to www
preserving the path. PowerShell bootstrap output points users at portal
for progress.

SEO metadata, sitemap, robots, OG and SLA/docs links remain on www.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:37:07 +02:00
Oliver Kieselbach a35726dcf0 fix(web): split WhiteGlove timeline at Part-2 agent_started, not at whiteglove_resumed
The post-reseal agent_started + its agent_version_check are part of the
User Enrollment block in the user's mental model, but the resumed-marker-based
split kept them inside Part 1 because whiteglove_resumed is emitted ~0-2s
after the boot. Walk back from whiteglove_resumed to the agent_started that
sits between Part-1 close and resume, and split there. Falls back to the
resumed marker when the boot event isn't present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:23:23 +02:00
Oliver Kieselbach b86af63055 changed msal to use window.location.origin 2026-05-06 12:36:59 +02:00
Oliver Kieselbach b4e98e32eb fix(agent): add MS-Organization-Access cert fallback to TenantIdResolver
Adds EntraDeviceCertHelper that reads the AAD-Join device certificate's
TenantId extension (OID 1.2.840.113556.1.5.284.5) from LocalMachine\My.
Wired as the 5th and final synchronous probe in TenantIdResolver, after
the four registry probes:

  1. enrollments_registry         (Type=6, Intune MDM)
  2. enrollments_registry_fallback (any Type, AADTenantID present)
  3. cloud_domain_join_tenant_info
  4. cloud_domain_join_join_info
  5. ms_organization_access_cert  <- new

Targets the Autopilot pre-provisioning / hybrid window where the cert
can land before the CloudDomainJoin registry writes complete. Lets the
agent boot without falling through to the registry-watcher awaiter and
burning the 600s wait window.

Helper hardening over the original draft:
- Filters to currently-valid certs (NotBefore <= now <= NotAfter) and
  picks latest NotAfter when multiple match, defending against an
  expired sibling from a prior re-enrollment.
- If the highest-priority cert lacks the OID extension, walks through
  the remaining candidates instead of bailing on the first miss.
- Logger emits Info on hit (thumbprint + NotAfter) and Warning on miss
  with the specific reason (no cert / extension missing / parse failed).

Mirrored across V1 (Agent.Core) and V2 (Agent.V2.Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:19:02 +02:00
Oliver Kieselbach b67d68c552 fix(agent): TenantIdResolver fallback for non-Type-6 enrollments + UPN redaction
Two-pass scan of HKLM\SOFTWARE\Microsoft\Enrollments: pass 1 keeps the
existing Type=6 (Intune MDM) preference, pass 2 falls back to any
non-Type-6 sub-key carrying a non-empty AADTenantID. Targets the
Autopilot pre-provisioning / hybrid window where Type=6 sits at
EnrollmentState=1 with AADTenantID still missing while a Type=26
AAD-Join entry already carries the TenantID. Observed at customer
where the agent burned ~30min waiting for CloudDomainJoin\TenantInfo
instead of resolving immediately from Type=26.

Captures the enrollment UPN and classifies it. Known Autopilot
pre-provisioning placeholders (fooUser@... Intune-MDM leg,
autopilot@... Entra-Join leg, both generated by the Windows
MakeFakeUserEmail code path) are logged in full with annotation;
anything else is treated as a real user UPN and redacted to
<redacted_real_user_upn> since UPNs are PII.

Mirrored across V1 (Agent.Core) and V2 (Agent.V2.Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:13:48 +02:00
Oliver Kieselbach 6935ca8e00 fix(backend,web): expose member-readable tenant flags via feature-flags endpoint
Three frontend hooks fetched /api/config/{tenantId} from non-admin contexts
(dashboard banner, session detail UI flags, gather-rules validation indicator)
and silently 403'd for Operator and Viewer because the endpoint is gated
TenantAdminOrGA — same anti-pattern as the tenant notifications bell.

The full /api/config/{tenantId} response cannot be relaxed because it carries
admin-only data (SAS URLs, webhook URLs). Instead, the already-MemberRead
/api/config/{tenantId}/feature-flags endpoint is extended with the five UI
display flags those hooks actually need:

- validateAutopilotDevice (Autopilot Device Validation banner)
- showScriptOutput (session PowerShell output visibility)
- enableSoftwareInventoryAnalyzer (session analyzer flag)
- enableIntegrityBypassAnalyzer (session analyzer flag)
- unrestrictedMode (gather-rules validation indicator)

Nullable model fields are flattened to their agent-side defaults so the UI
does not need a second nullable layer. The projection lives in a static
BuildPayload helper for unit-testability.

Frontend hooks switched from api.config.tenant() to api.config.featureFlags();
gather-rules GA-override path keeps using globalConfig.tenant (GA-only).

Tests: 4 new xUnit cases including a sentinel that fails if SAS URLs or
webhook URLs ever leak into the payload. Backend 886/886 green, web 234/234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:18:47 +02:00
Oliver Kieselbach 9b0a2dca95 fix(backend,web): role-gated tenant notifications + audience visibility
Tenant-bell endpoint silently 403'd every 60s for Operator/Viewer because the
GET was gated TenantAdminOrGA but the navbar shows the bell to every member.
Three real bugs were tangled in this:

- SLA breach + consecutive-failures notifications were emitted into the global
  channel even though they are per-tenant data. Only Global Admins ever saw
  them; the actual tenant got no in-app indication.
- Frontend polled /api/notifications for any authenticated user regardless of
  role and silently swallowed the 403 — masking the underlying mis-gate.
- Hardware-rejection notifications would have leaked admin-only context (link
  to whitelist editor) once the policy relaxed.

Fixes:

- New TenantNotificationAudienceCatalog (Type to Member/Admin map) drives
  per-type visibility server-side. Unknown types fail closed to Admin so a
  newly added type does not leak to non-admin members until registered.
- GET /api/notifications relaxed from TenantAdminOrGA to MemberRead. Dismiss
  endpoints stay TenantAdminOrGA (tenant-shared dismiss; per-user dismiss
  deferred with TODO + memory note).
- SLA breach + consecutive-failures emitters moved to TenantNotificationService.
  GlobalNotificationService injection removed from SlaBreachEvaluationService
  entirely (we do not use it anymore, so it is gone).
- Frontend polls only when user has a tenant role or is Global Admin; dismiss
  buttons hidden for Operator/Viewer.

Tests: 6 new xUnit, 7 new vitest. Backend 873/873 green, web 234/234 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:35:43 +02:00
Oliver Kieselbach 5ef6c8b1d2 fix(mcp): MCP server security & capability audit (Phases A-E)
Critical: GET /api/metrics/mcp-usage/user/{userId} (TenantAdminOrGA, no
TenantScoping) handed the oid to the repo without checking that returned
records belong to the caller's tenant. A tenant admin from tenant A could
read MCP usage records of any oid — including oids in tenant B.

McpUsageMetricsFunction.GetUserUsage now blocks 403 CrossTenantAccessDenied
when records are tenant-foreign and caller is not a Global Admin. The pure
decision logic lives in UsageCrossTenantGuard for unit-testability (9 tests
covering own/foreign/mixed/GA/empty/null/case-insensitive/empty-tid paths).

Also:
- MCP get_api_usage tool was calling /api/metrics/mcp-usage/{userId}
  (without /user/), which is unregistered and returned 404 — corrected.
- list_blocked_devices description honestly states the endpoint is
  GA-only on both variants (catalog policy, no change to the gate).
- days clamp [1, 365] added to /metrics/{app, geographic, geographic/sessions}
  for parity with /metrics/summary, /metrics/usage, /metrics/platform.
- get_geographic_metrics, get_geographic_sessions zod schemas now constrain
  days to int().min(1).max(365) — same pattern as the other tools.
- search/sessions, search/sessions-by-event, search/sessions-by-cve switched
  limit handling from Math.Min(lim, 100) to Math.Clamp(lim, 1, 100); the old
  form let a negative ?limit=-5 reach the repo.

Audit static analysis confirmed all other tenant-scoped endpoints route
tenantId through TenantHelper.GetTenantId(req) (JWT-only, ignores query),
so ?tenantId=<other> is silently dropped. Live-verified with an Operator
token: 14 cross-tenant query probes returned only the caller's tenant,
13 GlobalAdminOnly routes returned 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:12:54 +02:00
Oliver Kieselbach feba2a2551 fix(backend): materialize CurrentPhase=Failed on terminal failed status
V2 emits enrollment_failed with Phase=Unknown (per feedback_phase_strategy),
so currentPhase.HasValue was false in UpdateSessionStatusAsync — leaving
Sessions.CurrentPhase at -1 on Failed sessions even though decisionState
already knew the last observed phase. UI PhaseTimeline could not render a
Failed marker. Mirror the existing Succeeded => Complete override in both
the normal and the post-ETag-exhaustion force-write path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:42:20 +02:00
Oliver Kieselbach 0687612a96 fix(v2): preserve source timestamps + boot-anchor deadline math
Replay-safety fix for two related defects that surface when the agent
reads accumulated CMTrace / Shell-Core log content on first boot
(per-enrollment lifecycle, fresh install).

PR1 — IME adapter (SignalAdapters/ImeLogTrackerAdapter.cs)
  Every emit site (EspPhaseChanged, AppState, UserSessionCompleted,
  WhiteGloveSealing, ImeAgentVersion, DoTelemetry, ScriptCompleted)
  now stamps DecisionSignal.OccurredAtUtc and the InformationalEvent
  with the CMTrace log entry timestamp the tracker captures into
  LastMatchedLogTimestamp. Falls back to clock when no source time is
  available; tags the event with derivedTimestamp=true on fallback.
  Pathological clamp: source TS older than 24h or in the future >1h
  is rejected, original preserved as rejectedSourceTimestamp.

PR2 — Shell-Core backfill + EspAndHelloTrackerAdapter
  ShellCoreTracker.HandleBackfillRecord now propagates record.TimeCreated
  via a new LastEventOccurredAtUtc property on the tracker; the
  EspAndHelloTracker coordinator mirrors this property in its event
  forwarders. EspAndHelloTrackerAdapter reads the coordinator value at
  every Post site (HelloResolved, FinalizingSetup, WhiteGloveSuccess,
  EspTerminalFailure, DeviceSetupComplete, HelloPolicyDetected) so
  backfilled events carry source time, not "agent woke up just now".

PR3 — DecisionState.AgentBootUtc + boot-anchor guard
  Without this guard, PR1+PR2 are a footgun: replayed signals carry
  historical timestamps, and the reducer sites that arm deadlines as
  signal.OccurredAtUtc + window would produce due-times in the past,
  collapsing the Hello-safety window, device-only ESP detection,
  finalizing grace, and classifier-tick to immediate fires at boot.

  Added DecisionState.AgentBootUtc + DecisionEngine.EffectiveDeadlineBase
  helper that floors the arming base at the boot anchor. Applied at all
  six reducer sites (HelloSafety, DeviceOnlyEspDetection, FinalizingGrace,
  ClassifierTick @ SessionStarted + AttachWG + re-arm).

  EnrollmentOrchestrator.Start() stamps AgentBootUtc from the live IClock
  on initial state and re-stamps it on snapshot rehydration so the prior
  run's anchor doesn't bleed into deadline math after restart. Backward
  compat: legacy snapshots without the field deserialize with null and
  fall back to signal.OccurredAtUtc — pre-fix behavior preserved.

Tests: 287/287 DecisionCore + 1007/1007 V2.Core + 862/862 Functions +
530/530 Agent.Core. New: BootAnchorDeadlineFloorTests (5) +
ImeLogTrackerAdapterSourceTimestampTests (6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:24:24 +02:00
Oliver Kieselbach 84610f9fb0 refactor(v2): unify signal census between final-status.json and audit trail
The on-disk final-status.json and the on-the-wire enrollment_complete audit
trail both project the same DecisionState into a "which signals were observed"
view, but did so via two independent code paths that drifted: AAD device-only
joins were dropped entirely from final-status.json, ShellCore + IME pattern
strings differed in spelling, and account_setup_entered was missing client-side.

Extract the census into a shared DecisionStateSignalCensus helper in DecisionCore
so adding a new milestone signal lights it up in both surfaces automatically.
Final-status.json gains a top-level imeMatchedPatternId mirror so the IME pattern
id survives the format switch from "ime_pattern:{id}" to "ime_pattern_matched".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:57:50 +02:00
Oliver Kieselbach 41e47191f3 test(v2): lock .part1-<utc> archive bucket inclusion in diag package
The recursive AgentState walk in DiagnosticsPackageService already captures
StateArchiver's `.part1-<utc>/` buckets on WG Part-2 boots, but no test
asserts it. A future refactor that flips includeSubfolders to false or
trims StateFilePatterns would silently drop the Part-1 forensic snapshot
from the diagnostics ZIP.

Adds a lock-test that creates the four bucket files (snapshot/signal-log/
journal/reason) and asserts they round-trip under AgentState/.part1-…/.
Also extends the Section 4 comment to name `.part1-<utc>/` alongside
`.quarantine` as the second intended subfolder consumer of the recursion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:13:23 +02:00
Oliver Kieselbach d16b60f14c feat(v2): WG Part-2 architectural cleanup + install-mode handoff fix
Bundled change covering three orthogonal pieces of work that all landed in
the same working tree before the next PR-style commit could be split out.

## WG Part-2 architectural cleanup (PR-A through PR-E)

Plan: ~/.claude/plans/wg-part2-architectural-cleanup.md.

The V2 agent carried a full separate "Part-2 apparatus" (4 special signal
kinds, 2 special stages, a special outcome, a special classifier, a 24h
safety deadline, a dedicated reducer file, adapter `part2Mode` switches,
a SessionRecovered bridge). This did not match OS reality: post-reseal
Part 2 is just a normal user-driven OOBE running through the Classic
flow. The apparatus also carried a real risk (24h false-positive
enrollment_failed mid-success) and was not even active in practice
(observed in session bdb3cf9d-541b-4c44-aebe-5b9e72743b0a, 2026-05-04).

V2 is now V1-symmetric:

  PR-A — Orchestrator Resume Mechanic. EnrollmentOrchestrator detects a
  persisted Stage=WhiteGloveSealed snapshot at Start(), archives the
  reducer-state segments to state/.part1-<utc-ts>/, runs as a fresh
  first-boot through the normal recovery branches, and posts a single
  whiteglove_resumed InformationalEvent after the onIngressReady hook.
  New helper class StateArchiver.cs + StateArchiverTests +
  EnrollmentOrchestratorPart2EmissionTests.

  PR-B — DecisionCore Cleanup. Removed:
    - DecisionSignalKind.UserAadSignInComplete / HelloResolvedPart2 /
      DesktopArrivedPart2 / AccountSetupCompletedPart2 / SessionRecovered
    - SessionStage.WhiteGloveAwaitingUserSignIn / WhiteGloveCompletedPart2
    - SessionOutcome.WhiteGlovePart2Complete
    - DeadlineNames.WhiteGlovePart2Safety
    - DecisionState's 4 Part-2 SignalFacts (+ builder + snapshot slots)
    - WhiteGlovePart2CompletionClassifier + WhiteGlovePart2CompletionSnapshot
    - DecisionEngine.WhiteGlovePart2.cs (entire reducer file)
    - HandleSessionRecoveredV1 dispatcher + handler

  PR-C — Adapter Cleanup. Dropped the inert `part2Mode` ctor parameter
  from AadJoinWatcherAdapter, DesktopArrivalDetectorAdapter,
  HelloTrackerAdapter, EspAndHelloTrackerAdapter (and their tests).
  AadJoinHost is the only production caller using a named-arg form,
  unaffected.

  PR-D — Backend + Web. Removed `WhiteGloveCompletedPart2` cases from
  TelemetryPayloadParser.IsTerminalStage and DecisionGraphBuilder
  (IsTerminalStage + DeriveTerminalOutcome) plus their tests. Dropped
  the dead `UserAadSignInComplete` constant from Shared.Constants.
  Extracted pure helper computeWhiteGloveSplitSequence(events) in
  app/sessions/[sessionId]/utils/eventHelpers.ts; useSessionDerivedData +
  utils/sessionExportUtils now share it. New 6-case vitest covering
  splitter behaviour (no marker, primary trigger via whiteglove_resumed,
  agent_started fallback, Pending-only variants, primary-vs-fallback
  race). PhaseTimeline + eventTypes were already on the new model.

  PR-E — Tests + Fixtures Final Cleanup. Deleted the two
  whiteglove-part2-*.jsonl fixtures + EdgeScenarioTests references;
  removed dead `whiteglove_part2` case + test from SummaryDialog
  OutcomeMapper; trimmed PR-B history breadcrumbs that still embedded
  removed symbol names so the final grep is clean. Updated MEMORY
  entries (project_v2_completion_location, project_wg_part2_simple_model,
  project_wg_signal_validation_pending, project_bdb3cf9d_wg_followups,
  MEMORY.md index).

What stayed: SessionStage.WhiteGloveSealed (Part-1 marker), the
`_isWhiteGlovePart2` Hint-Bool (V1-symmetric, plan §11) wired only into
EnrollmentTerminationHandler / SoftwareInventoryAnalyzer for shutdown-
analyzer phase=2 tagging, the `whiteglove_part1_complete` event-type +
`whiteglove_part1` outcome-string, `PausedForPart2` terminal-outcome
label for the Inspector, the `whiteglove_resumed` event as UI-splitter
trigger.

Final acceptance: grep for the removed symbol set in src/ excluding V1
(Agent.Core/) is empty. V2.Core.Tests 991 / DecisionCore.Tests 282 /
Functions.Tests 862 / SummaryDialog.Tests 44 / Web vitest 227 — all
green. Solution build clean.

## Resume-path correctness fixes (Codex review of the cleanup)

Three findings on the new resume mechanic:

  P1a — AgentRuntimeHost was still skipping LifecycleEmitters
  PostEnrollmentFactsObserved + PostSessionStarted on isWhiteGloveResume.
  After PR-A's archive-and-reset the decision state is empty, so the
  Classic flow needs both anchors (otherwise ScenarioProfile stays at
  default and the Hello+Desktop AND-gate misclassifies). Fixed: post
  unconditionally for facts; SessionStarted is now only gated by an
  AdminAction, not by the resume flag. The orchestrator's
  whiteglove_resumed lands first (smaller sequence) so the Web splitter
  (`splitSequence = resumed.sequence - 1`) still puts SessionStarted
  into the Part-2 timeline block.

  P1b — StateArchiver was archiving event-sequence.json along with the
  reducer state. Part 2 reuses the same SessionId and the backend orders
  events session-wide by (SessionId, Sequence); the Web split also uses
  `splitSequence = resumed.sequence - 1`. Resetting the counter at the
  resume boundary would collide Part-1 and Part-2 sequences and break
  the splitter. event-sequence.json is now intentionally preserved; the
  three reducer-state segments still archive. StateArchiverTests +
  RecoveryPathTests asserting the new contract.

  P2 — V1 reset the local session-age clock on Part-2 resume
  (SessionPersistence.ResetSessionCreatedAt, MonitoringService.cs:530).
  V2 was clearing only the WhiteGlove marker, so a crash/reboot during
  Part-2 AccountSetup would let the next start run the emergency-break
  watchdog against the original Part-1 timestamp and trip too early.
  AgentRuntimeHost now also calls SessionIdPersistence.SaveSessionCreatedAt
  (DateTime.UtcNow) right after ClearWhiteGloveComplete. New
  ProgramGuardsTests test asserting the V1-symmetric resume contract.

## Install-mode handoff hardening

Plan reference (memory): project_v2_install_runtime_handoff.md.
Bootstrap script + Program.InstallMode + InstallModeTaskXmlTests.

  PR1 — Replace `schtasks /Run` with WMI Win32_Process.Create for the
  install-time runtime handoff. schtasks /Run reports SUCCESS as soon
  as the run is queued; the same battery / AV / OOBE-defer paths that
  motivate PR2 also queue a manual /Run (observed WinPE 2026-05-04,
  task stuck in Queued state). WMI also gives the runtime process the
  same isolation Task Scheduler uses, without the Job-Object inheritance
  risk of a direct Process.Start.

  PR2 — Register the BootTrigger Scheduled Task from a hardened XML
  definition rather than `schtasks /Create`. The CLI form inherits XML
  schema defaults DisallowStartIfOnBatteries=true and
  StopIfGoingOnBatteries=true, which queue the BootTrigger run
  indefinitely on a laptop that boots on battery in OOBE / WinPE. The
  XML disables both, sets StartWhenAvailable, and pins
  ExecutionTimeLimit=PT0S so the long-running agent is never killed at
  the schtasks 72h CLI default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:36:37 +02:00
Oliver Kieselbach c0b7b50457 fix(v2): PR-X1 — skip summary dialog + enrollment_summary_shown on WG Part 1
WhiteGlove Part 1 sealing has no end-user session and no completed app
installs, so launching the summary dialog there either lands invisibly
in SYSTEM context (about to be reseal-rebooted) or fails outright; the
enrollment_summary_shown event was semantically wrong and polluting the
timeline. Gate RunBuildAndLaunchDialog (and the state==null warn-only
branch) behind !isWhiteGlovePart1; Completed / Failed /
WhiteGloveCompletedPart2 paths unchanged.

Plan reference: bdb3cf9d-wg-part1-part2-followups.md PR-X1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:25:35 +02:00
Oliver Kieselbach ce97a583d0 fix(v2): propagate AadJoinedWithUser + rename aad_user_joined_late→_observed
AadJoinWatcherAdapter never wrote SignalPayloadKeys.AadJoinedWithUser into the
decision-signal payload, so HandleAadUserJoinedLateV1 always read withUser=false.
Every Classic AAD-Join session ended up with aad_user_joined_device_only in the
audit trail and a wrongly-flipped WhiteGloveSealingClassifier weight. The watcher
only fires AadUserJoined for non-placeholder accounts (placeholders go via the
separate PlaceholderUserDetected event), so the flag is unconditionally true at
emit time.

Cosmetic: rename the wire EventType from aad_user_joined_late to
aad_user_joined_observed. The "late" qualifier reflected the agent's observation
perspective ("I missed the live event"), which holds for 100% of Classic sessions
and reads as misleading in the timeline (the event lands seconds after agent
start, before aad_join_status). FailureSnapshotBuilder accepts both strings — V1
agents and historical data still carry the legacy name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:37:39 +02:00
Oliver Kieselbach 11838e1719 feat(web): PR4 — Lifecycle Anchors tab in Inspector
Plan §A + §B (Zero-Periodicity Observability, 2026-05-03). Render the per-
anchor DecisionState snapshots that PR2 wrote into Event.Data, with the
Death-Rattle dual-snapshot special case that PR3 produces.

- New "Lifecycle Anchors" tab in /sessions/{id}/inspector — one row per
  event carrying data.decisionState, oldest-first, filterable by event
  type. Each row expands into a DecisionStateCard showing stage /
  stepIndex / lastAppliedSignalOrdinal / scenario block / facts table
  with {value, ordinal} per fact / activeDeadlines / forwards-compat
  "Other fields" bucket.

- Death-Rattle special case: prior_run_died_with_state events render
  TWO cards side-by-side — "Prior run state (at death)" (priorState)
  on top, "Fresh run state (post-recovery)" (decisionState) below —
  plus a metadata strip with previousExitType, lastBootUtc, and the
  optional crashException. The pairing is the load-bearing diagnostic:
  delta between the two reads as the recovery delta.

- FailureSnapshotBlock on the session-detail page now links into the
  Inspector at ?tab=anchors so operators reading a 5h-timeout snapshot
  can jump straight to the anchor stream. SearchParam-driven tab
  initialisation in inspector/page.tsx (single read on mount; clicks
  stay client-state, no history churn).

- Schema-driven anchor filter: an event is an "anchor" iff
  data.decisionState is a non-null object — mirrors what the agent
  emitted exactly even if the C# allowlist evolves between agent
  versions. 13 vitest cases pin the predicate (Death-Rattle present
  and Death-Rattle missing-decisionState; primitive / null / undefined
  decisionState; sequence sort with fail-soft for missing field).

- Inspector remains behind requireGlobalAdmin (M6 gate) — no new
  permission surface.

Test stand: vitest 221 passed (15 files, +13 cases); tsc --noEmit clean.
2026-05-03 21:51:57 +02:00
Oliver Kieselbach 118b4c0a41 test(v2): extract DeathRattlePrelude so PR3 gate is directly testable
Codex review on PR3: the AgentRuntimeHost integration (gate + path build +
pre-Start read) is the fragile part of the plan and was only tested
indirectly through the helper + emitter paths. A regress like
"TryReadRaw moved to after Start", "WhiteGlove resume not skipped" or
"clean exit fires" would have slipped through.

Pull the gate / path / read sequence into a dedicated DeathRattlePrelude
in V2.Core so it can be exercised in isolation:

- DeathRattlePrelude.TryCapture(stateDirectory, previousExitType,
  isWhiteGloveResume, logger) returns the prior snapshot or null;
  IsUncleanExit exposed for callers that want to log the decision.
- AgentRuntimeHost reduced to a single TryCapture call; private
  IsUncleanExit + System.IO using removed.
- DeathRattlePreludeTests (24 cases) cover:
    - Clean-exit table (clean / first_run / unknown / null) — all skip,
      even with snapshot present.
    - Unclean-exit table (reboot_kill / hard_kill / exception_crash,
      mixed casing) — all read.
    - WhiteGlove Part-2 resume gate wins over unclean exit.
    - Snapshot missing / corrupt → null, never throws.
    - Path-layout pin: prelude reads the same hardcoded "snapshot.json"
      filename the orchestrator writes to.
    - Non-mutation contract: byte-level equality before/after, file
      still loadable via SnapshotPersistence.Load(), no .quarantine
      directory created (orchestrator owns the quarantine decision).
    - Argument validation (null logger / null+empty stateDirectory).

Test stand: V2.Core 983 (+24).
2026-05-03 21:40:10 +02:00
Oliver Kieselbach 6e240fba53 feat(v2): PR3 — Death-Rattle (prior_run_died_with_state)
Plan §B (Zero-Periodicity Observability, 2026-05-03). On the next agent run
after an unclean exit (reboot_kill / hard_kill / exception_crash), read the
prior run's last persisted snapshot BEFORE orchestrator.Start() and emit a
single prior_run_died_with_state event in the start-callback so post-mortem
diagnosis sees what the dying run last knew — without relying on client-side
log uploads.

Coupling with PR2 (Plan §A): the event itself is on the lifecycle-anchor
allowlist, so EventTimelineEmitter automatically attaches the FRESH run's
data["decisionState"] alongside the data["priorState"] we set here. Both
views (persisted-at-death vs post-recovery reconstructed engine state)
land in one wire payload.

Skip conditions: WhiteGlove Part-2 resume (planned exit, not a death),
clean / first_run exits, snapshot missing or corrupt (TryReadRaw returns
null on every failure mode, agent keeps running).

Changes:
- SnapshotPersistence: extracted TryReadInternal as shared parser; added
  public static TryReadRaw(path) — quarantine-free static reader the agent
  can call before Start without disturbing the orchestrator's recovery
  pipeline. Failure-equivalence with Load() pinned by reader-parity test.
- LifecycleEmitters.PostPriorRunDiedWithState: builds Warning + immediate-
  upload event with previousExitType (defensive null→"unknown" fallback),
  lastBootUtc, optional crashExceptionType, and priorState (Plan §A
  snapshot dict). Swallows ingress exceptions to keep the agent_started
  → version_check chain robust.
- AgentRuntimeHost: pre-Start TryReadRaw block + IsUncleanExit gate +
  in-callback PostPriorRunDiedWithState immediately after the existing
  agent_started / version_check / unrestricted-mode anchors.
- Tests: 9 SnapshotPersistence (TryReadRaw + reader-parity + no-quarantine
  contract); 8 LifecycleEmittersPriorRunDied (event shape, wire policy,
  priorState schema, crash-exception inclusion, null-ExitType fallback,
  message contents, ingress-exception swallow).

Test stand: V2.Core 959 (+17), DecisionCore 293, Backend 862, V1 530.
2026-05-03 21:06:39 +02:00
Oliver Kieselbach 7fe0e9ef58 feat(v2): PR2 — wire DecisionState snapshot into EventTimelineEmitter
Plan §A enrichment (Edge-Triggered State Snapshots, 2026-05-03). For
events on the lifecycle-anchor allowlist, EventTimelineEmitter.Emit
now attaches the current DecisionState snapshot under data["decisionState"]
after ResolveData(). Existing Data fields are always preserved — both
the parameter-rebuilt path and the typedPayload-merged path mutate a
fresh dict, so adding the new key is a strict merge, not a replace.

Behavior:
- 13 anchor event types get decisionState (full allowlist from PR1's
  LifecycleAnchorEventTypes).
- Non-anchor events (enrollment_complete/_failed, app_install_*,
  performance_snapshot, download_progress) are unchanged.
- Idempotent: an existing data["decisionState"] (defensive — should
  never occur in practice since only the emitter writes that key) is
  not clobbered.

Why Emitter, not Reducer (per Codex rev2 review):
- EffectRunner.cs:114 already passes stateAfterReduce to the emitter,
  so no new dependency is needed.
- Reducer-side enrichment via typedPayload would have replaced existing
  Data via the typedPayload-as-dict branch in ResolveData — wiping
  espPhase, patternId, network state etc. The emitter-seam is the only
  place that runs after ResolveData and sees a dict ready to merge into.

Tests (V2.Core +22 tests, suite 920 → 942):
- All 13 anchor event types via Theory: each gets decisionState with
  schemaVersion matching the builder's const.
- Parameter-based path: esp_phase_changed preserves reason+espPhase.
- typedPayload path: network_state_change preserves from/to/transition.
- Idempotence: prebuilt sentinel decisionState in typedPayload survives.
- Non-anchor exclusion: 5 representative non-anchors confirmed null.
- Top-level allowlist sanity-check (delegates full coverage to PR1's
  DecisionStateSnapshotBuilderTests).

Tests (regression-clean across all suites):
- V2.Core: 920 → 942 (+22)
- DecisionCore: 293 (no change)
- Backend Functions: 862 (no change)
- Existing EventTimelineEmitterTests: 26/26 still pass.

Plan: .claude/plans/v2-zero-periodicity-observability.md (rev7).
PR3 (Death-Rattle / prior_run_died_with_state) is next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:41:06 +02:00
Oliver Kieselbach a98f9d5725 feat(v2): PR1 — DecisionStateSnapshotBuilder + lifecycle-anchor allowlist
Foundation for Plan A (Edge-Triggered State Snapshots) and Plan B
(Death-Rattle / prior-run attestation). Add-only — no behavioral change,
no events emitted yet. PR2 wires the emitter, PR3 adds death-rattle.

Constants (Shared.EventTypes — 5 new):
- AgentStarted, SystemRebootDetected, PerformanceCollectorStopped,
  AgentMetricsCollectorStopped, PriorRunDiedWithState
- String-literals at V2 emit-sites refactored to use the constants:
  LifecycleEmitters, PeriodicCollectorLifecycleHost, DecisionEngine.Edge

DecisionCore (new):
- DecisionStateSnapshotBuilder: public const string SchemaVersion
  "decision-state-snapshot-v1"; Build(state) returns
  Dictionary<string, object?> with explicit top-level allowlist
  (schemaVersion, stepIndex, lastAppliedSignalOrdinal, stage, outcome,
  facts, scenario, activeDeadlines). All 15 SignalFact<T> properties
  serialized as { value, ordinal } for evidence trace; null for unset.
  Excluded: SessionId/TenantId (PII), AppInstallFacts /
  ScenarioObservations / ClassifierOutcomes (too large).
- LifecycleAnchorEventTypes: HashSet of 13 anchor event types behind
  Contains() wrapper. netstandard2.0-compatible (no IReadOnlySet).

Tests (DecisionCore +41 tests):
- SchemaVersion-Const-Check (against const, not literal)
- Top-level allowlist + excluded-fields enforcement
- Per-SignalFact value+ordinal preservation (DateTime, string, bool, enum)
- Reflection-coverage scoped to SignalFact<T> properties with camelCase
  key pinning — catches future SignalFact additions automatically
- Newtonsoft JSON round-trip (null facts emit as JSON null)
- Allowlist membership (13 anchors), non-anchor exclusion (6),
  null/empty/unknown handling, anchor-count assertion

Tests: DecisionCore 252 → 293 (+41), V2.Core 920, Backend 862,
V1 Agent.Core 530 (regression-clean).

Plan: .claude/plans/v2-zero-periodicity-observability.md (rev7,
PR-FREIGEGEBEN nach 7 Codex-Review-Pässen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:35:26 +02:00
Oliver Kieselbach fb31a2d6d1 feat(v2): close Hybrid User-Driven completion gaps
Session e58bcfdb stuck 5h on Hybrid User-Driven enrollment because
the foouser OOBE shell triggered desktop_arrived prematurely and no
Hello/AAD-real-user signals ever followed.

Agent (V2):
- DesktopArrivalDetector excludes foouser/autopilot placeholders
  (UPN prefix + exact bare username; autopilotadmin etc. stay through)
- AadJoinWatcherAdapter dual-emits placeholder + real-user join as
  informational events for Events-table consumers
- DesktopArrivalDetector resets on placeholder->real-user transition
  via AadJoinHost.onRealUserJoined wiring
- New single-shot HybridLoginPendingDetector (10m, gated on
  reboot_kill + isHybridJoin, cancels on real-user join)

Backend:
- New FailureSnapshotBuilder writes per-session diagnostic snapshot
  on 5h-timeout (last ESP phase, desktop arrival, Hello policy,
  AAD-join state, missing canonical signals); aad_user_join slot
  satisfied by either Part 1 or Part 2 join event
- Sessions.FailureSnapshotJson column with Store/Map round-trip
- UpdateSessionStatusAsync extended with optional snapshot param

Web:
- New collapsible Failure Snapshot block in SessionInfoCard

Tests: V2.Core 920, Backend 862, DecisionCore 252, V1.Core 530
(regression-clean), Web vitest 208.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:56:10 +02:00
Oliver Kieselbach ac3b3d82db feat(web): visualize SkipUserStatusPage as skipped phases
Account Setup (4) and Apps (User) (5) now render with a dashed gray
circle and "Skipped" label when SkipUserStatusPage=true and the session
is non-WhiteGlove. Replaces the previously umsortiertes layout
([0,1,2,3,6,5,7]) with the standard order plus a skippedPhaseIds set
so the progress bar reflects the ESP policy directly. Filters skipped
phases out of maxEventPhase and falls back when Backend.CurrentPhase
itself points at a skipped phase, keeping the active marker on the
real activity (e.g. AppsDevice) instead of getting dragged into a
phase the policy disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:18:18 +02:00
Oliver Kieselbach 4a996070b8 feat(noise): add ModernDeployment EventID 1010 to harmless default list
EventID 1010 (Autopilot.dll WIL hardwareinfo.cpp HRESULT 0x80070002 /
file-not-found probe) is a known noise event with no enrollment impact.
Downgraded to Debug across V1+V2 trackers, shared default list, admin
config fallback, and web UI defaults. ConfigVersion bumped 26 -> 27.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:54:30 +02:00
Oliver Kieselbach b86c294771 fix(agent): merge bootstrap-config.json on --install instead of clobbering
Three follow-ups from the TenantIdAwaiter PR review:

* HIGH: A redeploy that passed only --tenant-id-wait (PS1's default
  invocation today) was overwriting bootstrap-config.json with
  BootstrapToken=null / TenantId=null, pushing devices to cert-auth
  before their MDM cert was usable. The install path now reads the
  existing file, merges only fields explicitly given on this
  invocation, and writes the merged result back. Field-given booleans
  drive the merge so that prior values survive an absent CLI flag.

* MEDIUM: --tenant-id-wait 0 is now a real opt-out. Previously the
  persist block was gated on tenantIdWaitSeconds > 0, so an admin who
  flipped the PS1 default to 0 and redeployed kept the old non-zero
  wait persisted. The gate is now "any flag explicitly set" and the
  merge writes 0 over the prior value when 0 is what was given.

* LOW: --tenant-id-wait <SEC> is now in --help under a new
  "TenantId resolution" section so the start-reliability switch is
  discoverable.

The merge logic is extracted as Program.MergeBootstrapConfig — a pure
function — and covered by 7 InstallModeMergeTests cases (wait-only
preserve, explicit-zero override, tenant-only preserve, token-only
preserve, first-install path, PS1 default invocation, legacy
existing-config without the new field).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:15:29 +02:00
Oliver Kieselbach 430d83453e test(agent): serialise V2.Core RecoveryPath / EnrollmentOrchestrator / CollectorBaseReentrancy
These three classes intermittently failed under the parallel xUnit pool
on a busy box (snapshot/journal IO + Task coordination + Interlocked-gate
SpinUntil all timing-sensitive against ThreadPool contention from
unrelated tests). Confirmed pattern, not real regressions: each class
ran green isolated via --filter.

Now decorated with [Collection("SerialThreading")] — same collection as
DeadlineScheduler, SignalIngress, EnrollmentOrchestratorMaxLifetime,
etc. Verified 5 consecutive full-suite runs green (855/855 each, ~28s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:05:04 +02:00
Oliver Kieselbach 3016c15919 feat(agent): event-driven TenantId wait via RegistryWatcher
Bridges the OOBE / hybrid-AAD-join race window where the agent fires
before AADTenantID / CloudDomainJoin\TenantInfo are written to the
registry. Field evidence (GardnerMedia, 2026-04-30): the agent fired
~5 minutes before the AAD device cert landed; an event-driven wait of
10 minutes recovers the run instead of bailing and waiting for next
reboot.

Mechanism (V2 only — V1 keeps the fast-fail path):
* New TenantIdAwaiter.WaitForTenantId(timeoutSec, logger, ct) sets up
  a CompositeRegistryChangeSignal over HKLM\SOFTWARE\Microsoft\Enrollments
  and HKLM\SYSTEM\...\CloudDomainJoin (Registry64 view, recursive,
  Name+LastSet+ThreadAgnostic filter), re-probes silently on every
  change, and returns the resolved TenantId or null on timeout.
* 30 s belt-and-suspenders periodic re-probe protects against a missed
  RegNotifyChangeKeyValue notification.
* 100 ms debounce after each signal so multi-value enrollment
  transactions land before the re-probe.

Configuration:
* Opt-in via --tenant-id-wait <sec>. Default 0 = no wait (legacy
  fast-fail). The PS1 bootstrap script owns the production default;
  the agent itself never assumes one.
* CLI value persisted to bootstrap-config.json by --install so the
  Scheduled Task picks it up on each restart.
* Install-AutopilotMonitor-v2.ps1 sets $tenantIdWaitSec = 600. Admins
  override per tenant by editing this single line and re-deploying.

Testability:
* IRegistryChangeSignal abstraction lets unit tests drive the loop
  with a fake signal and scripted probe — 8 deterministic cases cover
  timeout, signal hit, multi-signal, cancellation, periodic re-probe,
  whitespace/empty/null handling, zero-timeout short-circuit, and
  argument validation.
* RegistryWatcherIntegrationTests exercise the real Win32 plumbing
  (RegNotifyChangeKeyValue → Changed event) against HKCU so no
  elevation is required and the test runs in standard CI.
* Both classes opt out of xUnit parallelisation — the timing-sensitive
  Thread.Sleep handshakes are unreliable on a busy parallel pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:54:43 +02:00
Oliver Kieselbach 58be7d19c3 fix(agent): force Registry64 view + trim TenantIdResolver miss log
Registry reads now go through RegistryKey.OpenBaseKey(.., Registry64)
explicitly. SDK-style net48 EXEs can resolve AnyCPU to 32-bit at runtime
depending on MSBuild defaults; in that case
HKLM\SOFTWARE\Microsoft\Enrollments redirects to the WOW6432Node mirror,
a different (and usually stale/empty) hive that never carries the active
MDM enrollment. Forcing Registry64 makes the resolver bitness-independent
regardless of how AnyCPU resolves.

Diagnostic-on-miss is now compact:
* Sub-keys without an EnrollmentType value (Context, Ownership, Status,
  ValidNodePaths) are recognized as structural and skipped silently.
* The detail list contains only Type=6 (Intune MDM) entries with their
  EnrollmentState and AADTenantID presence — that's the only enrollment
  flavor that ever carries the TenantId.
* Non-Type-6 enrollments collapse into a single counter so a heavily
  enrolled device (e.g. dev box with 30+ historical enrollments) no
  longer floods the agent log with one Warning per sub-key.

Mirrored across the V1 + V2 resolver shipped in 99ec2517 / fc950757.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:38:01 +02:00
Oliver Kieselbach 99ec25175d feat(agent): backport V1 TenantIdResolver with CloudDomainJoin fallback
Mirrors the V2 resolver shipped in fc950757 to V1 so the legacy rail
gets the same OOBE-race protection. Probe order: Enrollments registry
→ CloudDomainJoin\TenantInfo → CloudDomainJoin\JoinInfo, source-tagged
log line on hit, full diagnostic block on miss (which roots exist,
which enrollment GUIDs were seen with their EnrollmentType /
EnrollmentState, which JoinInfo thumbprints).

Removes the inline GetTenantIdFromRegistry helper from V1 Program.cs
and routes all three callsites (LoadConfiguration, await-enrollment
re-resolve, pre-flight bail) through the new resolver. The bail site
re-probes with a logger when the await branch was skipped, so the
diagnostic block lands in the agent log on a real miss instead of
just the generic "registry empty" error.

Updates a stale doc comment in V2 Program.GatherRulesMode.cs that
referenced the removed V1 method name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 19:13:59 +02:00
Oliver Kieselbach 5d03c6fbff chore(agent): demote success-path transport + IME re-match logs to Debug
BackendTelemetryUploader's "flushing"/"ingest OK" lines and
ImeLogTracker's repeated "platform script started" + same-phase ESP
re-match lines now live on Debug. Failures stay on Warning/Error so a
default Info log surfaces lifecycle/outcome events instead of pipeline
mechanics. Adds a guard test asserting success is silent at Info.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:59:41 +02:00
Oliver Kieselbach fc95075735 feat(agent): V2 TenantIdResolver falls back to CloudDomainJoin registry
Resolver now probes Enrollments → CloudDomainJoin\TenantInfo →
CloudDomainJoin\JoinInfo and tags the winning source in the log. On a
full miss it dumps a diagnostic block (which roots exist, which
enrollment GUIDs were seen with their EnrollmentType/EnrollmentState,
which JoinInfo thumbprints). Heals the OOBE race where the agent fires
before MDM enrollment is written but AAD join already populated
CloudDomainJoin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:59:33 +02:00
Oliver Kieselbach ba0fb7ae0c feat(agent): V2 WhiteGlove Part 1 graceful-exit hardening
Three coordinated fixes so a WhiteGlove Part-1 reseal-reboot does not race
the agent's termination path and leave the timeline missing
agent_shutting_down / whiteglove_part1_complete events plus a stale
previousExit=reboot_kill on the next run.

- Real spool-empty drain: EnrollmentTerminationHandler.DrainSpool polls
  PendingItemCount + IngressPendingSignalCount instead of blind Task.Delay,
  so it returns the moment the backend actually ack'd the lifecycle events
  (or hits the bounded fallback timeout).

- Early clean-exit.marker write: marker is written before _signalShutdown
  returns control, in addition to the existing AppDomain.ProcessExit
  fallback. Wins the race against admin-triggered shutdown.exe.

- Off-worker termination dispatch: Terminated event from
  OnDecisionTerminalStage now goes through Task.Run so the SignalIngress
  worker is freed to process events the handler itself just posted. Adds
  PendingSignalCount with race-free posted-not-yet-completed semantics
  (Interlocked.Increment in Post BEFORE channel.Add, decrement in
  ProcessItem finally + add-throw paths). Two-phase drain (ingress queue
  -> spool) replaces the previous spool-only wait.

- Fast-path inline classifier on WhiteGloveShellCoreSuccess: emits
  whiteglove_complete in a single reduce step with the full
  DecisionAuditTrailBuilder TypedPayload (decisionSource, trigger,
  signalsSeen, classifier verdict, classifierInputs, scenario), preserving
  byte-stable forensics across fast and slow paths.

Tests:
- DecisionCore: fast-path audit-trail completeness, fast-path single-step
  transition, slow-path fallback when AccountSetup activity excludes WG,
  scenario-replay run/skip counts.
- V2 Core: Termination two-phase drain (immediate / poll-until-zero /
  timeout-fallback / blind-delay-fallback / accessor-throw safe), early
  clean-exit-marker ordering vs signalShutdown, off-worker
  Terminated-dispatch verified by Thread.Name + handler-posted-signal
  drain, SignalIngress PendingSignalCount idle/in-flight/post-stop and
  deterministic race-leak regression via internal test seam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:05:37 +02:00
Oliver Kieselbach 9f3ee5eedb fix(agent): V1 bails cleanly when TenantId missing instead of crashing
Customer gardnermedia (2026-04-30) hit a fatal V1 startup crash:
"InvalidOperationException: Invalid agent configuration" thrown from
MonitoringService..ctor. Root cause: LoadConfiguration returned a config
with TenantId=null because HKLM\SOFTWARE\Microsoft\Enrollments did not
yet carry an EnrollmentType=6\AADTenantID entry and no bootstrap-config
.json was present. Without a guard, control reached MonitoringService
construction and IsValid() threw an unhandled exception → crash log →
"exception_crash" classification on the next start.

V2 already bails cleanly in this case (AgentBootstrap.Run line 123-127).
This commit gives V1 the same safety net plus a more diagnostic message
so any future occurrence is immediately analyzable from the crash log.

Changes:
* Program.cs: pre-flight TenantId guard right after the
  --await-enrollment block. Logs an Error and returns cleanly so the
  ProcessExit handler writes clean-exit.marker — no spurious crash log,
  Scheduled Task simply retries on the next trigger.
* MonitoringService..ctor: replace the opaque "Invalid agent
  configuration" throw with one that names the missing fields
  (ApiBaseUrl / SessionId / TenantId).
* MonitoringServiceConfigValidationTests: 5 new xUnit cases covering
  each missing-field path plus the multi-missing case.
* V1 buildcounter bump.

V2 unchanged — its existing AgentBootstrap guard already covers this
class of crash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:59:29 +02:00
Oliver Kieselbach 4066171f14 feat(backend): structured audit trail on V2 DecisionEngine timeline events
DecisionAuditTrailBuilder now produces a uniform typed-payload
(signalsSeen, signalEvidence, classifier verdict, scenario context)
for reducer-emitted decision events. Six previously shipped with empty
Data on the wire (whiteglove_complete, whiteglove_resumed,
whiteglove_part2_complete, three enrollment_failed flavors); the
existing enrollment_complete trail is consolidated onto the same
builder. V1 path untouched. AdminPreemption left for follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:55:51 +02:00
Oliver Kieselbach dbef6921ae feat(vuln): queue-driven correlation + WhiteGlove Part-1 inventory trigger
Replaces the fire-and-forget Task.Run inside EventIngestProcessor — which
Azure Functions scale-in could kill mid-flight, leaving sessions with no
vulnerability_report and forcing users to click "Analyze Now" — with a
dedicated queue + BackgroundService worker (vulnerability-correlate),
mirroring the analyze-on-enrollment-end pattern. Adds a V2-agent trigger
that emits a Part-1 software-inventory snapshot the moment Windows Event
62407 fires (WhiteGlove sealing success), so Part-1 vuln correlation
happens BEFORE the admin clicks Reseal — closing the gap that existed
because reboot_kill termination at sealing left no shutdown inventory
for the backend to correlate.

Backend
  * IVulnerabilityCorrelateProducer + AzureQueueVulnerabilityCorrelate
    Producer (fail-soft on agent's hot path).
  * VulnerabilityCorrelateQueueWorker (BackgroundService, 5-attempt
    visibility-timeout retry, poison-queue on exhaustion).
  * VulnerabilityCorrelateHandler ports the full pipeline (correlate +
    store + synth event + re-analyze enqueue + SignalR notify).
  * IVulnerabilityInventoryLoader extracted from
    GetVulnerabilityReportFunction so worker + manual rescan share one
    loader; phase-aware filter via whiteGlovePart parameter +
    includeAllPhases escape-hatch for the rescan UI.
  * EventIngestProcessor cutover: drops three concrete-class deps
    (_vulnerabilityCorrelation, _vulnRepo, _signalRNotification) and
    two now-orphaned private methods. 561 → 388 LOC.

Agent V2
  * WhiteGloveInventoryTrigger subscribes to EspAndHelloHost.WhiteGlove
    Completed and calls AgentAnalyzerManager.RunWhiteGlovePart1Inventory
    Snapshot via injected Action (no concrete-class coupling, trivially
    mockable). Single-fire guarded by Interlocked.Exchange.
  * IWhiteGloveCompletedSource interface keeps EspAndHelloHost internal
    sealed while letting the trigger be unit-tested with a 4-line stub.
  * AgentAnalyzerManager.RunWhiteGlovePart1InventorySnapshot runs ONLY
    SoftwareInventoryAnalyzer (LocalAdmin/IntegrityBypass deliberately
    skipped at WG hand-off — still SYSTEM context).
  * EnrollmentTerminationHandler now also tags Part 2 (WhiteGloveCompleted
    Part2 → wgPart=2); was previously untagged so the new phase filter
    couldn't see it.
  * SoftwareInventoryAnalyzer.AnalyzeAtShutdown gets per-WG-phase atomic
    idempotency via Interlocked.CompareExchange, with reset-on-failure
    so the EnrollmentTerminationHandler stays a working fallback if the
    Trigger crashes.

Cross-phase contamination & out-of-order resilience
  * Inventory loader filters by whiteGlovePart so a Part-2 correlation
    cannot see Part-1 inventory items (and vice versa).
  * Handler checks the existing report on BOTH Part-1 and Part-2 paths:
    Part-2 merges with existing Part-1, Part-1 SKIPS its store when the
    existing report already contains user_enrollment / both findings or
    has scan_summary.phase_contributions.user_enrollment > 0 — protects
    against late-arriving Part-1 retries overwriting the richer Part-2
    or merged report after at-least-once redelivery.
  * VulnerabilityCorrelationService.MergeReports gains NormalizeForMerge
    that recursively unwraps Newtonsoft JObject/JArray/JValue to native
    Dictionary/List/primitive shapes BEFORE the cast-based extract
    helpers run. Without this the normal-order Part-2 merge silently
    dropped Part-1 findings, because GetVulnerabilityReportAsync round-
    trips through JsonConvert and the existing helpers' OfType / "as
    Dictionary" casts produced empty results on JTokens.

Tests (+34 new across Backend & Agent V2.Core)
  * VulnerabilityCorrelateEnvelopeSerializationTests — wire-format pin.
  * VulnerabilityInventoryLoaderTests — phase filtering, includeAllPhases,
    lenient long-parsing.
  * VulnerabilityCorrelateHandlerSkipTests — out-of-order skip helper
    against native, JArray, JObject, both phase tag, phase_contributions
    counter.
  * VulnerabilityCorrelationServiceTests — round-trip safety of
    MergeReports against JsonConvert-deserialized existing reports.
  * WhiteGloveInventoryTriggerTests — fire-once, dedup, dispose-then-late,
    idempotent dispose, action-exception isolation, null-arg validation.
  * SoftwareInventoryAnalyzerIdempotencyTests — Part-1 dedup, Part-2
    dedup, Part-1 ≠ Part-2 independence.

Build green, 0 errors, 0 warnings. Backend 844/844 tests pass, Agent
V2.Core 825/825 tests pass (parallel-flaky tests pass isolated, see
feedback_v2_recoverypath_corrupt_snapshot_flake).

V1 ingest path (/api/agent/ingest) deliberately untouched per
project_v1_vuln_pipeline_gap — V1 is being phased out; fixing it
forward would inflate scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:25:28 +02:00
Oliver Kieselbach 627784e877 perf(backend): server-side $filter on tenantAssociatedDevices
Swap DeviceAssociationValidator's Graph query from $top=100 + client-side
scan to $top=25 with $filter=contains(serialNumber,'…'), mirroring the
Intune portal's own tenantAssociatedDevices search shape.

eq is not supported on this endpoint, so contains is used; the exact-match
guard in ParseTenantAssociatedDevicesResponse remains as defense-in-depth
against substring false-positives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:30:21 +02:00
Oliver Kieselbach eee65ba000 fix(metrics): honor days time-window in MCP get_metrics, get_usage_metrics, get_platform_metrics
The three metrics MCP tools previously ignored the time-window: get_metrics never
read the days query param (returned all-time totals), get_usage_metrics didn't
expose days at all and the service was hardcoded to 90, get_platform_metrics
had a fixed 500-most-recent-sessions cap. A "last 7 days" question therefore
returned cross-tenant all-time aggregates (~1300) instead of the actual 7-day
count (~500).

End-to-end fix:
- Plumbing days through MetricsSummaryFunction → IMetricsRepository →
  TableStorageService.GetMetricsSummaryAsync, with a server-side RowKey cutoff
  on SessionsIndex (reuses the inverted-tick D19 prefix pattern; extracted as
  ComputeCutoffRowKeyPrefix and the two pre-existing call sites consolidated).
- UsageMetricsService and PlatformMetricsService now accept int days, with
  per-window in-memory caches and a WindowDays field echoed on the response.
- MCP zod schemas accept any integer 1-365 (replacing the [7,30,90] whitelist),
  so callers can now ask for 5 or 12 days and get the right answer.
- Function-layer defaults stay at 90 for /usage and /platform routes (no
  dashboard regression) and 30 for /summary (Agent API, MCP-only path).

Tests: 22 new xUnit cases across MetricsCutoffRowKeyTests,
UsageMetricsServiceWindowTests, PlatformMetricsServiceWindowTests covering
boundary, clamping, and end-to-end window semantics. Backend 808/808 green,
web vitest 208/208 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:45:55 +02:00
Oliver Kieselbach 4aea916c14 fix(agent): correct V2 HAADJ detection + drop duplicate snapshot ticks
Two V2 bugs surfaced by Bayer session c4c8d206-… (HAADJ on Lenovo T14 Gen 5,
Win11 23H2 22631.4317):

* `IsHybridJoin=false` in the Sessions row though the Autopilot profile is
  `Win_Hybrid physical`. Root cause: `EnrollmentRegistryDetector.DetectHybridJoin`
  reads only the top-level `CloudAssignedDomainJoinMethod` registry value. On
  this device that value is persisted only inside the `PolicyJsonCache` JSON
  blob — `DeviceInfoCollector` already had the JSON fallback, the registration-
  time detector did not. Refactored into a pure `ResolveHybridJoinFromValues`
  helper with the same fallback ladder; full xUnit coverage incl. the real
  payload from the failing session.

* Duplicate `performance_snapshot` events with timestamps 0–1 ms apart, the
  second one carrying a fresh-baseline net delta of 0. Root cause: System
  .Threading.Timer dispatches callbacks on different ThreadPool threads when a
  callback exceeds the period, and after the long stall the OS network re-init
  slowed `NetworkInterface.GetAllNetworkInterfaces()` enough to overlap the
  next 30 s tick → two parallel `Collect()` calls racing on shared baseline
  fields. Added an `Interlocked.CompareExchange` reentrancy gate in
  `CollectorBase.CollectSafe`; protects all three subclasses (Performance,
  AgentSelfMetrics, DeliveryOptimization) at once. xUnit coverage for the
  single-tick, parallel-tick-dropped, post-exception, and 16-thread stress
  cases.

V2-only per phase-out policy. Full V2 suite green (812 + 14 new); pre-existing
RecoveryPathTests parallel-flake confirmed unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:37:12 +02:00
Oliver Kieselbach fca33d500b feat(web): mark admin-flipped sessions with a 'manual' pill on the status badge
Companion UX change for the duration-inflation fix (57b05575): admins now
see at a glance which sessions reached a terminal state via Mark Succeeded
/ Mark Failed in the portal vs. an agent-driven enrollment_complete /
enrollment_failed event. Backend already exposes AdminMarkedAction on
SessionSummary; only the TS type and the two StatusBadge call sites
(dashboard list + session detail header) needed wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:52:53 +02:00
Oliver Kieselbach 57b0557558 fix(backend): anchor admin-marked CompletedAt on LastEventAt, not UtcNow
Admin "Mark as Succeeded/Failed", rule-engine terminal failures, and the
maintenance auto-fail path all called UpdateSessionStatusAsync without a
completedAt, falling through to DateTime.UtcNow. DurationSeconds was then
(click-time − earliestEvent), which on session fe5d7153-… inflated a real
~7min enrollment to 29min on the dashboard list and Avg. Duration card.
The session detail page silently masked the bug because it recomputes
duration client-side from events.

Fix: extract ResolveCompletionTimestamp helper that prefers the caller-
supplied value, then falls back to the session's LastEventAt, and only
to UtcNow when the session never emitted events. Use the helper at both
write sites in TableStorageService.Sessions, and stop passing the bogus
DateTime.UtcNow from RuleEngine.TryMarkSessionFailedFromRuleAsync so the
fallback can take effect there too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:00:39 +02:00
Oliver Kieselbach f14c426a20 feat(agent): detect TPM-PSS-broken hardware on terminal Schannel failure
2015-era Infineon TPM 2.0 firmware (Surface Book 1 SLB 9665 et al.) can't
perform RSA-PSS signing, which Win 11 25H2 Schannel prefers for TLS
client-cert auth — the cert gets silently filtered from the handshake and
.NET surfaces a generic SecureChannelFailure with no actionable signal.

TpmPssCapabilityProbe runs once on terminal session-registration failure
(off the hot path) and emits DistressErrorType.TpmPssUnsupported when
PKCS#1 works but PSS fails on a TPM-backed key. Healthy devices pay zero
cost. V2-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:04:41 +02:00
Oliver Kieselbach d1c5b78acf chore counter 2026-04-29 09:00:52 +02:00
Oliver Kieselbach 617d63a9c2 chore(backend): security patches + minor bumps on net8.0
Backport security and hygiene fixes that were originally part of the
.NET 10 upgrade (reverted in 7abbd593) but apply equally to net8.0.

Security pins (override vulnerable transitive resolutions):
- System.Formats.Asn1 -> 8.0.2 (GHSA-447r-wph3-92pm, HIGH; was 8.0.0)
- System.Security.Cryptography.Xml -> 8.0.3 (GHSA-37gx-xxp4-5rgx +
  GHSA-w3x6-4m5h-cxqf, HIGH; was 8.0.1)

Minor/patch bumps (TFM-agnostic, surfaced by 'list package --outdated'):
- Azure.Identity 1.19.0 -> 1.21.0
- Azure.Data.Tables 12.9.1 -> 12.11.0
- Azure.Storage.Blobs 12.22.2 -> 12.27.0
- Azure.Storage.Queues 12.23.0 -> 12.25.0
- Microsoft.Azure.SignalR.Management 1.29.0 -> 1.33.0
- Newtonsoft.Json 13.0.3 -> 13.0.4

Skipped (require .NET 10 — defer until Flex stamp picks up runtime):
- Microsoft.Identity.Web 4.3 -> 4.8 (transitively needs ASP.NET Core 10)
- JwtBearer / Http.Polly bumps to 10.0.7 (ASP.NET Core 10 only)
- DataProtection 10.0.7 pin (CVE only affects 10.0.0, not 8.x)
- SYSLIB0057 X509CertificateLoader migration (obsoletion not flagged
  on net8.0 reference assemblies)

'dotnet list package --vulnerable --include-transitive' on net8.0:
  no vulnerable packages reported.
Tests: 781/781 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:46:05 +02:00
Oliver Kieselbach 7abbd59333 revert: roll back .NET 10 upgrade — Flex stamp missing runtime
Reverts:
- 8dacce5c "chore(backend): bump optional NuGet patches + fix SYSLIB0057"
- 2f68fe69 "chore(backend): upgrade Functions from .NET 8 to .NET 10 isolated"

After deploy succeeded and the Flex Consumption stack was switched to
.NET 10 Isolated in the portal, the dotnet-isolated worker died on every
boot with: dotnet exited with code 150 (FrameworkMissingFailure) and
"Exceeded language worker restart retry count for runtime:dotnet-isolated".
The deployed package was correct (TFM net10.0, FrameworkReference 10.0.0,
all package bumps applied) and the runtime stack property was set to
dotnet-isolated 10.0, but the .NET 10 runtime is not present on the
Flex Consumption stamp in West Europe even though Microsoft lists
DOTNET-ISOLATED|10.0 as supported. Restart and stop+start did not help.

Recovery sequence:
1. Stack switched back to .NET 8 Isolated (manual portal action).
2. Re-deployed last known-good net8.0 commit 637bd9a1 via temp branch
   rollback-net8 (now safe to delete).
3. /api/health back to 200.

Follow-ups:
- Re-apply security fixes (DataProtection 10.0.7 pin GHSA-9mv3-2cwr-p262,
  SYSLIB0057 X509CertificateLoader migration) on net8.0 in a separate PR.
- Open Microsoft support: ".NET 10 Isolated worker on Flex Consumption
  WestEurope fails with FrameworkMissingFailure despite stack listed as
  supported."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:42:56 +02:00
Oliver Kieselbach 8dacce5ce9 chore(backend): bump optional NuGet patches + fix SYSLIB0057
Follow-up to .NET 10 upgrade (2f68fe69). Picks up safe minor/patch bumps
flagged by 'dotnet list package --outdated' and replaces the obsolete
X509Certificate2(byte[]) ctor with X509CertificateLoader.LoadCertificate.

Package bumps (all minor/patch, no breaking changes):
- Azure.Identity 1.19.0 -> 1.21.0
- Azure.Data.Tables 12.9.1 -> 12.11.0
- Azure.Storage.Blobs 12.22.2 -> 12.27.0
- Azure.Storage.Queues 12.23.0 -> 12.25.0
- Microsoft.Azure.SignalR.Management 1.29.0 -> 1.33.0
- Newtonsoft.Json 13.0.3 -> 13.0.4

Code:
- CertificateValidator.cs:69 swap to X509CertificateLoader.LoadCertificate
  (.NET 9+ replacement for the obsolete byte[] ctor; same behavior, no
  flags/keystore changes — just unblocks SYSLIB0057).

Tests: 781/781 passing on net10.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:12:13 +02:00