Commit Graph

1524 Commits

Author SHA1 Message Date
Oliver Kieselbach bc02dad6bc test(decisioncore,agent/v2): RealmJoin gate, adapter, SID resolver tests + signal-kind fixtures (Phase D)
- DecisionCore.Tests/RealmJoinGateTests.cs (9 tests): baseline regression guard,
  RealmJoinDetected arms 60-min timeout, gate defers Finalizing on Classic AND-gate,
  RealmJoinResolved + Timeout both release through CompleteIfDeferredOrBookkeep,
  SelfDeploying terminal defers + direct-complete via SelfDeployingDeferredCompletion,
  per-package facts mutation, and audit-trail attaches realmjoin* fields.
- V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs (6 tests): each of the
  5 watcher events maps to the correct DecisionSignalKind + dual InformationalEvent;
  phase-changed is timeline-only; DisplayName clamped to 256 chars.
- V2.Core.Tests/Monitoring/Runtime/UserSidResolverTests.cs (4 tests): well-known
  BUILTIN\Administrators + NT AUTHORITY\SYSTEM SID round-trips, invalid/empty inputs
  return false.
- tests/fixtures/signal-kinds/realmjoin-{detected,resolved,timeout,package_started,
  package_completed}-v1.json: replay fixtures fulfilling the merge-block contract
  declared on DecisionSignalKind.cs:8.

Verified: 9/9 reducer + 10/10 adapter/SID tests green; no regressions in adjacent
suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:37:36 +02:00
Oliver Kieselbach 4b247f11a0 feat(agent/v2,shared): RealmJoin runtime + event-type catalog (Phase B+C)
Wire the V2 agent runtime that feeds the DecisionCore RealmJoin gate from Phase A.
On every Autopilot session the agent now observes the RJ deployment-state registry
keys and dual-emits DecisionSignals + InformationalEvents so the engine extends the
completion AND-gate while RJ is in flight.

Agent.V2.Core additions:
- RealmJoinInfo: registry constants + DeploymentPhase/Package readers
- UserSidResolver: advapi32 LookupAccountName + ConvertSidToStringSid P/Invoke
- RealmJoinWatcher: three RegistryWatchers (HKLM Parameters, HKLM Packages subtree,
  HKU\<sid> Packages subtree). Lazy attach with 2 s retry, 500 ms debounce, catch-up
  scan on Start() so pre-existing RJ state emits the matching one-shot events
  immediately. Strict dedup on Detected/Resolved/Timeout + (scope, packageId).
- RealmJoinWatcherAdapter: maps each watcher event to (1) a new DecisionSignalKind
  post + (2) a SharedConstants.EventTypes.RealmJoin* InformationalEvent. Package
  DisplayName truncated to 256 chars before posting.
- RealmJoinHost: ICollectorHost with compile-time kill-switch RealmJoinTrackingEnabled.
- DesktopArrivalDetector: new RealUserOwnerObserved event raised before DesktopArrived
  so the composition root can resolve the user's SID for the HKU watcher arm.
- DesktopArrivalHost: new onRealUserOwnerObserved callback param.
- DefaultComponentFactory: instantiates RealmJoinHost after AadJoinHost and routes the
  DesktopArrival owner through UserSidResolver -> RealmJoinHost.ArmHkuWatcher.

Shared additions:
- SharedConstants.EventTypes adds realmjoin_{detected,phase_changed,resolved,timeout,
  package_started,package_completed} so the engine + adapter + future tests all
  reference the same strings.
- DecisionEngine.RealmJoinPayloadKeys promoted from internal to public so the
  cross-assembly adapter can assemble signal payloads with the canonical key names.

Verified: full solution build clean; V2.Core tests 1124/1125 (the lone failure is the
documented WhiteGlove RecoveryPath flake — re-runs green in isolation, no Phase B link).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:29:38 +02:00
Oliver Kieselbach 98b173a858 feat(decisioncore): RealmJoin deployment gate + per-package facts (Phase A)
Add a new completion-gate to the V2 enrollment AND-gate so the session keeps
running while a RealmJoin (RJ) deployment is in flight. The agent detects RJ
via HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters and tracks
per-package installs from HKLM\SOFTWARE\RealmJoin\Packages\<id> + the
HKEY_USERS\<sid>\... counterpart; runtime wiring lands in Phase B.

Decision-layer changes:
- 5 new DecisionSignalKinds (RealmJoinDetected/Resolved/Timeout/PackageStarted/
  PackageCompleted) routed through the existing reducer dispatch.
- New DeadlineNames.RealmJoinTimeout (60-min hard cap, hardcoded).
- New immutable RealmJoinFacts + RealmJoinPackageFact on DecisionState; schema
  bumped v2 -> v3 (old snapshots load with RealmJoinFacts.Empty default).
- Classic Hello+Desktop AND-gate (HandleHelloResolvedV1, HandleDesktopArrivedV1
  Hello-disabled fast-path + helloAlreadyResolved branch, HelloSafety deadline)
  and SelfDeploying terminal path now AND with RealmJoinGateOpen(). When closed,
  the prerequisite facts are still recorded but TransitionToFinalizing /
  WithStage(Completed) is deferred until the RJ resolved/timeout handler
  releases the gate via CompleteIfDeferredOrBookkeep.
- Per-package facts capped at 200 rows; DisplayName truncated to 256 chars.
- Audit-trail attaches realmjoinDetectedUtc/Outcome/LastPhase/ResolvedUtc/Packages
  on terminal events when RJ was active.

Verified: DecisionCore tests 313/313, Agent V2.Core tests 1125/1125.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:16:02 +02:00
Oliver Kieselbach e8e062d24a feat(agent/v2,decision): surface ESP failure HRESULT + 30s settle window
Session 9d052230 dropped enrollment_failed with reason esp_terminal_failure
but the underlying HRESULT 0x87d1041c (Application not detected after install)
sat unparsed in the ESP subcategory statusText, and the agent terminated before
IME could emit the matching app_install_failed. Two-part fix:

- ProvisioningStatusTracker arms a 30s settle window between ESP-subcategory
  failure detection and EspFailureDetected fire. Gives ImeLogTracker a chance
  to surface the app-level failure on the timeline. Emits esp_failure_settle_started
  for observability.
- HRESULT extracted via language-invariant regex from the failed subcategory
  statusText, surfaced top-level on esp_provisioning_status (failedSubcategoryErrorCode)
  and propagated through new EspFailureDetectedEventArgs to the EspTerminalFailure
  signal payload. DecisionEngine.Edge writes failureType/errorCode/failedSubcategory/
  category onto the enrollment_failed event parameters. UI renders an HRESULT badge
  with description from errorCodeMap.ts (analogous to app_install_failed).

V2-only; V1 (Agent.Core) untouched. ConfigVersion unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:37:28 +02:00
Oliver Kieselbach a36bf8bf0c fix onboarding bug, onboardedBy and auto promote admin 2026-05-21 15:01:34 +02:00
Oliver Kieselbach b2e17d39f6 security hardnings - nosniff, x-forwaredded-for, ensure-tenantid 2026-05-21 14:26:12 +02:00
Oliver Kieselbach 610671a50c feat(agent): emit bootstrap version signal 2026-05-21 12:12:12 +02:00
Oliver Kieselbach f1f59b2786 feat(agent/v2,decision): surface ESP FirstSync settings into decision state
Reads BlockInStatusPage (bitmask: AllowReset / AllowTryAgain /
AllowContinueAnyway) and SyncFailureTimeout from
HKLM\SOFTWARE\Microsoft\Enrollments\{guid}\FirstSync alongside the
existing skip flags. Two new SignalFacts in EnrollmentScenarioObservations
(EspSyncFailureTimeoutMinutes, EspAllowContinueAnyway) carry the values
into the reducer so the DecisionEngine can emit substantiated
terminal-failure verdicts:

- app_install_failed message now reports the actual ESP timeout
  ("ESP timed out (60 min) while still installing.") when the
  FirstSync value is known; falls back to the generic wording.
- enrollment_failed event payload carries espSyncFailureTimeoutMinutes
  + espAllowContinueAnyway. When ContinueAnyway is true a
  mayHaveContinuedAnyway flag + continueAnywayHint string are added
  so the timeline shows the user may have dismissed ESP via the
  "Continue anyway" button and reached the desktop even though the
  agent saw a terminal failure.
- esp_config_detected event DataJson now also exposes the raw
  blockInStatusPage value, decoded allowReset / allowTryAgain /
  allowContinueAnyway flags, and syncFailureTimeoutMinutes for audit.

Bitmask semantics follow Microsoft's Get-AutopilotDiagnostics.ps1
(bit 1 = AllowReset, bit 2 = AllowTryAgain, bit 4 = ContinueAnyway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:46:10 +02:00
Oliver Kieselbach 7de094c6aa fix decisionEngine missing cancel deadlines in FinalizingStage 2026-05-20 23:00:41 +02:00
Oliver Kieselbach d89d6ef256 diag url path hardning and config check hardening 2026-05-20 21:52:37 +02:00
Oliver Kieselbach 84392a329a agent self-update hardning 2026-05-20 21:32:04 +02:00
Oliver Kieselbach ce459cdec4 hardened single-quote escape 2026-05-20 21:13:44 +02:00
Oliver Kieselbach e6792aee95 added entra upcoming valid algorithm 2026-05-20 20:09:10 +02:00
Oliver Kieselbach 7a05268250 security hardenings 2026-05-20 19:54:43 +02:00
Oliver Kieselbach 5f97a03ad9 session list add details 2026-05-20 13:21:56 +02:00
Oliver Kieselbach 9e358b9817 fix(agent/v2,web): recover terminal-stage on restart + quiet UI for terminal sessions
Agent V2: when RecoveryPath loads a snapshot already at Stage=Completed/Failed,
no reducer transition fires so OnDecisionTerminalStage was never invoked — the
agent idled until AgentMaxLifetime (default 6h), emitting periodic perf/metrics
events the whole time. Fire it explicitly at the end of Start() so the
EnrollmentTerminationHandler runs (marker write + diagnostics + self-destruct).
The WhiteGloveSealed archive-and-reset at step 0 already neutralises the
Part-2 case; non-terminal stages take the normal recovery path.

Web: gate the SignalR eventStream refetch and the 30s fallback polling on
non-terminal session status. Once Succeeded/Failed, late perf/metrics arrivals
no longer trigger a full-page refetch + re-render that scroll-jumps the
timeline while the user is reading.

Observed in session 17ef0e0e-... (2026-05-19): 4x enrollment_complete, only 1x
agent_shutting_down; reboot mid-shutdown left subsequent agent instances
running for hours emitting noise.

Tests: 3 new in RecoveryPathTests (Completed/Failed -> Terminated, non-terminal
-> no Terminated), 4 in sessionStatus.test.ts. Suites grun: Web 384/384,
Agent V2.Core 1095/1095, Backend 1844/1844.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:19:55 +02:00
Oliver Kieselbach 6c263da945 docs(architecture,skills): document dual-destination diagnostics + bump skill submodule
- docs/architecture.md Diagnostics section rewritten: both destinations
  (CustomerSas default + Hosted opt-in), new download routing on shape,
  cascade-delete step §5b with SAS-delete-permission gating, and the V2
  remote-config retry + observability events.
- Submodule .claude/commands bumped to 17a6f5b for the dual-destination
  fetch-diag-zip.sh refactor + skill error-handling note updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:57:51 +02:00
Oliver Kieselbach aadf18bbf8 fix(agent/v2): retry remote config fetch + wire-visible fallback observability
Motivating incident — session 8f2bef72 (2026-05-19, OK's VM test): a Function
App cold-start triggered by a fresh deploy stalled the agent's single-shot
GetAgentConfig call for ~60 s; the client gave up, RemoteConfigService
silently fell back to built-in defaults (ConfigVersion=0,
SelfDestructOnComplete=true, DiagnosticsUploadMode=Off), and the entire
enrollment ran the wrong policy with no wire-visible trace of the degraded
mode. ReportAgentError (the emergency channel) was also racing the cold
start and lost to 499.

Three fixes:

1. Retry FetchConfigAsync on transient errors (3 attempts; 10/30/60 s linear
   backoff). BackendAuthException still bails immediately — 401/403 won't
   change with a retry and an operator needs the AuthCertificateRejected /
   DeviceNotRegistered distress signal fast. EmergencyReporter only fires on
   the final attempt to give the function time to warm. Live ServerAction
   rotate_config keeps single-shot so it doesn't block the action handler
   for ~100 s.

2. agent_started DataJson now carries configVersion + remoteConfigFetched +
   remoteConfigOutcome (Succeeded / FromCache / UsedDefaults / NotAttempted).
   Operators can tell at a glance whether the agent is running on fresh
   tenant config vs. defaults vs. stale cache without inferring from
   absence of expected tenant-controlled knobs.

3. New remote_config_fetch_failed Warning event emitted right after
   agent_started when the fallback path was taken. Carries attempts,
   failureType, failureMessage (truncated 512), and authStatusCode when
   relevant. Closes the blind spot — the only prior trace was a Warning
   in the local agent log which is gone after a SelfDestruct cleanup.

V2 Agent suite: 1092/1092 (+16: 7 retry behaviour + 9 emitter shape).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:24:33 +02:00
Oliver Kieselbach 6952822f81 feat(web): soften hosted-diagnostics UI tone from alarm to friendly info
The red color treatment + warning-triangle icon on the Hosted-storage
option implied misconfiguration. Hosted is a deliberately good choice
(blob-scoped per-upload SAS, isolated per-tenant prefix, automated
retention via cascade-delete) — the only trade-off is data leaving the
customer's Azure tenant. Swap red for sky-blue + info-circle icon, drop
the "data leaves your tenant" red badge in favour of "managed for you",
and rephrase the disclosure as a friendly heads-up instead of a warning.
The substance is unchanged; the visual cue just no longer reads as a
fault state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:56:12 +02:00
Oliver Kieselbach feff123f11 feat(backend,agent,web): hosted diagnostics destination + cascade-delete cleanup
Adds an opt-in second destination for agent diagnostics uploads:
- CustomerSas (default, unchanged): tenant's own SAS-backed container
- Hosted (new, opt-in via explicit admin click): backend's storage account
  under {tenantId}/AgentDiagnostics-...zip, with per-upload 15-min
  blob-scoped Write+Create SAS minted via the Function App's MI

Closes the long-standing gap where Session.DiagnosticsBlobName was captured
into the deletion-manifest but the blob was never actually cleaned up by
the cascade. Hosted blobs are always deleted (we own the storage); customer
blobs are deleted only when the SAS includes the 'd' permission, otherwise
logged + skipped.

Existing tenants change nothing: the new TenantConfiguration.DiagnosticsUploadDestination
field defaults to CustomerSas, no silent flip. Hosted requires an explicit
admin click + Save with a clearly marked "data leaves your tenant" disclosure.

Tests: Backend 1844, V2 1076, V1 538, vitest 380 (+78 new across all suites).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:45:01 +02:00
Oliver Kieselbach c4aeaa4307 updated commands 2026-05-19 14:40:56 +02:00
Oliver Kieselbach d697e02f04 chore(backend,web): bump feedback comment limit 500 -> 4096 chars 2026-05-19 13:14:09 +02:00
Oliver Kieselbach 5753a567a1 docs(web): May 2026 changelog + agent changelog + known-issues resolution entry
- Public changelog: platform release entry covering safe cascade-delete + restore
  window, async tenant offboarding, optional Graph add-on, Remediation lifecycle,
  SLA throttling fix, dashboard stats overhaul, Agent V2 robustness, dual-stack
  cert selection, and miscellaneous polish.
- Docs > Agent Changelog: May 2026 section summarising V2-as-primary, health-script
  lifecycle, ASR-handoff resilience, Hello-disabled deadlock fix, TPM PSS distress
  category, MDM cert exact-match, structured cert-rejection diagnostics.
- Docs > Known Issues: resolution announcement closing out the 2026-04-16 cleanup
  incident — documents the new safeguards (session-deletion restore window,
  cleanup-table guided procedure with sample/backup/two-round gates).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:45:02 +02:00
Oliver Kieselbach c26b25b81b feat(backend,web): offboarding farewell-email capture + dedicated feedback table + reports tabs
- Persist tenant Preview-Notification-Email on OffboardingHistory row at Phase 1 so it
  survives Phase 2 wipe; ResendEmailService.SendAsync wired but disarmed by a const-false
  arm-flag with a [DRAFT] placeholder template — explicit flip required to ship.
- Move user feedback storage out of PreviewConfig discriminator into a dedicated Feedback
  table (PK=InApp / Offboarding) that is NOT in any tenant-offboarding wipe list — feedback
  from departing tenants is exactly the signal we want to keep. New IFeedbackRepository +
  TableFeedbackRepository; FeedbackFunction migrated; (PreviewConfig, "Feedback")
  removed from TenantOffboardingHandler.DiscriminatorTables.
- POST /api/tenants/{tenantId}/offboard/feedback collects a free-form farewell comment
  during the drain-barrier countdown (marker-gated, fail-soft OpsEvent emit). Reports >
  User Feedback page gains an Offboarding-Feedback sub-tab next to In-App Feedback.
- OffboardingFeedbackReceived OpsEventType registered (info-severity) + catalog entry
  in OpsAlertRulesSection so admins can wire Telegram in two clicks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:44:36 +02:00
Oliver Kieselbach bd9fc6f6f4 feat(backend): ScriptNameCache cache negative verdict 2026-05-19 10:18:49 +02:00
Oliver Kieselbach c2df889f94 fix(web): surface Optional Graph capabilities in the sidebar
`tenantNavSections.ts` (settings-page routing) was wired but the sidebar reads
from `globalNavConfig.tsx`, which is a separate source of truth for the left-rail
nav. Adding the matching entry between Bootstrap Sessions and Submit Logs so the
section becomes reachable without typing the URL manually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:26:52 +02:00
Oliver Kieselbach 863291ff7b feat(backend,web): App Insights telemetry for optional Graph add-on feature
So we can answer "which tenants actively have this on" and "how many people are
using it" later — without bolting on a separate metrics store.

Backend events
- `GraphAddOnPermissionsDetected` (GraphFeatureDetector, fresh-acquire path):
  fires at most every ~55min per tenant (cache TTL). Props: TenantId,
  GrantedRoleCount, HasScriptDisplayNames, TokenCacheTtlMinutes. → distinct
  count over time = tenants who have the optional permission turned on.
- `ScriptDisplayNamesResolved` (ScriptDisplayNameResolver, only when permission
  is active and the resolver actually did work): per session-view with scripts.
  Props: TenantId, RefsRequested, RefsResolved, CacheHits, TriggeredFullPull.
  → real usage pulse.
- `GraphAddOnStatusChecked` (GET status endpoint): admin engagement with the
  capability page. Props: TenantId, UserId, IsTransient, ScriptDisplayNamesGranted.
- `GraphAddOnRefreshTriggered` (POST refresh endpoint): high-signal "tenant just
  granted" event. Props: TenantId, UserId.

Web events (via `trackEvent` -- tenantId auto-attached by the telemetry initializer)
- `GraphAddOnStatusViewed` on Settings → Optional Graph capabilities mount.
- `GraphAddOnGrantCommandCopied` on Copy command / Copy permissions click.

Notes
- All emits wrapped in try/catch with debug-level logging — telemetry failures
  must never poison the acquire / resolve hot paths.
- Tests pass a no-op `TelemetryClient(new TelemetryConfiguration())` (same
  pattern as SlaTenantStatusLifecycleTests). 1718 backend / 364 web tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:23:21 +02:00
Oliver Kieselbach e4c4dd57db feat(backend,web): optional Graph add-on permissions for Intune script display names
Adds opt-in resolution of Intune Platform Script + Remediation display names in
session timelines via a tenant-side appRoleAssignment grant. No app manifest
change, no impact on customers who don't enable it. Architecture is reusable for
future optional Graph permissions: catalog -> detector -> resolver -> endpoint.

Backend
- GraphFeatureDetector parses the `roles` claim from the SP access token and
  caches the (token, roles) bundle per tenant. GetSnapshotAsync surfaces a
  transient flag so admin-UI callers can render "unknown" instead of a
  misleading "0 of 0 granted" when token acquire times out.
- GraphTokenService now accepts a CancellationToken so optional-feature callers
  can apply tight budgets instead of inheriting the 5+15+30 s retry chain.
- ScriptDisplayNameResolver: cache-first, list-pull-on-cold-tenant, per-ID
  fallback. Throttle-aware abort: skip per-ID for kinds whose full-pull hit a
  transient failure; break the per-ID loop on the first HttpRequestException.
  Total budget 5 s, with a tight 1.5 s sub-budget on the token acquire.
- Azure Table `ScriptNameCache` + direct-TableClient fail-loud repository
  (mirrors TableOffboardingAuditRepository pattern). Daily
  ScriptNameCacheCleanupFunction: 7 d positive / 1 h NotFound TTL.
- POST /api/tenants/{id}/scripts/display-names (body-carried refs to dodge URL
  length limits; backend cap 200 refs/request).
- GET /api/tenants/{id}/graph-permissions/status (4 s budget; includes app
  ClientId so the UI can render an exact grant command).
- POST /api/tenants/{id}/graph-permissions/refresh (invalidates token cache).
- All three routes registered in EndpointAccessPolicyCatalog (MemberRead for the
  resolve, TenantAdminOrGA for the admin-UI surfaces).

Web
- `useScriptDisplayNames` hook with stable refset fingerprint (no spurious
  refetches on parent re-renders); clears stale map on tenant/refset change so
  a session switch never leaks names across tenants.
- Chunked POST loop: 200-ref batches with partial-success semantics. Preserves
  earlier chunks when later ones fail (non-2xx OR transport throw); only
  TokenExpiredError propagates so auth flow can react.
- ScriptExecutions renders " . {DisplayName}" alongside existing label/short-id,
  keyed by Kind + policyId so Platform/Remediation under the same id never
  collide.
- New Settings section "Optional Graph capabilities": status table with granted
  / not granted / unknown, copy-paste PS one-liner with ClientId baked in,
  refresh button gated on response.ok.

Customer-facing
- scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1: idempotent
  grant / verify / revoke script. -Features ScriptDisplayNames (high level) or
  -Permissions ... (admin-UI generated). Pure ASCII for PS 5.1 compat.
- docs/customer-graph-add-on-permissions.md: end-to-end customer guide
  covering prerequisites, the appRoleAssignment pattern, and revocation.

Tests
- 1718 backend xUnit / 364 web vitest. Highlights: JWT roles parsing edge
  cases; full-pull-429 suppresses per-ID; per-ID-429 breaks loop; transient
  detection via GetSnapshotAsync; chunked partial success (HTTP failure + thrown
  transport); TokenExpiredError propagation; EndpointPolicyCatalogCompleteness
  covers the three new routes by reflection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:14:21 +02:00
Oliver Kieselbach 206388da99 feat(backend,web): tenant offboarding async cascade with customs archive
Replace synchronous DeleteAllTenantDataAsync with a queued async cascade
that wipes all 24 tenant-scoped tables safely, archives custom rules,
and survives crashes at every step.

Phase 1 (TenantOffboardFunction, ~200ms sync):
  - Write History / ByTenantPointer / Marker rows (OffboardingAudit table)
  - Flip TenantConfiguration.Disabled=true (the active auth gate)
  - Enqueue with 6 min visibility-delay (cache-drain barrier =
    TenantConfigurationService.CacheDuration 5 min + 1 min safety)
  - Return 202 with EarliestProcessingAt for the UI countdown

Phase 2 (TenantOffboardingWorker + Handler, async after drain):
  - Enumerate sessions, per-session cascade-enqueue, persist
    Expectations blob (separate offboarding-state container)
  - Drain probe against per-session progress blobs; self re-enqueue
    up to 60x at 2 min visibility-delay
  - 2.D-archive: GatherRules / AnalyzeRules / ImeLogPatterns moved
    to TenantOffboardingCustomsArchive table (3-iter cap + verify)
  - 2.D-wipe: SafeWipe over all tenant tables (4 variants: exact-PK,
    composite-PK range, discriminator+TenantId, property-only) with
    fetch+verify+delete pattern + SafeWipeVerificationException
  - 2.E: blob-prefix wipe deletion-manifests/{tenantId}/
  - 2.D-pass-2: re-wipe AuditLogs (defense-in-depth)
  - 2.G: side-effects FIRST (Marker, Pointer, blob, audit, OpsEvent)
    then History=Completed LAST (DrainCompletedAt as resume anchor)
  - 2.F-final: delete TenantConfiguration row LAST so self-service
    re-onboarding (next /api/auth/me auto-creates default) works

Defense-in-depth:
  - OffboardingMarkerCleanupFunction (2h timer): sweeps Completed
    markers after 15 min grace, conditional TenantConfiguration sweep
    (only when row is still the offboarding tombstone — preserves
    user-driven self-service re-onboards)
  - Worker poison path: durable Failed state MUST land before queue
    moves; transient failure leaves message visible for retry
  - SessionDeletionHandler reorder: Audit + SignalR notify BEFORE
    CompletedAt write so the drain predicate is correct

Web UI:
  - /settings danger zone: typed "OFFBOARD" confirmation dialog,
    post-202 drain-barrier banner with minute-based countdown, hint
    "you can sign out and close this tab — deletion runs in
    background", silent auto-logout when countdown elapses
  - /admin/customs-archive (GA only): list runs + inspect entries +
    typed "DELETE" confirmation modal for run/entry deletion
  - OpsAlertRulesSection: TenantOffboardingFailed event registered
  - lib/api.ts: shared CustomsArchive* response types

Tests: 100+ new tests covering drain contract, handler ordering,
worker poison path, ETag-CAS, SafeWipe variants, OffboardingFilters
prefix-bleed safety, MarkerCleanup conditional sweep, Bootstrap
auth-disabled-tenant gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:06:51 +02:00
Oliver Kieselbach 884f29b019 update offboard function 2026-05-18 12:37:35 +02:00
Oliver Kieselbach e64b795b28 fix certificate selection 2026-05-18 12:25:45 +02:00
Oliver Kieselbach bb4f663ace fix(agent/v2,decisioncore): gate AwaitingHello on AccountSetup truly succeeded
Session 330f73f3 completed via DeadlineFired:finalizing_grace (helloOutcome=
Timeout) while ESP AccountSetup was still at "4 of 5 subcategories completed"
and 3 of 8 apps still pending. ShouldTransitionToAwaitingHello promoted on
AccountSetupEnteredUtc != null alone, but Windows CXH event 62407 fires at
every ESP-page transition — the first occurrence (Device→Account handoff,
9 s after AccountSetup entered) was treated as the genuine final exit,
armed HelloSafety against an in-progress AccountSetup, fired its 5-min
synthetic timeout, and FinalizingGrace declared the session terminal
mid-flight.

Introduce a strong "AccountSetup truly succeeded" fact sourced from
ProvisioningStatusTracker (categorySucceeded=true OR fallback after 30 s
of all subcategories at succeeded/notRequired — same shape as DeviceSetup).
Forwarded by EspAndHelloTracker, posted as new DecisionSignalKind.
AccountSetupProvisioningComplete by the V2 adapter, lands as DecisionState.
AccountSetupProvisioningSucceededUtc. The reducer's
ShouldTransitionToAwaitingHello now requires this fact (or SkipUserEsp);
the old AccountSetupEnteredUtc gate becomes an entry-time marker only.

HandleAccountSetupProvisioningCompleteV1 has a deferred-promotion path:
when the strong fact arrives AFTER a guard-blocked EspExiting
(EspFinalExitUtc set) OR after a guard-blocked EspPhaseChanged(Finalizing-
Setup) (FinalizingEnteredUtc set, ShellCoreTracker adapter fire-once-
dedupes), the handler performs the deferred promotion + arms HelloSafety
anchored at the AccountSetup-succeeded instant.

The new fact is registered in DecisionStateSignalCensus so enrollment_*
audit trails and final-status.json show whether the strong gate was
satisfied at termination — load-bearing for diagnosing future
false-negative timeouts under the new gate.

Side cleanup: ProvisioningStatusTracker's fallback timer callbacks (new
AccountSetup AND pre-existing DeviceSetup parallel path) now reset the
timer field on every abort path AND in the outer exception catch. Without
this, a transient subcategory flicker between fallback arm and timer fire
could permanently prevent re-arming even when the state re-stabilises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:46:37 +02:00
Oliver Kieselbach 9538eba3ad feat(web): sidebar usage telemetry — global prop + toggle event
Adds `sidebarState` ("full" | "icons" | "hidden") as a global telemetry
property on every App Insights event/pageView, analogous to `theme`.
Pre-seeded from localStorage at init and kept in sync via the
useSidebarState hook on every state change.

Fires a `sidebar_state_changed` customEvent with `{from, to}` only on
user-initiated cycle clicks (guarded by previous != next, so SSR
hydration and idempotent setter calls do not produce noise).

Together these answer "how many users live in the collapsed sidebar?"
(persistent preference per Auth-User via global prop) and "how often do
people toggle it?" (active interaction via customEvent). Bumps the
.claude/commands submodule for the matching ux-insights skill catalog
update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:34:11 +02:00
Oliver Kieselbach 75b8785513 docs(skill): bump backend-logs submodule — cert-rejection KQL section
Pulls in submodule commit e27a9d3 which adds queries for the new AgentCertRejected
+ AgentRequestRejected backend warnings and the V2 distress cert-context fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:30:10 +02:00
Oliver Kieselbach 0a8a6eabd7 feat(backend,agent): cert-reject visibility — structured backend warnings + V2 distress cert-context
Backend emits AgentCertRejected (with reason enum) at every cert-validation
failure path and AgentRequestRejected (with request-context) at every 401/403
return so operators can diagnose why mTLS auth was denied. V2 agent now
attaches a Thumbprint/Subject/Issuer/NotBefore/NotAfter snapshot plus a
DistressCertSourceState classifier to each distress report; correlates 1:1
with the backend warnings via tenant + serial + thumbprint.

Distress endpoint stays unauthenticated and security-restrictive: rate-limit
(3-layer) + sanitize + truncate + enum-validation unchanged; new fields are
hex-regex / IsDefined / hard-capped at 96 chars; MaxContentLength bumped from
1024 to 1536 to fit the cert-context payload. V1 agent untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:26:52 +02:00
Oliver Kieselbach 1041d44831 feat(backend,web): restore-browser tenant filter — only tenants with snapshot blobs
Adds a "Only tenants with restore data" checkbox to the Restore Browser
tenant dropdown so a Global Admin can hide tenants that have nothing to
recover. Default off (full AdminConfig list); flipping it on fetches a
cheap hierarchy listing once (cached for page lifetime) and intersects
the dropdown with the tenants that own ≥1 snapshot blob.

Backend: new GlobalAdminOnly endpoint
GET /api/global/tenants-with-deletion-manifests backed by
BlobStorageService.ListTenantsWithDeletionManifestsAsync, which uses
GetBlobsByHierarchyAsync with delimiter="/" so Azure returns just the
top-level prefixes (= tenant IDs) without enumerating blobs underneath.
Cost scales with tenant count, not manifest count.

Web: checkbox under the tenant dropdown shows a "(X of Y)" counter when
active, dropdown placeholder reflects the loading state, and the
currently-selected tenant clears if the filter excludes it. Fetch
failures snap the checkbox back to off so the operator never gets
stranded with an empty list.

Build: 0 warnings/errors. Backend 1526/1526 pass. tsc clean.
Vitest 346/346 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:39:57 +02:00
Oliver Kieselbach c99ca0e461 update audit entry to more specified perfomed by 2026-05-15 15:09:58 +02:00
Oliver Kieselbach 2211399f23 chore(backend,web): remove legacy session-delete path (PR7)
Cascade-delete V2 is the only path now. Drops the per-tenant
EnableCascadeDeleteV2 flag, the legacy ETag-CAS-guarded DeleteSessionAsync
helper, the AppInstallSummaries cleanup helper (only legacy caller),
and the SessionsLegacyDeleted counter chain (fanout → maintenance → OpsEvent).

DeleteSessionFunction collapses to a single kill-switch gate; the producer
owns 404 / 409 / Poisoned mapping AND the intentional Queued/Preparing+Snapshot
→ 202 recovery resume — gating those at HTTP would strand crashed admin
cascades. Fanout now aborts both the inner loop AND outer loop on producer
KillSwitchActive instead of leaking calls into the next tenant.

Test coverage:
- DeleteSessionCasGuardTests removed (helper gone)
- AdminConfigurationSessionDeletionKillSwitchTests trimmed to kill-switch only
- DeleteSessionFunctionTests pinned to the kill-switch short-circuit + null
- SessionRetentionFanoutServiceTests: legacy path tests dropped, two new
  abort regressions for producer KillSwitchActive (inner + outer loop)

Build: 0 warnings, 0 errors. Backend: 1526 passed / 0 failed.
Web: tsc clean, vitest 346 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:59:40 +02:00
Oliver Kieselbach ae0e8a61ae updated tooltip 2026-05-15 11:12:31 +02:00
Oliver Kieselbach 51920dbf4e added session id to restore browser 2026-05-15 11:03:39 +02:00
Oliver Kieselbach 6f900a412a fix(agent/v2): tighten shutdown-gate semantics + WMI error counting
Codex review of ef530fb8 surfaced three issues; all addressed.

P1 - Auth-failure bypassed the new shutdown gate. CreateAuthThresholdHandler
emitted agent_shutting_down directly without claiming the cross-path slot,
so the finally-block runtime_host_exit fallback then also emitted, putting
two events on the wire. The handler now accepts tryClaimShutdownEvent +
releaseShutdownEventClaim Funcs and participates in the same gate as the
other paths. AgentRuntimeHost wires it in via TryClaimAgentShuttingDownEmit
plus the new ReleaseShutdownEventClaim helper.

P2 - The gate was claimed before the emit had any chance to succeed, so a
Ctrl+C that fired before onIngressReady (lifecyclePost == null) consumed
the slot permanently and the later finally fallback could no longer surface
the event. EmitAgentShuttingDownGapPath now returns the new
AgentShuttingDownEmitResult enum (Success / NoPost / EmitFailed). The
AgentRuntimeHost wraps claim + emit in TryEmitAgentShuttingDownGap which
releases the slot on NoPost so a later path can retry, while keeping it
claimed on Success and EmitFailed. Auth-failure path symmetric: releases
on null post.

P3 - GetProcessOwner swallowed its WMI exceptions and returned null without
bumping _wmiErrorCountSinceStart. desktop_detector_first_poll and
_no_candidate therefore reported wmiErrorCount=0 even when WMI was broken,
masking the very failure mode the liveness events were designed to detect.
The counter is now incremented inside GetProcessOwner's catch AND on
non-zero InvokeMethod results, so the per-poll PID-owner failure rate
flows through to telemetry.

Tests: 3777 green (1538 backend + 1068 V2.Core, +3 for new auth_failure
gate tests + 530 V1 + 294 DecisionCore + 347 vitest). TypeScript noEmit
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:56:16 +02:00
Oliver Kieselbach 8dd80d7232 update gitignore 2026-05-15 09:39:37 +02:00
Oliver Kieselbach ef530fb890 feat(agent/v2): DAD liveness + agent_shutting_down gap closure
DAD liveness (3 new state-change-only events, NOT periodic, max 3 per
detector lifetime): desktop_detector_started / _first_poll /
_no_candidate. Distinguishes "agent dead post-reboot" from "DAD wiring
broken" from "user never logged in" in sessions missing desktop_arrived
(the ca9e3c59 timeout pattern). Threshold via global AdminConfiguration
.DesktopDetectorNoCandidateTimeoutMinutes (default 10, 0 = disabled),
round-trip wired through TableConfigRepository, GetAgentConfigFunction
(ConfigVersion 27 -> 28), CollectorConfiguration, DefaultComponentFactory,
DesktopArrivalHost + admin UI. Also fixes a peripheral-loss bug: V2's
DesktopArrivalDetector.OnTraceEvent callback was never wired, so the
existing desktop_excluded_user and desktop_real_user_detected events
were dead code in V2. Now routed via InformationalEventPost in the host.

Shutdown gap closure (4 new agent_shutting_down emit paths): ctrl_c,
process_exit, unhandled_exception (with exception type + 500-char-
truncated message), runtime_host_exit (defensive finally fallback).
Cross-path Interlocked idempotency gate shared with EnrollmentTermination
Handler via new tryClaimShutdownEvent ctor param prevents double-emit
when Ctrl+C races a real Terminated transition.

Event-type unification (V2 only): CreateAuthThresholdHandler now emits
agent_shutting_down with data.reason=auth_failure (was agent_shutdown).
EnrollmentTerminationHandler reason vocabulary unified to lowercased
tags (decision_terminal, max_lifetime). V1 keeps legacy agent_shutdown
(phasing out). Web/MCP consumers (eventHelpers, whiteGloveSplit test
fixtures, gather-rules eventTypes catalog, MCP semantic-event list,
docs) treat both as synonyms.

Tests: 3411 green (1538 backend + 1065 V2 core + 530 V1 core + 294
DecisionCore + 347 vitest). TypeScript noEmit clean. 5 new DAD-liveness
tests, 5 new gap-path tests, 2 new idempotency tests, 3 reason-vocab
updates.

Not in this PR: cross-process death detection for the actual ca9e3c59
silent-post-reboot-death pattern (Logon-Trigger / external watchdog) -
separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:39:07 +02:00
Oliver Kieselbach f1583fc586 fix(backend,web): move cascade-delete admin routes from /api/admin/* to /api/global/*
The /api/admin/* prefix collides with the Azure Functions runtime's built-in
admin paths, which sit behind mTLS client-certificate auth. Browser preflights
to /api/admin/* trigger TLS renegotiation against the Function App's mTLS
boundary and die before our PolicyEnforcementMiddleware ever runs — the
operator sees CORS-shaped errors that aren't actually CORS-shaped.

Repo convention all along has been /api/global/* for cross-tenant GA-only
HTTP endpoints (e.g. global/session-deletions, global/session-reports). The
cascade-delete endpoints landed under /api/admin/* in the original 7-PR
backend branch and went undetected until the admin UI started calling them
from the browser.

Routes renamed:
  admin/sessions/{sessionId}/delete/preview     → global/sessions/{sessionId}/delete/preview
  admin/sessions/{sessionId}/restore            → global/sessions/{sessionId}/restore
  admin/sessions/{sessionId}/deletion-manifest  → global/sessions/{sessionId}/deletion-manifest
  admin/tenants/{tenantId}/deletion-manifests   → global/tenants/{tenantId}/deletion-manifests

Touched:
- 4 Function classes: Route attribute + XML-doc header
- EndpointAccessPolicyCatalog: 4 entries (TenantScoping.RouteParam preserved
  on the tenants route), with a comment explaining why /api/global/* is the
  load-bearing prefix for new GA-only HTTP routes
- DeleteSessionFunction: 3 user-facing "Cascade is poisoned; recover via
  POST /api/.../restore" error messages updated
- SessionDeletion.cs: XML-doc reference to the restore route
- lib/api.ts: 4 frontend URL builders (storedManifest, preview, restore,
  tenantManifests)
- RestoreSessionFunctionTests: 3 EndpointAccessPolicyCatalog.FindPolicy
  lookups
- deleteSessionResponse.test.ts: test fixture message string

No semantic change — only the URL prefix moves. CORS config + JWT middleware
+ policy gating all work the same. The Restore Browser tab + the per-row
Preview / Restore buttons in the Session Cleanup admin page now actually
reach the worker from a browser.

Tests: backend xUnit 1538/1538 pass, web vitest 347/347 pass, dotnet build +
tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:38:34 +02:00
Oliver Kieselbach 44076686b6 feat(backend,web): restore-browser tab + detailed dry-run/real-run dialog
Follow-ups on the Session Cleanup admin page after first hands-on review:
an entry point for restoring already-completed cascades (Sessions row gone,
manifest still in the 33-day TTL window), a proper outcome panel that surfaces
what the backend already returned, and a sidebar fix.

== New Restore Browser tab ==
- Backend: GET /api/admin/tenants/{tenantId}/deletion-manifests enumerates
  every persisted snapshot blob under <tenantId>/, parses (sessionId,
  manifestId, sizeBytes, lastModifiedUtc) from the blob name, groups by
  sessionId and sorts both levels by recency. GA-only + TenantScoping.RouteParam
  in EndpointAccessPolicyCatalog (pinned by EndpointPolicyCatalogCompletenessTests).
- BlobStorageService.EnumerateDeletionManifestsByTenantAsync — sibling to
  the existing maintenance-sweep enumerator but tenant-prefixed and yielding
  size + timestamp so the UI can rank without an extra HEAD per blob.
- Web: new tab "Restore Browser" between Stranded and Activity. Tenant
  dropdown (sourced from AdminConfigContext.tenants) → collapsible
  session/manifest tree on the left, manifest summary on the right with a
  Restore… button that opens the dialog with IDs pre-filled. Useful when
  the cascade completed cleanly and the row no longer appears on the
  In-Flight / Poisoned / Stranded tabs.

== Reusable summary view ==
DeletionManifestSummaryView extracted from the modal so the same fetch +
field block + worker-progress + preflight-counts + last-failure panel renders
both inside the modal (Preview button on the other tabs) and inline on the
Restore Browser's right-hand panel. Modal becomes a thin wrapper.

== Dry-run / real-run side-by-side ==
RestoreConfirmDialog now keeps both outcomes visible:
- Two slots (dryRunResult, realRunResult), each rendered as a full result
  section with predicted/actual rows-by-table, inventory re-increments,
  duration, backend message, current state, pending manifest id.
- "Start over" resets both slots so the operator can re-run cleanly.
- Submit buttons gate the workflow: pre-submit shows "Run dry-run" + an
  explicit "Skip dry-run · Restore now"; after dry-run the second button
  becomes "Apply restore" (clear-intent); after real-run all submit
  buttons disappear so accidental double-applies aren't possible.
Previously the dialog discarded everything but Outcome + Mode + dryRun
flag, hiding the actual point of dry-run (the wouldRestoreByTable map).

== Codex review follow-ups (this round) ==
- P2: Restore Browser cleared the displayed sessions only after the new
  fetch returned, so a fast A→B switch (or out-of-order responses) could
  leave the operator looking at tenant A's manifests with the dropdown
  saying B. Fix: clear sessions/expandedSessions/selection synchronously
  on tenant change, abort the in-flight request via AbortController,
  pair the response with a loadedTenantId state, and gate rendering on
  loadedTenantId === selectedTenantId. Three-layer protection so a late
  response that sneaks past the abort can't paint stale data.
- P3: PoisonedTab's onRestored callback closed the dialog immediately
  after a real restore, defeating the new dry-run vs real-run side-by-side
  display. Fix: drop setRestoring(null) from the callback so the dialog
  stays open until the operator dismisses it via Close / Start over.
  Background refresh of the Poisoned list still happens via setRefreshKey.
  Now consistent with the Restore Browser tab.

== Sidebar highlight fix ==
GlobalSidebar marked BOTH ga-ops sub-items active when on the longer
nested route, because each sub used pathname.startsWith(sub.href + "/")
independently. Visible bug: navigating to /admin/ops/session-cleanup lit
up both "Maintenance" (/admin/ops) and "Session Cleanup" in the sidebar.
Fix: compute the longest-matching sub.href once per expanded group, mark
only that one active.

Tests: backend xUnit 1538/1538 pass (one new endpoint registered in the
catalog, picked up by the existing completeness test). Web vitest 347/347
pass. TypeScript strict + dotnet build clean.

Deliberately deferred:
- xUnit for GetTenantDeletionManifestsFunction (same HttpRequestData
  mock-harness gap as the other read endpoints).
- vitest for RestoreBrowserTab + the new dialog flow (defer until a
  real field issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:48:30 +02:00
Oliver Kieselbach fa8ad38ed1 change bell notification to always visible 2026-05-13 20:19:40 +02:00
Oliver Kieselbach 09bfe7a1bc feat(backend,web): admin session-cleanup UI for V2 cascade-delete + codex follow-ups
Operationalises the 7-PR backend cascade-delete pipeline that landed via commits
c8658b9f..921994e8: per-tenant V2 opt-in switch, audit-trail consolidation, and
a Global-Admin-only Session Cleanup page to track in-flight / poisoned / stranded
cascades and trigger restores.

== PR-A: per-tenant V2 enrollment ==
- UpdateTenantConfigurationFunction extends the GA-only gate list with
  EnableCascadeDeleteV2 so tenant admins cannot self-opt-in / out of the
  snapshot+restore pathway.
- TenantManagementSection.tsx adds a purple-bordered "Enable V2 Cascade Delete"
  checkbox in the GA edit modal and a "Cascade V2" badge on the tenant card so
  rollout state is visible at a glance.

== PR-B: audit consolidation ==
- SessionDeletionHandler drops the 15-per-cascade deletion_step_completed /
  deletion_step_failed / deletion_verification_failed audit emissions. Step
  telemetry lives in DeletionProgress + structured logs; tenant admins now see
  just deletion_started + deletion_completed + deletion_restored.
- SessionDeletionWorker no longer writes a deletion_poisoned tenant audit;
  the signal moved to a new SessionDeletionPoisoned OpsEvent (operator-bound,
  Telegram-routable, dual-registered in OpsAlertRulesSection.tsx catalog).
  IMaintenanceRepository dependency removed from the worker constructor.
- SessionRestoreService stops emitting the deletion_manifest_downloaded
  breadcrumb; deletion_restored is the operator-visible outcome.

== PR-C: Session Cleanup admin page ==
- New /admin/ops/session-cleanup route (gated by app/admin/layout.tsx's
  ProtectedRoute requireGlobalAdmin). Sidebar entry under ga-ops alongside
  Maintenance. Four URL-state tabs: In-Flight / Poisoned / Stranded Queued /
  Activity (links to existing /audit page).
- Backend reads: GetSessionDeletionsListFunction
  (GET /api/global/session-deletions?state=&strandedSinceMinutes=) +
  GetSessionDeletionManifestFunction
  (GET /api/admin/sessions/{id}/deletion-manifest?tenantId=&manifestId=&mode=).
  Both registered as GlobalAdminOnly in EndpointAccessPolicyCatalog.
- DeletionPreviewModal reads the persisted snapshot via the stored-manifest
  endpoint (NOT a fresh dry-run via the existing /delete/preview, which would
  enumerate live-but-partially-deleted data for in-flight cascades). Renders
  manifest counts, snapshot SHA, worker progress (CompletedSteps,
  VerificationDone, TombstoneStarted, aggregate decrements), and the last
  verification failure with residual sample. Download button uses
  authenticatedFetch + Blob + Object URL so the Bearer token is sent.
- RestoreConfirmDialog drops the Full/Partial radio (mode is auto-selected
  by SessionRestoreService based on cascade state — forcing the wrong mode
  would corrupt state). Reason field threads through to deletion_restored
  audit details. Dry-run default = true and previews the auto-selected mode.

== Codex review follow-ups (across four rounds) ==
- High: tenantId is explicitly accepted on /delete/preview (query) and
  /restore (body); GA cross-tenant resolution prefers the explicit value over
  JWT TargetTenantId, falling back to SessionsIndex lookup. Without this, a
  GA acting on a session in another tenant got their JWT home tenant.
- High: new stored-manifest endpoint replaces the dry-run preview for
  in-flight / poisoned rows; previous fix built a fresh manifest against
  already-partially-deleted data and misrepresented the cascade.
- Medium: SessionDeletionPoisoned OpsEvent now carries failureType +
  failureMessage + observedResidualCount + a small residual preview, persisted
  by the handler into DeletionProgress (LastFailureType, LastFailureMessage,
  LastObservedResidualCount, LastResidualSampleJson) before the throw so the
  worker can include them.
- Medium: dry-run restore reports the AUTO-SELECTED mode (full/partial) so
  the dialog's preview reflects what the real run will do, instead of the
  literal string "dryRun".
- Medium: residual count field renamed to observedResidualCount with an
  honest XML doc — CascadeVerificationService caps at 50 per table AND
  short-circuits after the first failing table, so the number is a lower
  bound. UI surfaces "N of 50+ observed rows" when N hits the cap.
- Medium: three-layer cap defense on the OpsEvent residual sample to stay
  under the 4096-char TableOpsEventRepository truncation: max 5 entries
  (OpsEventResidualSamplePreviewSize), per-field max 96 chars with `…` marker
  (OpsEventResidualKeyMaxChars), total preview budget 1200 chars
  (OpsEventResidualPreviewBudgetChars) with trailing-entry drop. Full sample
  stays in the progress blob for the admin UI.
- Low: estimatedSnapshotBytes field name aligned end-to-end; activity tab
  text clarifies audit-action vs OpsEvent distinction.

Tests:
- Backend xUnit 1538/1538 pass (up from 1510 baseline; +28 across the new
  Session Cleanup endpoints, OpsEvent enrichment, restore-mode reporting,
  cap-defense unit + integration tests).
- Web vitest 347/347 pass.
- TypeScript strict + dotnet build clean.

Deliberately deferred:
- xUnit tests for GetSessionDeletionsListFunction +
  GetSessionDeletionManifestFunction (no HttpRequestData mock harness in repo;
  would be a separate refactor).
- vitest for DeletionPreviewModal + RestoreConfirmDialog (defer until first
  field issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:09:50 +02:00
Oliver Kieselbach 5846b11775 fix(mcp-deps): bump transitive protobufjs to 7.5.8 to resolve Dependabot alerts
Patch-level bump within onnxruntime-web's ^7.2.4 constraint; closes 8 open
Dependabot alerts (CVE family: DoS, code injection, prototype pollution).
Lockfile-only change, no manifest update needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:54:31 +02:00
Oliver Kieselbach 1c00f44709 Merge pull request #63 from okieselbach/fix/v2-classic-hello-disabled-deadlock AND parts of the deletion cascade feature
fix(agent-v2): Classic-v1 + Hello=disabled completion deadlock AND parts of the deletion cascade feature
2026-05-13 15:10:39 +02:00
Oliver Kieselbach 2e7172b6ab test(agent-v2): pin coordinator re-raise path via OnEspExited test seam (PR6/6)
The PR4 coordinator-pipeline tests drove adapter.TriggerEspExitingFromTest(),
which only proves the adapter emit — it bypasses the coordinator's OnEspExited
handler (where LastEventOccurredAtUtc mirroring + public EspExited re-raise
live). A future regression that breaks the coordinator subscription, the
LastEventOccurredAtUtc mirror, or the public EspExited fire would slip past.

This commit adds a coordinator-level test seam mirroring the existing
TriggerFinalizingSetupPhaseForTest pattern, and reroutes the wire-through tests
through it.

EspAndHelloTracker.TriggerEspExitedForTest:
- internal seam (InternalsVisibleTo) that calls OnEspExited(this, args)
- exact signature match to the live ShellCoreTracker.EspExited delegate
- exercises LastEventOccurredAtUtc mirror + public EspExited invoke + finally
  reset — i.e. the full coordinator-side contract

ShellCoreToEngineEspExitingTests:
- Coordinator_EspExited_handler_re_raises_to_adapter_as_EspExiting_signal:
  asserts source-time preservation through the coordinator → adapter chain
  and that no clock-fallback "derivedTimestamp" evidence tag was added.
- Coordinator_EspExited_handler_clears_LastEventOccurredAtUtc_after_re_raise:
  new defensive test for the finally-block reset; protects against a stale
  ShellCore timestamp bleeding into a subsequent Hello/Provisioning forward.
- Coordinator_to_reducer_arms_HelloSafety_when_AccountSetup_observed:
  rewritten to use the coordinator seam; now asserts HelloSafety dueAtUtc ==
  sourceTime + 300s (not Clock.UtcNow + 300s) so source-time threading is
  proven end-to-end (ShellCoreTracker → Coordinator → Adapter → Engine), not
  just adapter → engine.

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