Commit Graph

1173 Commits

Author SHA1 Message Date
Oliver Kieselbach fbb338efcc Merge pull request #57 from okieselbach/chore/mcp-dep-vulns
chore(mcp): fix Dependabot vulns (protobufjs critical, hono, vite, sdk)
2026-04-26 13:25:05 +02:00
Oliver Kieselbach 50c002712b chore(mcp): fix Dependabot vulns via npm audit fix + @anthropic-ai/sdk major bump
Resolves all 13 Dependabot alerts on src/McpServer/autopilot-monitor-mcp
(1 critical, 4 high, 8 medium):

npm audit fix (no --force) lifted these transitively, all within existing
parent semver ranges:
- protobufjs 7.5.4 -> 7.5.5 (CRITICAL: arbitrary code execution,
  GHSA-xq3m-2v4x-88gg, transitive via @huggingface/transformers ->
  onnxruntime-web; the over-pinned protobufjs override I had planned
  turned out to be unnecessary)
- hono 4.12.9 -> 4.12.15 (6 advisories: serveStatic bypass, cookie name
  validation, IP matching, JSX SSR, toSSG path traversal)
- @hono/node-server 1.19.11 -> 1.19.14 (serveStatic bypass mirror)
- path-to-regexp 8.3.0 -> 8.4.2 (high: ReDoS x2)
- vite 8.0.3 -> 8.0.10 (high x3, transitive via vitest)
- postcss 8.5.8 -> 8.5.10 (transitive via vite)

Also bumped:
- @anthropic-ai/sdk ^0.80.0 -> ^0.91.0 (medium: Memory Tool path
  validation; we don't use the Memory Tool, but bumping anyway to clear
  the alert. Verified agents/query.ts still compiles -- only standard
  Messages API used: client.messages.create, Anthropic.Tool,
  Anthropic.MessageParam, Anthropic.ToolUseBlock, Anthropic.TextBlock.)

Verified:
- npm audit -> 0 vulnerabilities
- npm run build (tsc) -> green
- npm test gated on AUTOPILOT_API_TOKEN env var (pre-existing setup,
  not affected by this change)
2026-04-26 13:22:00 +02:00
Oliver Kieselbach 8a773a062f chore(web): fix Dependabot vulns via npm audit fix + postcss override
Resolves 6 Dependabot alerts on src/Web/autopilot-monitor-web (next high,
vite high x3, picomatch high, flatted high, postcss medium):

- next 15.5.12 -> 15.5.15 (closes GHSA-q4gf-8mx6-v5v3, GHSA-3x4c-7xq6-9pq8,
  GHSA-ggv3-7p47-pfv8 within ^15.1.6 range)
- vite 8.0.3 -> 8.0.10 (transitive via vitest)
- picomatch, flatted bumped via lockfile resolution
- postcss devDep ^8.4.49 -> ^8.5.10 + "overrides" entry to force the
  copy bundled inside next/node_modules/postcss to >= 8.5.10
  (npm refused override-only because the direct range overlapped the
  vulnerable window; bumped both)

Verified:
- npm audit -> 0 vulnerabilities
- npm run build -> green
- npm test -> 179/179 passed
2026-04-26 13:19:20 +02:00
Oliver Kieselbach 0305c0d635 test(v2): add UserDriven Hello-skipped fixture (anon df7abbc2)
Real-world V2 enrollment-session replay covering a UserDriven-v1 happy
variant where Hello-for-Business was enabled but per-user enrollment
resolved as "skipped" rather than "Success". Exercises the Hello+Desktop
AND-gate's tolerance for any terminal Hello outcome.

Asserts Stage=Completed, Outcome=EnrollmentComplete, HelloOutcome=skipped,
ScenarioProfile.Mode=Classic (set by ImeUserSessionCompleted).

README replaced with pointer to ScenarioTests.cs as source of truth —
old "(empty)" placeholder dropped, only the new entry listed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:59:21 +02:00
Oliver Kieselbach 0e08af8f58 refactor(v2): extract AgentRuntimeHost + TerminationPipeline (PR7)
Final PR of the V2 Runtime-Host slice — Phase 7+8 of RunAgent (the
EnrollmentOrchestrator-using block, the shutdown synchronisation
primitives, the onIngressReady wiring, the post-startup decision-signal
posts, the analyzer + probes startup, the shutdown.Wait + finally)
moves into Runtime/AgentRuntimeHost.cs and Runtime/TerminationPipeline.cs.

AgentRuntimeHost.Run takes the bundles produced by PR2-PR5
(BootstrapResult, BackendAuthBundle, RuntimeConfigBundle,
TelemetryClientResult, SessionRegistrationOutcomeResult) plus the
pre-computed paths and returns the agent's process exit code. The
cadence-clamp helpers (Min/Max/Default Upload* constants +
ClampUploadIntervalSeconds + ClampUploadBatchSize) move with it — they
are an internal concern of the runtime host, not a Program-level
configuration helper.

TerminationPipeline.Run owns the finally-block contract: unsubscribe
event handlers (Ctrl+C / ProcessExit / Terminated / ThresholdExceeded)
to prevent re-entrant shutdown, run orchestrator.Stop, dispose the
mTLS HttpClient and the BackendApiClient, then signal shutdownComplete
LAST so any ingest-dispatcher thread parked in the
ServerActionDispatcher terminate-callback only resumes once a
fully-cleaned-up agent is visible. The Plan §6.2 synchronous-shutdown
contract is preserved verbatim.

RunAgent collapses to a linear ~75-line composer: Phase 1+2 →
AgentBootstrap.Run, Phase 3 → BackendClientFactory.BuildAuthClients,
Phase 4 → AgentRuntimeConfig.Resolve, Phase 5 →
BackendClientFactory.BuildTelemetryClients, Phase 6 →
BackendSessionRegistration.Register, Phase 7+8 →
AgentRuntimeHost.Run. Every dead alias from the previous PRs is
cleared; each phase is a single named call with a clear exit-code
result.

Program.cs: 1607 LOC (pre-PR1) → 441 LOC (after PR7). RunAgent body:
~770 LOC → ~75 LOC, fully linear. No behavioural change — every
construction order, exit code, side effect and synchronisation contract
is preserved exactly.

Build clean (0 W / 0 E). Full V2.Core suite 766/767 (1 known
RecoveryPathTests threading flake, passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:36:56 +02:00
Oliver Kieselbach fb71fb1c9c refactor(v2): extract LifecycleEmitters + ServerControlPlane (PR6)
PR6 of the V2 Runtime-Host slice — the heaviest closure-capture block
of RunAgent moves into two new factory classes plus the
ServerActionSynthesis partial is folded into the new home.

LifecycleEmitters (Runtime/LifecycleEmitters.cs):
- EmitAgentStarted, EmitVersionCheckIfAny,
  EmitUnrestrictedModeAuditIfChanged
- PostSessionStarted, PostAdminPreemption, PostSystemRebootObserved
  (decision-signal posters that previously lived as Program statics)
- CreateMaxLifetimeEmitter / CreateAuthThresholdHandler — factory
  methods returning EventHandlers that close over a Func<Post>
  accessor so the watchdogs can be subscribed BEFORE the live
  InformationalEventPost is constructed in the orchestrator's
  onIngressReady hook (the trick that made the inline lambdas hard
  to read).

ServerControlPlane (Runtime/ServerControlPlane.cs):
- BuildDispatcher — constructs the live ServerActionDispatcher with
  its rotate_config / upload_diagnostics / terminate_session callbacks
  and the 60-second synchronous-shutdown wait
- Wire — the EnrollmentOrchestrator.Transport.ServerResponseReceived
  handler that translates DeviceKillSignal / AdminAction / Actions[]
  into ServerActions
- HandleAdminAction, MapAdminOutcome — moved from Program.cs and the
  Program.ServerActionSynthesis.cs partial (which is deleted)

The terminate_session callback's synchronous-shutdown wait, the
dispatcher's reentry short-circuit (Codex Finding 1) and the
admin-outcome mapping (Codex Finding 2) are preserved verbatim with
identical comments. RunAgent's onIngressReady block shrinks from
~135 lines to ~12 lines of factory invocations.

Existing tests (AdminActionOutcomeMappingTests, HandleAdminActionTests,
PostSessionStartedSignalTests) migrated to the new namespace
(global::AutopilotMonitor.Agent.V2.Runtime.* instead of Program.*).

New tests (LifecycleEmittersTests, 9 cases): max-lifetime suppress on
null post, no-emit on non-max-lifetime reason, emit-on-max-lifetime,
auth-threshold signalShutdown side-effect, agent_shutdown event
emission, signalShutdown still fires when post is null,
unrestricted-mode no-op gates (null/unchanged/changed).

Build clean (0 W / 0 E). Full V2.Core suite 766/767 (1 known
RecoveryPathTests threading flake, passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:25:25 +02:00
Oliver Kieselbach 79622515f0 refactor(v2): extract BackendSessionRegistration from Program.RunAgent (PR5)
Phase 6 (POST /api/agent/register-session with retry, outcome-based
exit-code mapping, on-failure mTLS+API-client disposal) moves into
Runtime/BackendSessionRegistration.cs.

SessionRegistrationOutcomeResult is the discriminated outcome — Continue
with the live SessionRegistrationResult (consumed downstream by
PostSessionStartedSignal, PostAdminPreemptionSignal,
WireTelemetryServerResponse, HandleAdminAction) or Exit with the
V1-parity codes (6 = AuthFailed, 7 = anything else; the diag skill
keys on the distinction in Scheduled-Task history).

Disposal of mtlsHttpClient + auth.BackendApiClient on registration
failure is preserved inside the factory, matching the original
inline behaviour. RunAgent's Phase 6 block shrinks from ~20 lines
to 8 lines (factory call + result destructuring).

Tests: 4 new BackendSessionRegistrationTests (Exit-6, Exit-7, Continue,
null-arg validation). Existing SessionRegistrationHelperTests cover
the underlying retry/outcome behaviour. Full V2.Core suite 757/758
(1 known RecoveryPathTests threading flake, passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:02:50 +02:00
Oliver Kieselbach 6b804d38d9 refactor(v2): extract AgentRuntimeConfig from Program.RunAgent (PR4)
Phase 4 (RemoteConfigService.FetchConfigAsync, RemoteConfigMerger.Merge,
AuthFailureTracker ceiling refresh, logger SetLogLevel, SelfUpdater
hash propagation, BinaryIntegrityVerifier check + IntegrityCheckFailed
emergency-report dispatch, BootstrapConfigCleanup H-2 mitigation) moves
into Runtime/AgentRuntimeConfig.cs.

RuntimeConfigBundle exposes the live RemoteConfigService (still needed
later by the rotate_config ServerAction callback), the fetched
AgentConfigResponse and the RemoteConfigMergeResult diff (consumed by
the EmitUnrestrictedModeAuditIfChanged lifecycle hook).

VerifyBinaryIntegrity and TryCleanupBootstrapConfig stay private to
the factory — they are pure side-effect helpers with no return value
needed downstream. The runtime-self-update trigger closure no longer
captures Program.GetAgentVersion via implicit access; it receives the
agent version as a parameter instead.

RunAgent's Phase 4 block shrinks from ~70 lines to 7 lines (factory
call + bundle destructuring). No behavioural change: ordering of
side effects (Merge before UpdateLimits before SetLogLevel before
SelfUpdater hash before VerifyIntegrity before BootstrapCleanup) is
preserved exactly.

Tests: 4 new AgentRuntimeConfigTests (RuntimeConfigBundle property
mapping + 3 null-arg validation cases). Full V2.Core suite 753/754
(1 known RecoveryPathTests threading flake, passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:57:23 +02:00
Oliver Kieselbach 1785854ac7 refactor(v2): extract BackendClientFactory from Program.RunAgent (PR3)
Phase 3 (BackendApiClient + DistressReporter + EmergencyReporter +
AuthFailureTracker + AuthCertificateMissing distress emit) and Phase 5
(mTLS HttpClient + BackendTelemetryUploader) move into
Runtime/BackendClientFactory.cs.

BackendAuthBundle holds the four clients plus the cached
manufacturer/model/serial from HardwareInfo so RunAgent + Phase 5 read
the same hardware identity. TelemetryClientResult is the discriminated
outcome for Phase 5 — Continue with mTLS+uploader, or Exit with the
V1-parity codes (4 = mTLS construction failure, 5 = uploader
construction failure).

Phase 4 (RemoteConfig fetch + merge + binary-integrity + bootstrap-
config cleanup) stays inline in RunAgent for PR4. RunAgent reads the
Phase 3 clients via local aliases so Phase 4 wiring is unchanged.

RunAgent's Phase 3+5 blocks shrink from ~80 lines to ~12 lines of
factory invocations + result destructuring. No behavioural change:
constructor argument lists, distress-emit guard, exit-code mapping
and the disposal-on-failure semantics are preserved.

Tests: 4 new BackendClientFactoryTests (auth-bundle smoke,
no-cert-lookup-on-bootstrap-auth, TelemetryClientResult.Exit,
TelemetryClientResult.Continue). V2.Core 747/750 without the 3 known
EnrollmentOrchestrator threading flakes (pass solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:50:01 +02:00
Oliver Kieselbach 9b9ad61315 refactor(v2): extract AgentBootstrap from Program.RunAgent (PR2)
Phase 1+2 of RunAgent (previous-exit classification, persisted-config
load, TenantId resolution, AgentConfiguration build, SessionId
persistence, completion-marker / emergency-break guards, optional
--await-enrollment cert wait) move into Runtime/AgentBootstrap.cs.

The static helpers (DetectPreviousExit, TryReadBootstrapConfig,
TryReadAwaitEnrollmentConfig, DeleteAwaitEnrollmentConfig,
CheckEnrollmentCompleteMarker, CheckSessionAgeEmergencyBreak,
BuildAgentConfiguration) stay on Program (Program.Guards.cs) and are
called via internal access from the new Runtime namespace.

BootstrapResult is the discriminated outcome — ShouldExit + ExitCode
for the V1-parity early-return paths (0 = guard handled, 2 = no
TenantId, 3 = await timeout), or a Continue payload carrying
AgentConfiguration, SessionIdPersistence, PreviousExitSummary,
IsWhiteGloveResume and the cleanup-service factory.

RunAgent's first ~95 lines collapse to a single AgentBootstrap.Run
call + result destructuring. No behavioural change: every helper
invocation, exit code and side effect (sessionPersistence.Delete on
--new-session, IsWhiteGloveResume snapshot before GetOrCreate,
DeleteAwaitEnrollmentConfig on cert success) is preserved.

Tests: 4 new AgentBootstrapTests (marker-handled exit, age-emergency
exit, continue-with-bootstrap-config, first_run classification);
existing 18 ProgramGuardsTests still pass; full V2.Core suite 745/746
(1 known RecoveryPathTests threading flake).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:39:24 +02:00
Oliver Kieselbach 7abd286774 refactor(v2): split DefaultComponentFactory hosts into Hosts/ folder (PR1)
Mechanical extract: 10 nested ICollectorHost classes moved from
DefaultComponentFactory.cs (1131 LOC) into individual files under
Orchestration/Hosts/. Visibility flips from `private sealed` to
`internal sealed`; namespace stays `Orchestration` so existing
relative type references keep resolving without new usings.

DefaultComponentFactory shrinks to ~280 LOC of pure composition.
No behavioural change: all 11 host constructors, Start/Stop/Dispose
contracts, and the factory's public surface (ImePackageStates,
AllKnownPackageStates, ImeAppTimings, SetTelemetrySpool) are
preserved verbatim.

PR1 of the V2 Runtime-Host slice (Codex P2 finding); follow-up PRs
will extract AgentBootstrap, BackendClientFactory, AgentRuntimeConfig,
BackendSessionRegistration, LifecycleEmitters/ServerControlPlane,
AgentRuntimeHost/TerminationPipeline from Program.RunAgent.

Tests: V2.Core 741 pass + 1 known threading flake (passes on rerun);
Agent.Core 507 pass; DecisionCore 232 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:28:33 +02:00
Oliver Kieselbach a775f3288b fix(v2): apply remote telemetry cadence + spool perf/observability
P1 — UploadIntervalSeconds and MaxBatchSize were merged into agentConfig
by RemoteConfigMerger but never reached the orchestrator: drainInterval
fell back to a hardcoded 30s and TelemetryUploadOrchestrator to its
default batchSize=100. Pass both from agentConfig at construction with
clamped bounds (5..300s, 1..500 items) and log the applied values.

P2 step 1 — TelemetrySpool.Enqueue used FileOptions.WriteThrough +
Flush(true) per item. Make fsync conditional on RequiresImmediateFlush
so periodic snapshot items go through the OS cache only; backend
dedupes via (PartitionKey, RowKey), so the worst-case loss on crash is
a few non-lifecycle items.

P2 step 2 — Replace File.ReadAllLines on every Peek with an in-memory
LinkedList tail cache populated once in the ctor and kept in sync by
Enqueue/MarkUploaded. Disk JSONL stays the source of truth for crash
recovery and diagnostics ZIP forensics; the cache makes drain O(batchSize)
instead of O(N).

Telemetry — surface spool stats (pendingItemCount, peakPendingItemCount,
fileSizeBytes, totalEnqueuedCount) in agent_metrics_snapshot, plumbed
via DefaultComponentFactory.SetTelemetrySpool. Emit a one-shot
spool_pressure_detected event with ImmediateUpload=true when pending
crosses 2000 items or file grows past 5 MB, so upload-stall conditions
become queryable on the backend without waiting for the next snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:25:16 +02:00
Oliver Kieselbach 1d42332c7b fix(v2): count BackendTelemetryUploader requests in NetworkMetrics
Insert a NetworkMetricsRecordingHandler at the head of the mTLS HttpClient
pipeline so every outbound request through MtlsHttpClientFactory.Create
(in particular BackendTelemetryUploader's POST /api/agent/telemetry, which
dominates per-session traffic) is recorded on the shared NetworkMetrics
instance. Previously only the legacy BackendApiClient calls
(config + gather-rules + emergency) called RecordRequest directly, so
agent_metrics_snapshot.net_total_requests undercounted by ~25-30x
(observed: ~2/session in V2 vs ~58/session in V1).

The handler is wired by passing backendApiClient.NetworkMetrics — the same
instance later read by AgentSelfMetricsCollector — into the factory. No
double-count: BackendApiClient builds its own HttpClient and is unaffected
by the mTLS pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:04:17 +02:00
Oliver Kieselbach fdc0e3e8ce fix(v2): normalize timeline event Source values + stop periodic collectors before diag ZIP
Source attribution review on session df7abbc2 surfaced four inconsistencies in the Source field
on the Events table — assembly leakage, V1-parity carry-overs, and a CamelCase / snake_case mix
on the same emitter. Normalize so every Source is a CamelCase class-name or the stable lifecycle
label "Agent":

- Program.cs `agent_started` — "AutopilotMonitor.Agent.V2" -> "Agent" (V1 parity, drops assembly leak)
- ImeLogTrackerAdapter `script_completed` — "IME" -> "ImeLogTracker" (consistent with the seven
  sibling events emitted from the same adapter)
- StartupEnvironmentProbes (5 sites: device_location, agent_trace, timezone_auto_set, two
  ntp_time_check shapes) — "Network" -> "StartupEnvironmentProbes" (class-name consistency;
  eventType already encodes the sub-probe)
- EventTimelineEmitter default — "decision_engine" -> "DecisionEngine" (CamelCase, matches the
  override path used by phase_transition; eliminates the per-event mix on the wire)

Tests adjusted: ImeLogTrackerAdapterTests, StartupEnvironmentProbesTests, EventTimelineEmitterTests,
InformationalEventPostTests. EnrollmentEvent.Source XML doc updated to reflect the new convention.

Same session also showed `performance_snapshot` (seq 220) firing AFTER `diagnostics_collecting`
(seq 219), because periodic collectors keep ticking through the termination handler. Fix:

- New EnrollmentOrchestrator.StopCollectorHosts() — idempotent; the existing Stop() now delegates
  to it so we never double-stop.
- EnrollmentTerminationHandler gains an optional `stopPeripheralCollectors` callback and invokes
  it best-effort right before RunUploadDiagnosticsWithEvents (after DelayLateEventGrace). The
  WhiteGlove Part 1 path returns earlier and is unaffected — the agent stays alive for Part 2.
- Program.cs wires `() => orchestrator.StopCollectorHosts()` so all collector hosts (Performance,
  AgentSelfMetrics, DeliveryOptimization, ESP/Hello, IME log, …) are stopped before the diag ZIP
  is built. Late `performance_snapshot` / `agent_metrics_snapshot` no longer slip in after the
  `diagnostics_collecting` event.

Tests: V2.Core 732/732, DecisionCore 232/232, Backend 736/736.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:02:11 +02:00
Oliver Kieselbach 6937ff73b8 fix(v2): start drain pump at Start() step 3b instead of after collector boot
Symptom: Web UI showed register-session, then ~6.7s of silence, then a wall of
~100 events at once — visibly different from V1's streaming feel. Confirmed
on session 3808befb-…: agent_started enqueued at 18:20:20.5, first
BackendTelemetryUploader flush at 18:20:27.2 with firstId=0 lastId=99 (a
full default 100-item batch).

Root cause: the periodic drain pump was started at Start() step 15, after
collector hosts had spun up. Steps 4-14 do meaningful synchronous work —
gather-rule compile, Hello event-log subscriptions, PowerShell runspace open
in DeliveryOptimizationCollector, ipinfo.io GeoLocation, analyzer scheduling —
which on a constrained VM kept the calling thread (or the ThreadPool) busy
for ~6.7s. During that window the spool accumulated 100 startup signals,
saturating the batchSize cap. Even though immediate-flush wakeups did fire
correctly, they only set a TaskCompletionSource that nobody was awaiting yet.

Fix: move the drain pump start to step 3b, directly after the spool and the
immediate-flush bridge are wired (step 3 + 3a). The pump's loop only
references _spool / _transport (both built at step 3) and the wakeup TCS
(step 3a) — no collector-state, classifier-state, or ingress dependency —
so an early start is safe. An empty Peek returns OK(0) without an HTTP
round-trip, so a pump tick that races ahead of any enqueue is a no-op.

With the pump live from step 3b onward, the very first immediate-flush
wakeup (e.g. agent_started in onIngressReady at step 13b) sets the TCS and
the drain loop's WhenAny returns within milliseconds, then DrainAllAsync's
peek-flush-peek loop drains whatever the spool has accumulated up to that
moment in tight ~100-item batches.

Tests: V2.Core 732/732 green; no behaviour-coupling on the old step 15
position. Solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:06:42 +02:00
Oliver Kieselbach 4de2677a5a fix(v2): 882fef64 followup — restore V1 audit trail on enrollment_complete
V2's reducer-emitted enrollment_complete event landed in the backend with
empty Data{} — V1 had carried a signal audit trail (signalsSeen,
signalTimestamps, completionSource, helloOutcome) that downstream consumers
relied on to reconstruct "which signals drove completion" without re-reading
the agent's local final-status.json.

BuildEnrollmentCompleteEffect now takes (state, completionTrigger) and
attaches a structured TypedPayload. EventTimelineEmitter passes that dict
verbatim into EnrollmentEvent.Data, so the wire shape mirrors V1's audit
trail (plus enrollmentMode/JoinMode from the V2 ScenarioProfile).

Both kernel-driven completion paths now carry the trail:
- DecisionEngine.Classic: DeadlineFired:FinalizingGrace
- DecisionEngine.SelfDeploying: DeviceSetupProvisioningComplete

AdminPreemptionDetected keeps its existing adminAction/source params —
different completion semantics, leave alone.

Tests: DecisionCore 232 (+1), V2.Core 732, V1.Core 507, all green.
Solution build 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:55:53 +02:00
Oliver Kieselbach 8a9a9ffe82 fix(v2): restore V1-parity shadow download_progress on terminal app states
V2's ImeLogTrackerAdapter collapsed V1's parallel-channel emission into one
event per state transition, dropping V1's shadow `download_progress` event
on Installed/Error transitions. The Web UI's DownloadProgress panel relies
on that shadow to close out apps that never produced byte-level progress.

WinGet/Store apps (Company Portal, …) emit no `download_progress` ticks at
all because they have no DO byte data — their row in the panel was stuck on
"started" forever even though the install completed and InstallProgress
showed it as installed.

Restores V1 parity: when MapAppStateToEventType yields Installed/Error,
also emit a shadow `download_progress` (Severity=Debug, ImmediateUpload)
carrying the same payload plus `status=completed/failed`. Frontend logic
(unchanged) then flips the row to complete.

Also caches BuildAppStatePayload across both emissions and constructs the
shadow payload via foreach-copy (net48 lacks the IEnumerable+Comparer
Dictionary ctor).

Tests: V2.Core 732 (+3 — Theory[Installed|Error] + non-terminal guard).
Solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:51:48 +02:00
Oliver Kieselbach 96191c8740 fix(v2): 882fef64 codex review followup — TableStorage force-update CurrentPhase + persist phase package snapshots
Codex review of merged PR3 + PR5 surfaced two correctness gaps that match real-world
session traffic.

Finding 1 — TableStorageService.Sessions.cs force-update path:
The normal status-update path mirrors `update["CurrentPhase"] = Complete` whenever
status flips to Succeeded (line ~1019), so V2 enrollment_complete events (which
carry Phase=Unknown per feedback_phase_strategy) still land at CurrentPhase=Complete.
The ETag-exhaustion / force-update block at line ~1224 was missing the same
override, leaving CurrentPhase stuck at the prior in-flight value (or -1) when
ETag retries are exhausted exactly at the completion update. 6-line fix mirrors
the well-tested normal-path override.

Finding 2 — ImeTrackerStateData.PhasePackageSnapshots persistence:
GetAllKnownPackageStates() returns the union of live _packageStates and the
_phasePackageSnapshots dict (DeviceSetup apps captured before _packageStates.Clear()
on the AccountSetup transition). SaveState only persisted the live list, so an
agent restart between phases — common on hybrid-join + multi-reboot enrollments —
lost the DeviceSetup snapshot and dropped those apps from FinalStatus +
app_tracking_summary. ImeTrackerStateData now carries
`Dictionary<string, List<PackageStateData>> PhasePackageSnapshots`, SaveState
serialises it, LoadState restores it tolerantly (null on old state files →
empty dict, no crash). Refactor side-product: extracted ToPackageStateData /
FromPackageStateData as private static helpers in ImeLogTracker so the mapping
is single-source-of-truth across live + snapshot serialisation.

Skipped:
Finding 3 (DetectHelloPolicy first-match-wins between Device/User CSP scopes)
not fixed — Intune CSPs are exclusive per setting (Device OR User, never both),
CSP wins over GPO, the first-match iteration is correct in practice. Saved
feedback_csp_scope_exclusive.md so a future review doesn't re-suggest the
defensive-aggregation patch.

Tests: V2.Core 729 (+2 new for save/load round-trip + old-snapshot backwards-compat),
DecisionCore 231, Functions 736, vitest 179, solution build 0/0. No new test for
Finding 1's force-update path — the method has no existing test infrastructure
(no Azurite fixture, no mock TableClient); the fix mirrors a well-tested
normal-path override and risk is essentially zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:11:24 +02:00
Oliver Kieselbach d0200d061a fix(v2): 882fef64 PR5 — peripheral logging follow-up (SelfUpdater + AppPackageState + RemoteConfigMerger)
Plan listed five 0-logger-call hotspots; two (RegistryWatcher, VersionCheckEventBuilder)
are already adequately observed via existing call-site logging. The remaining three
got targeted log lines where forensic gaps actually exist:

- SelfUpdater: 4 new entry/success Info lines (start, ZIP downloaded, integrity
  verified, extract succeeded) so the happy-path is no longer silent. The failure
  branches were already logged via WriteSkipMarker / inner helpers.

- AppPackageState: new read-only `WasAutoDowngradedToSkipped` flag set when
  UpdateState reclassifies an Installed-without-prior-download into Skipped via
  the inverse-detection rule. ImeLogTrackerAdapter.EmitAppState's PR3-D4 DEBUG
  line now appends `auto-downgraded=true (inverse-detection)` when the flag is
  set — answers "why is App X marked Skipped without a download" in one line.

- RemoteConfigMerger: pre-overwrite snapshots for 7 operator-relevant knobs
  (SelfDestruct, KeepLog, Reboot, DiagnosticsMode/Enabled, ShowSummary,
  AgentMaxLifetime). One Info line per knob that actually flipped — bounded
  spam (Merge runs ~5min). Existing LogLevel + UnrestrictedMode lines unchanged.

Skipped:
- RegistryWatcher: already exposes Error event + _trace callback wired by both
  callers (AadJoinWatcher, ProvisioningStatusTracker) into _logger.Trace +
  _logger.Warning. No silent path.
- VersionCheckEventBuilder: caller in Program.cs already logs ParseError
  (Warning), Event (Info+outcome), Deduped (Debug). No silent path at the
  caller boundary.

Tests: V2.Core 727, DecisionCore 231, Functions 736, solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:31:09 +02:00
Oliver Kieselbach 07312ee2a3 fix(v2): 882fef64 PR3 — kernel-path observability (A2 + A3 + D1-D5)
Session 882fef64's 12-min agent log carried only `EnrollmentOrchestrator: started.`
and `decision terminal stage reached` between the first event and termination — no
stage-transition trail, no signal cadence, no effect failures. PR3 wires logging
into the kernel hot path so every Decision-Stage transition, every signal accept/
reject, every effect retry/critical-failure, and every IME app state change leaves
a trace at the right severity.

A2 — noise reduction:
- TelemetrySpool `enqueued itemId=...` DEBUG -> VERBOSE (600+ lines/session before)
- IME `impersonation` lines deduplicated by user with 60s rollup

A3 — strengthened existing weak lines:
- `Command line:` shows `(no args)` instead of empty
- `Process exit code: 0` -> `Process completed: <name> -> exit=N, durationMs=T`
- IME session change now carries sessionId+user
- HelloTracker 358/360 suppression includes eventTime+provider
- EnrollmentOrchestrator started/stopping carry sessionId/stage/hosts/signals

D1-D5 — observability into the silent kernel components:
- DecisionStepProcessor: per-step DEBUG (stage transitions + effects) + VERBOSE for no-ops
- SignalIngress: 5 strategic log lines (drop, append-fail, worker-fault, stop summary, processed)
- EffectRunner: 5 strategic log lines (retry, exhausted, critical, anti-loop, classifier throw)
- ImeLogTrackerAdapter: 7 emit-site log lines covering all 7 emission paths
- InformationalEventPost: per-emit DEBUG with eventType/source/immediate/severity/dataKeys

All log statements are null-safe (_logger?.Debug); legacy unit-test rigs without a
logger keep working. New ctor overloads on InformationalEventPost / SignalIngress /
EffectRunner / ImeLogTrackerAdapter are backwards-compatible (logger param defaults
null).

Side fix: BinaryIntegrityVerifierTests pinned to [Collection("SerialThreading")].
Process-global `_runtimeSelfUpdateFired` static raced with parallel mismatch tests;
PR3's logging shifted thread timing enough to surface the race consistently.

Tests: V2.Core 727, DecisionCore 231, Functions 736, vitest 179, solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:20:42 +02:00
Oliver Kieselbach ae330f54d9 fix(v2): 882fef64 PR4 — Hello policy → DecisionEngine + 10s grace + mismatch detector
Surface the WHfB / Hello-for-Business policy fact to the decision engine and use
it to drive the post-ESP-exit Hello wait cadence. Policy=enabled keeps the 30s
default; policy=disabled shortens to 10s as a sanity check against a buggy
detector. A `hello_policy_detection_mismatch` Warning event fires when a Hello
terminal arrives despite policy=disabled — that is the detector-bug indicator.

The completion-gate is intentionally left untouched: completion is orthogonal to
Hello status (the Hello+Desktop AND-gate already accepts any terminal Hello
outcome). A feedback memory is saved so a future iteration doesn't re-add a
completion guard on this fact.

Foundation:
- DecisionSignalKind.HelloPolicyDetected (v1)
- SignalPayloadKeys.HelloEnabled / HelloPolicySource
- DecisionState.HelloPolicyEnabled : SignalFact<bool>? (null = unknown)
- DecisionStateBuilder.WithHelloPolicyEnabled fluent helper
- HandleHelloPolicyDetectedV1 reducer (set-once, no-op on same value, update on
  different value, DeadEnd on missing/unparseable payload)

Behavior:
- HelloTracker.HelloWaitTimeoutDisabledSeconds = 10
- StartHelloWaitTimer picks 10s when policy explicitly disabled, 30s otherwise
- HelloTracker.HelloPolicyDetected event re-raised through EspAndHelloTracker
- EspAndHelloTrackerAdapter posts HelloPolicyDetected signal once per session
- EmitHelloPolicyDetectionMismatch on Hello-terminal arrival under policy=disabled
  (wired into both ProcessHelloEvent and ProcessHelloForBusinessEvent paths)

Tests: DecisionCore 231 (+11), V2.Core 727 (+11), Functions 736, vitest 179,
solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:54:38 +02:00
Oliver Kieselbach f6d8a286ef fix(v2): 882fef64 PR2 — AgentLogger size-cap rotation
Daily log file used to grow without bound under verbose mode. AgentLogger now caps
each segment at 50 MB (configurable per-instance) and rolls forward to
agent_YYYYMMDD_002.log, _003.log, ... when the cap is reached. Existing rotation
suffixes are probed on construction so process restarts mid-day continue at the
highest existing segment instead of overwriting older ones. The first line of every
rotated segment is an `AgentLogger: rotated` marker for forensic readability. Size
check + rotation are best-effort: exceptions are swallowed so the logger never
breaks the agent.

Tests: V2.Core 716 (+5 new in Logging/AgentLoggerTests.cs), solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:03:17 +02:00
Oliver Kieselbach b243706629 fix(v2): 882fef64 PR1 — Company Portal UI, diagnostics ZIP State/Spool, agent_shutting_down V1 parity
- DownloadProgress.tsx: exempt app_download_started from <1024B drop so WinGet/Store
  apps (Company Portal etc.) appear in the UI; filter logic extracted to pure helpers
  in lib/downloadProgressFilters.ts for vitest coverage.
- DiagnosticsPackageService: ZIP now also packages AgentState/ (snapshot, journal,
  signal-log, ime-tracker-state, enrollment-complete.marker, final-status,
  whiteglove-backfill-state + .quarantine), AgentSpool/ (pending uploads + cursor),
  and AgentMarkers/ (top-level whiteglove.complete + clean-exit). BuildArchiveBytes
  extracted as internal seam so xUnit asserts ZIP contents without HTTP.
- EnrollmentTerminationHandler: agent_shutting_down emission moved to top of Handle()
  so it fires unconditionally — including SelfDestruct=false dev VMs and WhiteGlove
  Part-1 exits. Data carries reason/outcome/stage/uptimeMinutes/agentVersion.

Tests: V2.Core 711 (+12), DecisionCore 220, Functions 736, vitest 179 (+11),
solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:59:00 +02:00
Oliver Kieselbach 8b7a5f509e fix(v2): debrief 7dd4e593 followups + final-status schema 2 + V2 dialog renderer
F3 — SummaryDialog launch crash: switch temp dir from C:\WINDOWS\SystemTemp\
(unreadable for standard users when agent runs as SYSTEM) to flat
%ProgramData%\AutopilotMonitor-Summary\ + wipe-on-launch (V1 parity), and
grant BUILTIN\Users delete rights so the dialog's --cleanup self-removal
works.

F4 — Backend phase classification: classify `phase_transition` events
(emitted by V2 DecisionEngine for AppsDevice/AppsUser/FinalizingSetup) as
phase updates so CurrentPhase advances during App phases. Also force
CurrentPhase=Complete on Status=Succeeded to materialise the terminal
phase regardless of the carrier event's Phase value (V2 emits enrollment_
complete with Phase=Unknown per feedback_phase_strategy).

F5 — App tracking summary covers all phases: ImeLogTracker now snapshots
AppPackageState references (not serialized dicts) before _packageStates.
Clear() at the AccountSetup transition, and exposes a deduped union
GetAllKnownPackageStates() for the termination summary path. Without this,
DeviceSetup apps were silently dropped from app_tracking_summary and the
SummaryDialog after the phase transition.

Schema 2 / V2 dialog — final-status.json now carries schemaVersion=2 plus
failureReason, signalTimestamps, and per-app errorPatternId/errorDetail/
errorCode. SummaryDialog branches on schemaVersion: V1 path is the
unchanged legacy renderer (deletable when V1 retires), V2 path renders
6 outcome states (Success / PreProvisioningComplete / TimedOut / Failure
/ Unknown / WhiteGlovePart2), shows a coloured banner with the agent-
supplied failure reason, and per-app rows with duration on the right plus
the IME-pattern error detail under the app name when an install failed.

V1 agent + V1 dialog rendering remain bit-for-bit identical (no schema
field → V1 render path). V1 Agent.Core.Tests 507/507, DecisionCore 220/220.

Tests: V2.Core 702/702 (+12 schema-2 builder, signal-timestamps, failure-
reason, per-app errors, phase-snapshot dedup, launcher path). Backend
736/736. New SummaryDialog.Tests 45/45 (OutcomeMapper + V1/V2 JSON
roundtrip + FormatDuration). Build clean (0 warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:37:31 +02:00
Oliver Kieselbach a523192dc7 backend fix buildtime 2026-04-25 12:26:34 +02:00
Oliver Kieselbach 43ee4d2fd0 fix(v2): first-session regressions — deadline payload key, immediate-flush wakeup, willlaunch noise
Three independent first-session bugs surfaced while debugging session 9ed7021e
(V2 agent v2.0.333, Classic user-driven enrollment):

P0 — FinalizingGrace deadline dead-ended with "deadline_fired_without_name".
The orchestrator's OnDeadlineFired posted a payload using key "deadlineName"
while the reducer reads under SignalPayloadKeys.Deadline ("deadline"). Every
deadline (FinalizingGrace, HelloSafety, DeviceOnlyEspDetection, ClassifierTick,
WhiteGlovePart2Safety) fell through that key-mismatch and was rejected by the
reducer — in production this left every Classic session hanging in
Stage=Finalizing forever, blocking enrollment_complete, the final snapshot, the
terminal drain, and the diagnostics upload. Fix: forward ActiveDeadline.FiresPayload
verbatim so the per-deadline payload its scheduling callsite already populated
reaches the reducer unchanged.

P1 — "30 s silent gap at session start" in the UI. Every lifecycle-critical
item (agent_started, enrollment_type_detected, esp_phase_changed, hello wizard
events, auth-failure shutdown, enrollment_failed on max-lifetime, …) was
enqueued with RequiresImmediateFlush=true but the drain loop never woke up on
it — it blocked the full Task.Delay(_drainInterval) window (30 s by default)
before the first upload fired. Fix: add ITelemetrySpool.ImmediateFlushRequested
event raised on every immediate-flush enqueue, and race the drain loop's delay
against it via Task.WhenAny on a per-iteration TaskCompletionSource. First
post-start upload now happens in milliseconds; periodic cadence is unchanged.

P2 — hello_provisioning_willlaunch/willnotlaunch (EventID 358/360) noise in
the timeline. Per project memory `project_hello_willlaunch_unreliable` these
are snapshot-only prerequisites-passed flags that flip multiple times per
session (session 9ed7021e: 6× 358, three within 232 ms around desktop arrival)
and the DecisionEngine already treats them as non-evidence. Stop emitting
the backend events entirely — keep a single DEBUG log line per observation
so the sequence is still reconstructible from agent-logs during diagnostics.

Tests
- TelemetrySpoolTests: event fires exactly on immediate enqueues.
- RecoveryPathTests: past-due FinalizingGrace deadline advances Finalizing→Completed
  (direct P0 regression guard) + fixed two assertions that encoded the old
  buggy key / stale-log emptiness assumption.
- ScenarioTests.Full_pipeline: asserts enrollment_complete reaches the uploader
  within 10 s — doubles as the P1 end-to-end wakeup guard.
- EnrollmentOrchestratorTests.Stop_flushes_final_snapshot: relaxed "stepIndex
  equals observed" to "snapshot matches stop-time CurrentState + ≥ observed",
  now that deadlines cascade correctly after SessionStarted.
- HelloTrackerImmediateUploadTests: 358/360 assertions flipped from "emitted
  with ImmediateUpload=false" to "not emitted at all".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:58:11 +02:00
Oliver Kieselbach 2cdebaa7fc test(v2): consolidate xUnit tests to Theories + extract shared Harness
Converts repeated Fact-clusters across the V2 Agent and DecisionCore test
suites into data-driven Theories, and extracts two oft-duplicated inner
fixture/rig classes into reusable Harness helpers. Net: smaller files and
more coverage via InlineData rows for edge cases that had no own Fact before.

Wave 1 — Fact→Theory consolidations (10 files):
- RemoteConfigMergerTests: LogLevel parsing (3 Facts → 1 Theory, 9 rows
  including new Whitespace/Empty/Null + case-insensitive combos).
- ImeLogTrackerAdapterTests: EspPhaseChanged phase-mapping (3 Facts →
  1 Theory, 7 rows + Finalizing alias, Complete, preboot).
- InformationalEventPostTests: eventType/source validation + evidenceSummary
  precedence (3 Facts → 2 Theories with 4+4 rows).
- ProgramGuardsTests: CheckEnrollmentCompleteMarker matrix (3 Facts →
  1 Theory with 4 rows incl. new (no-marker, no-selfdestruct) cell).
- AuthFailureTrackerTests: distress dispatch (2 Facts → 1 Theory, 5 rows
  incl. 403 dedup + 500 catch-all).
- SessionIdPersistenceTests: ctor rejection (1 Fact → 3 rows) +
  LoadSessionCreatedAt null-return (2 Facts → 5 rows incl. empty/whitespace
  /out-of-range dates).
- TelemetrySpoolTests: MarkUploaded regression guard (1 Fact, 3 inline
  calls → 1 Theory with 5 rows incl. same-value idempotence).
- ReducerReplayTests: null-arg rejection (1 Fact, 3 asserts → 1 Theory).
- AppInstallSignalHandlerTests: newState → breakdown (4 Facts → 1 Theory,
  7 rows incl. case-insensitive lowercase/uppercase + empty).
- ClassicAwaitingHelloGuardTests: 8 Facts → 2 Theories with 6 rows each
  (full skipUser × accountSetupEntered matrix; 4 new cells).

Wave 2 — Shared Harness extraction (2 new helpers):
- ImeLogTrackerAdapterFixture.cs (60 LOC) replaces 5× duplicated inner
  Fixture classes across ImeLogTrackerAdapter*Tests.cs (~150 LOC saved).
- EnrollmentOrchestratorRig.cs (65 LOC) replaces 6× near-identical Rig/
  Build helpers across EnrollmentOrchestrator*Tests.cs (~200 LOC saved).

Results:
- AutopilotMonitor.Agent.V2.Core.Tests: 679 tests (was 650), all green.
- AutopilotMonitor.DecisionCore.Tests:  220 tests (was 164), all green.
- 22 files changed, +350 / -825 lines (-475 net).
- No production code touched; no V1 code touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:48:02 +02:00
Oliver Kieselbach 511ed70361 v2 codex-post-#51: partial-skipUser guard regression + enum serialization parity
Addresses Codex second-pass on commit 51d57153:

Fix #A (High, behavioral regression) — ShouldTransitionToAwaitingHello in
DecisionEngine.Shared.cs was gated on Profile.EspConfig ∈ {DeviceEspOnly,
NoEsp}, which requires BOTH skipUser + skipDevice observations before the
derived enum leaves Unknown. A realistic bootstrap where only the skipUser
half lands (skipDevice missing) therefore blocked the AwaitingHello
promotion, while the pre-refactor `state.SkipUserEsp?.Value == true` guard
would have unlocked it. The guard now reads the raw half-fact
`ScenarioObservations.SkipUserEsp?.Value` directly, restoring exact legacy
semantics. Profile.EspConfig keeps its "needs both halves" shape — it is a
derived classification for other consumers, not a substitute for the raw
observation here.

Two new regression tests pin the fix:
 - EspConfigDetectedSkipUserTrue_onlyHalfPayload_stillUnblocksAwaitingHello
 - EspExiting_partialSkipUserTrue_armsHelloSafety

Fix #B (Medium, serialization parity) — DecisionCoreJsonSettings did not
register UnknownFallbackEnumConverter<T> for the five new profile enums
(EnrollmentMode, EnrollmentJoinMode, EspConfig, PreProvisioningSide,
ProfileConfidence), so they round-tripped as numeric JSON with no
cross-version fallback. Now registered alongside the other DecisionCore
enums; fallbacks follow each enum's Unknown-equivalent value (None for
PreProvisioningSide, Low for ProfileConfidence, Unknown for the rest).

Six new SerializationTests verify string-form serialization, known-value
round-trip, and unknown-value fallback for each of the five enums plus an
end-to-end DecisionState round-trip through StateSerializer that asserts
the profile dimensions persist as string literals, not integers.

Suites: DecisionCore 211/211 (+8 new tests), Agent.V2.Core 650/650,
Functions 735/735; 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:37:26 +02:00
Oliver Kieselbach 51d57153ad v2 codex-05: typed ScenarioProfile + Observations + ClassifierOutcomes aggregates
Codex review follow-up #5 (Option 2b, clean-cut). Replaces the legacy
9-field hypothesis/fact mosaic on DecisionState with three structured
aggregates that separate concerns cleanly:

- EnrollmentScenarioProfile — derived classification (Mode, JoinMode,
  EspConfig, PreProvisioningSide, Confidence, EvidenceOrdinal, Reason)
  replacing the Hypothesis EnrollmentType + SkipUser/DeviceEsp facts.
- EnrollmentScenarioObservations — raw per-signal evidence
  (ShellCoreWhiteGloveSuccessSeen, WhiteGloveSealingPatternSeen,
  AadUserJoinWithUserObserved, SkipUserEsp, SkipDeviceEsp) with set-once
  semantics, feeding the WhiteGlove sealing classifier snapshot.
- ClassifierOutcomes — classifier verdict + anti-loop storage
  (WhiteGloveSealing, WhiteGlovePart2Completion, DeviceOnlyDeployment)
  replacing the three Hypothesis fields they used to occupy.

EnrollmentScenarioProfileUpdater holds the signal → profile mapping rules
with monotonic-confidence invariants. EspConfigDetected keeps its partial-
first-signal / second-signal-completes semantic via the observations half-
facts; the profile's EspConfig is only derived once both halves are known.

Critical Codex correction: AadUserJoinWithUserObserved is NOT a JoinMode
signal — it is a late-AADJ user-presence hard-excluder for the WhiteGlove
classifier. JoinMode comes strictly from SessionStarted.payload.isHybridJoin.

Classifier algorithm, weights, and thresholds unchanged; only snapshot
input sources, anti-loop verdict storage, and the AwaitingHello guard
migrate. The WhiteGloveSealingSnapshot fields keep their names and types,
so ComputeInputHash stays byte-stable and the anti-loop contract is
preserved across the refactor. AssemblyVersion stays 2.0.0.0.

Suites: DecisionCore 203/203 (+39 new aggregate/updater tests),
Agent.V2.Core 650/650, Functions 735/735; 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:21:11 +02:00
Oliver Kieselbach 1418eacfe9 v2 codex-post-#50-2: rehydrate-failure durable abort + v1 payload contract (#E #F)
Addresses Codex second-pass findings against commit bd3ec4e1:

#E (High) Deadline re-arm failure path was still catch+log-only. If
   _scheduler.RehydrateFromSnapshot throws, DecisionState still claims the
   deadline is active but the live scheduler never registered it — the same
   phantom-deadline hang class already closed for live ScheduleDeadline /
   CancelDeadline failures. The orchestrator now posts an
   EffectInfrastructureFailure signal with the v1 payload contract through
   the already-running ingress, so the reducer terminates the session
   cleanly instead of deferring to the max-lifetime watchdog.

   An IDeadlineScheduler? schedulerOverride constructor parameter was added
   to EnrollmentOrchestrator so tests can drive the failure path with a
   FakeDeadlineScheduler { ThrowOnSchedule = ... }.

#F (Medium) The inline EffectInfrastructureFailure signal synthesised by
   SignalIngress.ProcessInlineAbortSignal only carried `reason` in the
   payload and used a placeholder sourceOrigin. That broke the v1 contract
   documented on DecisionSignalKind.EffectInfrastructureFailure + the
   fixture (effect-infrastructure-failure-v1.json): { reason, failingEffect }
   with sourceOrigin "effectrunner:critical:<EffectKind>" so forensic
   consumers can distinguish ScheduleDeadline-vs-CancelDeadline failures.

   ProcessItem now extracts the critical failure's EffectKind from
   EffectRunResult.Failures (last non-transient entry) and threads it into
   the synthetic signal's sourceOrigin, Evidence.Identifier and payload.

Suite-Baseline all green, 0 warnings:
  DecisionCore 164/164, Agent.V2.Core 650/650, Functions 735/735.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:00:36 +02:00
Oliver Kieselbach bd3ec4e195 v2 codex-post-#50: recovery + abort durability follow-ups (#A #B #C #D)
Addresses Codex review against PR #50:

#A (High) EnrollmentOrchestrator branch (c) now distinguishes empty-log
   (fresh start) from log-has-entries (full-log replay via ReducerReplay)
   so a crash-before-first-snapshot cannot regress state to StepIndex=0
   while the SignalLog preserves older ordinals.

#B (High) EffectInfrastructureFailure is now crash-durable. ApplyStep
   returns EffectRunResult; on SessionMustAbort the phantom snapshot is
   skipped, and SignalIngress synthesises + durably appends + reduces the
   terminal failure signal INLINE within the same worker iteration. The
   old in-memory PostEffectInfrastructureFailureSignal path in EffectRunner
   is removed.

#C (Medium) ReducerReplay.Replay gained an Action<DecisionTransition>?
   onTransition overload. The orchestrator's recovery path truncates
   Journal AHEAD-of-seed phantoms before replay and rematerialises BEHIND
   entries bit-for-bit via the callback, closing the gap between
   SignalLog.Append and Journal.Append crashes.

#D (Medium) ReducerVerifier.ReplaySemantically was seeding DecisionState
   with swapped (tenantId, sessionId) — tests mirrored the bug so it
   slipped through. Args fixed; defensive replay_identity_mismatch issue
   added so future swaps surface as an Error-severity verifier finding.

Suite-Baseline all green, 0 warnings:
  DecisionCore 164/164, Agent.V2.Core 648/648, Functions 735/735.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:39:47 +02:00
Oliver Kieselbach ed1c26f98b v2 codex-03: forward-link events to their originating transition + signal
Closes Codex review follow-up #3. Two distinct traceability gaps:

1. DecisionTransition.EmittedEventSequences was always empty. The
   journal record is appended before the effects run, so at
   construction time the Event.Sequence values haven't been assigned
   yet. Populating it later would need a journal-reorder or a sidecar
   mapping file — both more risk than payoff.

2. SessionTraceOrdinal had two independent meanings in the codebase:
   the reducer-step correlation counter (SessionTraceOrdinalProvider)
   shared by a signal and its transition, vs. the transport-cursor on
   TelemetryItem (== TelemetryItemId when session-scoped). The
   backend parser already prefers the inner (payload) value, but the
   shared name was misleading.

Resolution:

- Forward link instead of backward. EnrollmentEvent gets two new
  nullable columns, CausedByTransitionStepIndex and
  CausedBySignalOrdinal, populated in EventTimelineEmitter.Emit from
  currentState.StepIndex and currentState.LastAppliedSignalOrdinal
  (the reducer already has both, zero new plumbing). Nullable so
  legacy events and any future non-reducer emits stay valid.
  Inspector queries "events emitted by step N" = WHERE
  CausedByTransitionStepIndex = N — trivial index scan.

- DecisionTransition.EmittedEventSequences stays on the type for
  journal backward-compat but the XML doc now states explicitly that
  it is always empty and points readers at the new forward link.

- SessionTraceOrdinal XML docs clarified on DecisionSignal,
  DecisionTransition (by extension), and TelemetryItem: reducer-
  correlation counter is authoritative for payload, transport-cursor
  for envelope, backend parser resolves by preferring inner value.
  No code change — the two counters were already independent in
  practice; the name overload was the whole confusion.

- Backend TableStorageService persists both new columns in
  StoreEventAsync + StoreEventsBatchAsync, reads them in
  MapToEnrollmentEvent. Entity setters guarded by HasValue so
  legacy agent uploads don't write null-as-zero.

Tests (+2 in EventTimelineEmitterTests):
  - Emit populates CausedByTransitionStepIndex + CausedBySignalOrdinal
    from a state with StepIndex=7, LastAppliedSignalOrdinal=12.
  - CausedBySignalOrdinal is null when LastAppliedSignalOrdinal=-1
    (fresh initial state sentinel), avoiding a meaningless -1 on the
    wire.

Suites green: DecisionCore 161/161, Agent.V2.Core 645/645 (+2),
Functions 734/734. Build 0 warnings.

Plan: .claude/plans/codex-03-traceability.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:00:33 +02:00
Oliver Kieselbach 3bfe7d7fdb v2 codex-04: wire AppInstall signals as observation-level facts
Closes Codex review follow-up #4. Before this commit the reducer had
no handler for DecisionSignalKind.AppInstallCompleted /
AppInstallFailed — the signals were real (ImeLogTrackerAdapter posts
one terminal per app) but fell through to HandleUnhandledSignal, so
the journal accumulated 'unhandled_signal_kind:AppInstallCompleted:v1'
dead-ends and the per-app outcomes disappeared. Subsequent ESP / IME
classifiers had no structured evidence of which apps blocked.

Scope: observation-only, as the Master-Tracker recommended. Neither
handler changes Stage or Outcome — they roll the terminal outcome into
a new DecisionState.AppInstallFacts aggregate that:

  - increments CompletedCount (+ Installed / Skipped / Postponed
    breakdown from the adapter's newState payload)
  - increments FailedCount and appends the appId to FailedAppIds,
    deduplicated and capped at MaxFailedAppIds (50) so pathological
    sessions cannot bloat DecisionState.

Every DecisionSignalKind now has at least one handler; the dead-end
path is reachable only through an unknown (kind, schemaVersion) pair
— the genuine forward-compat failure mode per plan §2.5. The
'Still unwired' dispatch comment is replaced with a 'Fall-through:
unknown (kind, schemaVersion)' label.

Tests:
  - new AppInstallSignalHandlerTests (+10): Installed/Skipped/Postponed
    breakdowns, unknown newState forward-compat, no stage/outcome
    change, failed count + appId recording, dedup on repeated appId,
    MaxFailedAppIds cap keeps first-observed entries, mixed-stream
    integration covering all buckets in one fold.
  - HarnessTests.Replay_unknownSignalKind_recordsDeadEnd renamed to
    Replay_unknownSchemaVersion_recordsDeadEnd, switched from the
    now-wired AppInstallCompleted to a v99 SessionStarted so the
    dead-end path stays covered through its real-world trigger.
  - ReducerFailSafeTests.Reduce_unhandledKind same retarget.

Suites green: DecisionCore 161/161 (+10), Agent.V2.Core 643/643,
Functions 734/734. Build 0 warnings.

Fixtures:
  - tests/fixtures/signal-kinds/app-install-completed-v1.json
  - tests/fixtures/signal-kinds/app-install-failed-v1.json

Plan: .claude/plans/codex-04-appinstall-signals-wiring.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:45:52 +02:00
Oliver Kieselbach 8da8dc649d v2 codex-06: ReducerVerifier semantic replay — reducer-agreement CI gate
Closes Codex review follow-up #6. Until now
GetSessionReducerVerificationFunction only ran structural checks
(ordinal contiguity, step-index contiguity, ReducerVersion drift,
orphaned SignalOrdinalRef). That catches corruption but never verifies
the backend's reducer agrees with the agent-produced journal under the
same ReducerVersion — exactly the invariant the whole
(newState, transition, effects) architecture rests on.

Unblocked by Codex #1 phase 1: ReducerReplay.Replay exists in
DecisionCore, and the backend already references DecisionCore.

Flow:
  1. Skip preconditions: drift, ordinal gaps, step-index gaps, empty
     session, or head PayloadJson deserialization failure → record
     SemanticReplaySkipReason + optionally emit an Info 'replay_skipped'
     issue. Never throws.
  2. Deserialise each SignalRecord.PayloadJson via SignalSerializer.
  3. Fold through a transient DecisionEngine: state = Reduce(state, sig).
  4. Compare each produced transition against the stored one on
     (StepIndex, Trigger, FromStage, ToStage, Taken, DeadEndReason).
     Discrepancies surface as 'replay_divergence' issues (capped at
     20 individual entries; overflow message follows the same pattern
     as the structural gap reports).
  5. Compare replayed final state's Stage to the last stored
     transition's ToStage — mismatch surfaces as 'replay_final_stage_mismatch'
     at Error severity.

Report additions (backward-compatible):
  - SemanticReplayPerformed
  - SemanticReplaySkipReason
  - SemanticReplayFinalStageMatches
  - ReplayedFinalStage
  - TransitionDivergenceCount

Side change: GetSessionReducerVerificationFunction XML-doc no longer
claims "structural only" — now documents both layers and the skip
rules.

Tests (+7, ReducerVerifierSemanticReplayTests):
  - Skip on ReducerVersion drift.
  - Skip on signal-ordinal gap.
  - Skip on step-index gap.
  - Perfect agreement (reducer-seeded journal round-trips).
  - Divergence: tampered Trigger → one replay_divergence issue.
  - Divergence: tampered last-ToStage → replay_final_stage_mismatch Error.
  - Mid-stream deserialization failure tolerated without aborting.

Existing structural ReducerVerifierTests unchanged — their stub
PayloadJson='{}' deserialization failures now silently set
SemanticReplaySkipReason='deserialization_failure' without polluting
Issues, preserving the happy-path empty-issues assertion.

Suites green: DecisionCore 151/151, Agent.V2.Core 643/643,
Functions 734/734 (+7). Build 0 warnings.

Plan: .claude/plans/codex-06-reducer-verifier-semantic-replay.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:32:56 +02:00
Oliver Kieselbach 386f3e909f v2 codex-02: EffectRunner posts EffectInfrastructureFailure on critical fail
Closes Codex review follow-up #2. Before this commit, a
DecisionEffectKind.ScheduleDeadline / CancelDeadline failure raised
EffectRunResult.SessionMustAbort but the decision world never heard
about it — DecisionStepProcessor only logged and continued. A safety
deadline could live in DecisionState without ever being armed on the
scheduler, leaving the session to hang until the max-lifetime
watchdog tripped hours later.

Flow now:

  1. EffectRunner.TryRunCriticalDeadlineEffect fails.
  2. EffectRunner posts a synthetic
     DecisionSignalKind.EffectInfrastructureFailure signal onto the
     ingress before returning. Payload: reason + failingEffect.
  3. Ingress worker picks it up next iteration.
  4. HandleEffectInfrastructureFailureV1 (new, in DecisionEngine.Shared.cs)
     transitions Stage=Failed, Outcome=EnrollmentFailed, ClearDeadlines,
     and emits one EmitEventTimelineEntry effect for enrollment_failed
     with the reason from the payload.
  5. DecisionStepProcessor observes the terminal stage and fires the
     Terminated event; EnrollmentOrchestrator tears down normally.

EnrollmentFailed (not Aborted) distinguishes infrastructure-induced
failures from user/operator-initiated aborts in forensics. The ingress
post is wrapped in a try/catch so a full/closed channel cannot crash
the worker — the max-lifetime watchdog remains the last-resort exit.

Changes:
  - DecisionSignalKind: new EffectInfrastructureFailure enum value.
  - DecisionEngine: dispatch entry (v1).
  - DecisionEngine.Shared: HandleEffectInfrastructureFailureV1 handler,
    mirrors HandleSessionAbortedV1 shape but with EnrollmentFailed
    outcome and a failure timeline effect.
  - EffectRunner: PostEffectInfrastructureFailureSignal helper, called
    from the critical-effect path before returning SessionMustAbort.
  - EffectRunResult: XML-doc clarifies that SessionMustAbort is now
    observability-only (reducer terminates on its own).
  - DecisionStepProcessor: comment update — no code change needed, the
    synthetic signal reaches state naturally.
  - tests/fixtures/signal-kinds/effect-infrastructure-failure-v1.json:
    replay fixture per the enum's self-documented contract.

Tests (+8):
  - DecisionCore EffectInfrastructureFailureHandlerTests: transition
    shape, deadline clearing, enrollment_failed emission with reason,
    missing-reason fallback, double-post idempotency.
  - EffectRunnerTests: schedule-fail posts signal with correct payload,
    cancel-fail same, ingress-post exception tolerated.
  - FakeSignalIngressSink: ThrowOnPost toggle for the tolerance test.

All suites green: DecisionCore 144/144 (+5), Agent.V2.Core 633/633
(+3), Functions 727/727. Build 0 warnings.

Plan: .claude/plans/codex-02-effect-failure-to-decision.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:16:32 +02:00
Oliver Kieselbach 96854d20ac v2 codex-01 phase 4: correct ISnapshotPersistence doc reference to ReducerReplay
The interface docstring promised rebuild via "Reducer.Replay(signals)"
but no such method existed until Phase 1. Point readers at the actual
symbol (AutopilotMonitor.DecisionCore.Engine.ReducerReplay.Replay) and
note the seed semantics now that Phase 2 wires tail-replay.

Off-repo artefacts also updated (not committed — tasks/ + ~/.claude/
are local scope):
  - tasks/lessons.md: new entry on recovery-anchor-preservation.
  - .claude/plans/codex-review-followups-master.md: #1 marked Done
    with commit hashes.
  - user memory project_v2_single_rail_in_progress: appended Codex
    follow-up section with phase commits + new invariant.

All suites remain green (build 0 warnings). Next: Finding #2.

Plan: .claude/plans/codex-01-recovery-signallog-replay.md §3 Phase 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:16:31 +02:00
Oliver Kieselbach 5029bdd4c3 v2 codex-01 phase 3: re-arm persisted deadlines after recovery replay
Wires IDeadlineScheduler.RehydrateFromSnapshot (long-present, zero
production callers) into EnrollmentOrchestrator.Start so deadlines
persisted inside DecisionState survive a restart. Without this any
safety-net armed pre-crash (HelloSafety, Part2Safety, FinalizingGrace,
ClassifierTick) silently vanishes and a resumed session can hang until
the max-lifetime watchdog trips.

Placement matters: the rehydrate call MUST run after the Fired event
is subscribed (step 12 in Start) AND after _ingress.Start (step 13)
because (a) past-due deadlines fire immediately via ThreadPool and
would otherwise fall into the void without a subscriber, (b)
OnDeadlineFired returns early when _ingress is null and would drop
the synthesised DeadlineFired signal. Put at step 13.5 just before
the WG Part-2 bridge so rehydrated fires precede fresh collector
signals.

Rehydrate failures are logged at error but do NOT throw — this path
will get a synthetic EffectInfrastructureFailure transition once
Codex follow-up #2 lands, at which point the session can terminate
cleanly instead of relying on the max-lifetime watchdog.

Tests (2 new in RecoveryPathTests):
  - Snapshot_with_past_due_deadline_fires_DeadlineFired_signal_on_start
    — end-to-end: scheduler fires via ThreadPool, bridge posts signal,
      signal reaches the SignalLog within 3s.
  - Snapshot_with_future_deadline_is_scheduled_but_not_yet_fired
    — future-due deadline reports IsScheduled=true, no premature fire.

All suites green: DecisionCore 146/146, Agent.V2.Core 640/640 (+2),
Functions 727/727. Build 0 warnings.

Plan: .claude/plans/codex-01-recovery-signallog-replay.md §3 Phase 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:16:31 +02:00
Oliver Kieselbach 216967e51c v2 codex-01 phase 2: orchestrator recovery replays SignalLog tail + truncates phantom Journal
Wires ReducerReplay.Replay (Phase 1) into EnrollmentOrchestrator.Start so
recovery honours the SignalLog = truth / Snapshot = cache invariant:

  - Snapshot loaded cleanly: replay any SignalLog entries past the
    snapshot's LastAppliedSignalOrdinal onto the snapshot seed. Closes
    the crash window where Journal flushed step N but Snapshot only
    made it to step N-1.

  - Snapshot corrupt: quarantine the snapshot alone and rebuild state
    by replaying the entire SignalLog. Previously the log was moved
    aside together with the snapshot, destroying the only recovery
    anchor. Log-head-corrupt remains the only escalation to
    total-loss quarantine (SegmentQuarantine.QuarantineAll).

  - Post-recovery: if Journal.LastStepIndex >= initialState.StepIndex
    the on-disk tail contains phantom transitions from a pre-crash
    flush. JournalWriter.TruncateAfter(boundary) moves the suffix to
    .quarantine/<ts>/journal-phantom-tail.jsonl and realigns counters.
    Without this the next live Append would fail monotonicity and
    quarantine-escalate the session.

Changes:
  - IJournalWriter: new TruncateAfter(int lastValidStepIndex) method.
  - JournalWriter: clock ctor overload (for deterministic quarantine
    bucket timestamps); TruncateAfter implementation with write-temp +
    atomic rename and forensic phantom-tail preservation.
  - FakeJournalWriter: matching in-memory TruncateAfter.
  - EnrollmentOrchestrator: three-branch recovery (a/b/c) described
    inline, using ReducerReplay.Replay and CollectSignalLogTailAfter.
    Snapshot quarantine now scoped to the snapshot file only unless
    the log is head-corrupt.

Tests:
  - JournalWriterTests: 5 new TruncateAfter cases (drops suffix +
    quarantines, boundary=last is no-op, boundary>last throws,
    boundary=-1 empties file, empty journal no-op).
  - RecoveryPathTests: 3 new integration scenarios (snapshot+pending
    tail, corrupt-snapshot-intact-log, journal-ahead-of-state
    triggers truncate).
  - All suites green: DecisionCore 146/146, Agent.V2.Core 638/638
    (+8), Functions 727/727. Build 0 warnings.

Plan: .claude/plans/codex-01-recovery-signallog-replay.md §3 Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:16:31 +02:00
Oliver Kieselbach 084274629c v2 codex-01 phase 1: ReducerReplay.Replay pure fold + determinism tests
Codex review follow-up #1 (recovery correctness) — lays the foundation
for crash-safe state reconstruction. ReducerReplay.Replay folds a signal
stream through IDecisionEngine.Reduce without side effects: no journal
append, no effect runner, no telemetry. Enforces strict ordinal
monotonicity against the seed state's LastAppliedSignalOrdinal so a
tampered or corrupt log never silently regresses state.

Phase 2 wires this into EnrollmentOrchestrator recovery; Phase 3 adds
post-replay deadline re-arm. Backend Finding #6 (ReducerVerifier
semantic replay) also consumes this.

Tests: 7 new cases in ReducerReplayTests (empty stream, single signal,
multi-signal determinism, snapshot+tail equivalence, ordinal-at-seed
regression guard, mid-stream monotonicity violation, null-arg guards).
All suites green: DecisionCore 146/146, Agent.V2.Core 630/630,
Functions 727/727. Build 0 warnings with TreatWarningsAsErrors.

Plan: .claude/plans/codex-01-recovery-signallog-replay.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:16:31 +02:00
Oliver Kieselbach fef5834ad0 v2 single-rail 13: remove dead Registry-guard in CheckEnrollmentCompleteMarker
The registry-based ghost-restart branch in CheckEnrollmentCompleteMarker
(HKLM\SOFTWARE\AutopilotMonitor\Deployed present + !SessionExists) has
never fired in production and was structurally unreachable:

- session.id is created via SessionIdPersistence.GetOrCreate BEFORE the
  guard runs (both in V1 LoadConfiguration:131 and V2 Program.cs:216), so
  the !SessionExists() retry-cleanup arm is never reachable at runtime.
- Even if the arm were reachable, the Deployed regkey cannot distinguish
  "fresh install" from "ghost restart": it is written by --install and
  intentionally preserved by CleanupService as a bootstrap-script
  reentry lock (read by scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
  Guard 1 to prevent re-install on production devices). Both scenarios
  therefore present as "Deployed present + session.id absent".

Remove the registry-guard block and its dead retry-cleanup path. Keep
the file-based enrollment-complete.marker path intact — it is written
by CheckSessionAgeEmergencyBreak before its own retry-cleanup attempt
and serves the "cleanup-disabled emergency break" exit-cleanly case.

Also drop the now-obsolete "Create SessionId BEFORE guards" justification
comment in Program.cs and the matching boot-sequence doc wording —
neither remaining guard (file-based marker, session-age watchdog) keys
on session.id absence for ghost detection. CheckEnrollmentCompleteMarker
loses the now-unused dataDirectory parameter; callsites updated.

Tests: ProgramGuardsTests.cs keeps marker-absent / marker-without-
selfdestruct / marker-with-selfdestruct-retry coverage; the first-run-
after-install regression test is renamed to match its actual scope
(returns_false_when_marker_file_is_absent). 630/630 V2.Core tests green.

No behavior change — removed path was unreachable. TryWriteDeploymentMarker
in Program.InstallMode.cs stays (writes the bootstrap-reentry-lock);
CleanupService continues to intentionally leave the regkey in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:24:52 +02:00
Oliver Kieselbach 8fe75a0f4e v2 single-rail 12: first-session-fixes PR 4 — data completeness + operability (Fixes 4+5)
Closes the "final-status.json shows only 3 apps", "every app ~30 s" and
"agent log silent during uploads" gaps observed in session 30410cd7.

Fix 4c — Per-app install-lifecycle timing. New AppInstallTiming model
(StartedAtUtc / CompletedAtUtc / DurationSeconds). ImeLogTrackerAdapter
tracks timing in a set-once dictionary (first Downloading/Installing/
InProgress stamps StartedAt; first terminal stamps CompletedAt) and
exposes it via a new public AppTimings snapshot. app_install_* DataJson
carries startedAt / completedAt / durationSeconds when known; omitted
otherwise so the UI can distinguish "not tracked" from "stamp=epoch".

Fix 4a — FinalStatusBuilder uses timings. Optional appTimings param on
Build(); FinalStatusPackageInfo gets StartedAt / CompletedAt /
DurationSeconds with NullValueHandling.Ignore. Existing per-targeted
phase bucketing preserved — that was correct.

Fix 4b — app_tracking_summary terminal event. EnrollmentTerminationHandler.
EmitAppTrackingSummary runs once at termination before diagnostics upload
with aggregate counts, per-phase breakdown, and per-app records
(appId/name/phase/finalState/optional timing). ImmediateUpload=true,
Phase=Unknown (feedback_phase_strategy). Skipped on WhiteGlove Part 1
(apps haven't installed yet).

Fix 5 — Upload-cadence logging. BackendTelemetryUploader + TelemetrySpool
gained optional AgentLogger params. Uploader logs INFO flushing/ingest OK,
WARN ingest TRANSIENT (5xx/timeout/network), ERROR ingest PERMANENT (4xx),
with items + durationMs. Spool logs DEBUG per-enqueue with kind + immediate
flag + pending count. Empty-batch path stays silent (short-circuit).

Plumbing: ImeLogHost.AppTimings → DefaultComponentFactory.ImeAppTimings →
Program.cs wires appTimingsAccessor into EnrollmentTerminationHandler;
logger flows into uploader (Program.cs) and spool (EnrollmentOrchestrator).
All new ctor params are nullable-default → zero breakage for existing
call sites and tests.

Tests: +18 new (7 adapter timing, 2 builder timing, 5 handler summary,
4 uploader logging). DecisionCore 139, Agent.V2.Core 630, Functions 727,
Web vitest 168 — all green. Web typecheck clean. Solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:24:04 +02:00
Oliver Kieselbach 017c7ca1cd v2 single-rail 11: first-session-fixes PR 3 — phase tagging + Finalizing stage (Fixes 2+6)
Fix 6 — Finalizing pseudo-stage with ~5 s grace. New non-terminal
SessionStage.Finalizing between "both prerequisites resolved" and Completed
+ DeadlineNames.FinalizingGrace. HandleHelloResolvedV1 / HandleDesktopArrivedV1
/ HandleHelloSafetyDeadlineFired (desktop-already-arrived branch) now route
through a shared TransitionToFinalizing helper instead of jumping straight to
Completed — the helper arms the 5 s deadline and emits a phase_transition
declaration effect (Phase=FinalizingSetup). HandleFinalizingGraceDeadlineFired
transitions Finalizing → Completed + enrollment_complete. IsTerminal()
deliberately unchanged so DecisionStepProcessor.OnDecisionTerminalStage (and
therefore EnrollmentTerminationHandler) fires only on the real terminal, not
on Finalizing — giving the reducer-emitted phase_transition +
enrollment_complete headroom to reach the backend before spool drain.

Fix 2 — Sub-phase declarations AppsDevice / AppsUser. ImeLogTrackerAdapter
emits a phase_transition InformationalEvent on the first app activity in a
given ESP phase (DeviceSetup → AppsDevice, AccountSetup → AppsUser). Fire-once
per ESP phase, ImmediateUpload=true, respects feedback_phase_strategy (only
phase_transition / esp_phase_changed / agent_started may carry Phase != Unknown).
FinalizingSetup + unmapped ESP phases short-circuit — the reducer owns the
FinalizingSetup declaration (see Fix 6) and adapter mustn't duplicate.

Test harness race-safety. PostFixture in the integration fixture had a latent
race where ApproximateQueueLength==0 + "stepIndex > stepIndexBefore" could be
met while the actually-posted signal was still executing ApplyStep. Replaced
with authoritative LastAppliedSignalOrdinal >= signalLog.LastOrdinal watermark.
ReplayHarness + ClassifierAwareReplayHarness + PostFixture all auto-fire a
synthetic DeadlineFired:finalizing_grace at end-of-stream when the reducer is
parked in Finalizing — fixtures stay unchanged.

Tests: +12 new (6 DecisionCore FinalizingStageTests, 6 ImeLogTrackerAdapter
SubPhaseDeclarationTests). DecisionCore 139, Agent.V2.Core 612, Functions 727,
Web vitest 168 — all green. Web typecheck clean. Solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:58:57 +02:00
Oliver Kieselbach 6fec3e2b0b v2 single-rail 10: first-session-fixes PR 2 — event observability (Fixes 1+3)
Fix 1 — ImmediateUpload=true on UX-critical lifecycle events. Policy driven
by UI responsiveness, not "lifecycle-sounding":
 * ImeLogTrackerAdapter: esp_phase_changed, all app-state transitions
   (started/completed/failed/download-started + download_progress live bar),
   do_telemetry flip to immediate. download_progress ticks every 3s only
   while a download is active (bounded by download duration).
 * HelloTracker: hello_policy_detected flips to immediate. Existing terminal
   set (shouldTriggerHelloCompleted / hello_provisioning_failed) unchanged.
   willlaunch/willnotlaunch/pin_status stay batched — snapshot flags that
   flip multiple times per session (project_hello_willlaunch_unreliable).
 * ShellCoreTracker + reducer-synthesised terminals were already immediate.

Fix 3 — desktop_arrived InformationalEvent. DesktopArrivalDetectorAdapter
now dual-emits: existing DecisionSignalKind.DesktopArrived(Part2) for the
reducer AND SharedEventTypes.DesktopArrived informational event for the
Events table / UI timeline / MCP query_raw_events. Fire-once across both
rails. Payload carries detectedAt / detectionSource / part2Mode.

Tests: +20 xUnit cases. DecisionCore 133, Agent.V2.Core 606, Functions 727,
Web vitest 168 — all green. Solution build 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:11:48 +02:00
Oliver Kieselbach 7b64f98e63 v2 single-rail 9: soft admin-action semantics + shutdown-race fixes
Addresses two tasks/todo.md entries from 2026-04-23:
  - Soft Admin-Action Semantik + SignalIngress-Race-Fix
  - Backend AdminAction-Trigger fix

Backend — authoritative AdminMarkedAction field:
  New Session.AdminMarkedAction property (null | "Succeeded" | "Failed")
  replaces the fragile heuristic "Status is terminal AND current event is not
  a completion marker", which fired falsely on every post-completion event
  the agent sent (agent_shutting_down, diagnostics_uploaded,
  enrollment_summary_shown). The backend's AdminAction response to /register
  and /ingest now reads the explicit field. ISessionRepository +
  TableSessionRepository + TableStorageService.Sessions +
  Ingest{Events,Telemetry}Function + MarkSession{Succeeded,Failed}Function +
  RegisterSessionFunction + EventIngestProcessor all updated to set / clear /
  read the new field coherently.

Agent — Program.cs WireTelemetryServerResponse refactor:
  Routes TelemetryUploadOrchestrator.ServerResponseReceived through three
  distinct semantic paths instead of one terminate_session synthesis:
    1. DeviceKillSignal  -> terminate_session with forceSelfDestruct=true
                            (hard admin kill — overrides SelfDestructOnComplete).
    2. AdminAction=Succeeded -> single admin_marked_session timeline entry
                                 (informational; agent continues own path).
    3. AdminAction=Failed -> EnrollmentTerminationHandler.Handle(outcome=Failed)
                              (soft shutdown; same analyzers/diagnostics/summary
                              as a locally-detected failure).
  Plus a shutdown-race guard: if shutdownComplete is already set, short-circuit
  instead of crashing deep in the dispatcher with an InvalidOperationException
  from a disposed SignalIngress. Backed by new HandleAdminActionTests.

Agent — ServerActionDispatcher ingress-disposed race:
  Added InvalidOperationException swallow around InformationalEventPost.Emit
  for the narrow window between SignalIngress.Stop and
  TelemetryUploadOrchestrator.DrainAllAsync completion. Dropped events are
  acceptable during teardown; the alternative was a nested Emit failure that
  corrupted the outer termination sequence. Backed by new
  ServerActionDispatcherTests case.

Tests all green: DecisionCore 133, Agent.V2.Core 586, Functions 727.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:35:36 +02:00
Oliver Kieselbach 0c58465095 v2 single-rail 8.1: codex PR-1 pass-1 — SkipUser race + late-fill fix
Codex static review of df2133cb flagged two defects:

Hoch — EspConfigDetected race with Shell-Core esp_exiting.
  Pre-fix: DeviceInfoHost.CollectAll runs fire-and-forget on ThreadPool, so on
  SkipUser=true (device-only) enrollments the tracker's Shell-Core esp_exiting
  forward to EspPhaseChanged(FinalizingSetup) could arrive before the reducer
  had seen EspConfigDetected. Fix 8's ShouldTransitionToAwaitingHello guard
  would then block the legitimate AwaitingHello promotion because SkipUserEsp
  was still null. The adapter's _finalizingPosted fire-once flag makes this
  unrecoverable — device-only sessions would get stuck in EspDeviceSetup with
  no HelloSafety arm, forever.

Fix: EnrollmentOrchestrator.PostEspConfigDetectedBootstrap reads
  HKLM\...\FirstSync synchronously in a new step 13c, BEFORE any collector
  host starts. Runs unconditionally — production correctness > test
  convenience. Tests use TestAssemblyInit (ModuleInitializer) to force the
  probe to (null, null) assembly-wide so existing signal/transition counts
  stay stable; the dedicated bootstrap tests wrap themselves in
  EspSkipConfigurationProbe.ScopedOverride to re-enable deterministic values.

Mittel — EspConfigDetected late-fill path blocked by instance dedup.
  Pre-fix: DeviceInfoCollector._espConfigSignalPosted fire-once prevented any
  later CollectAll / CollectAtEnrollmentStart call from posting the signal a
  second time with values missing from the first read (e.g. FirstSync key
  half-populated at session start, other half written later). The reducer's
  per-fact set-once already handles duplicate posts safely.

Fix: Removed _espConfigSignalPosted; dedup lives exclusively in
  HandleEspConfigDetectedV1's per-fact "if null then set" logic. Multiple
  posts with partial payloads compose correctly to fill both facts.

Tests (all green):
  - DecisionCore xUnit: 133 pass (+2 ordering-contract tests in
    ClassicAwaitingHelloGuardTests).
  - Agent.V2.Core xUnit: 586 pass (+4 EnrollmentOrchestratorEspConfigBootstrap
    tests; assembly-wide TestAssemblyInit module initializer neutralizes the
    probe for existing tests that count signals).
  - Functions xUnit: 727 pass (regression check).
  - Solution build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:28:56 +02:00
Oliver Kieselbach df2133cbb3 v2 single-rail 8: first-session-fixes PR 1 — reducer correctness (Fixes 9/7/10/8)
Addresses the premature AwaitingHello + HelloSafety-baseline pathology observed in
session 30410cd7: on Classic V1 (SkipUser=false) enrollments the Device-ESP exit was
treated as the final ESP exit, arming HelloSafety from the wrong timestamp.

Fix 9 (foundation):
  - New DecisionSignalKind.EspConfigDetected + DecisionState.SkipUserEsp / SkipDeviceEsp
    facts with set-once semantics (per-fact, not per-signal).
  - Shared EspSkipConfigurationProbe reads HKLM\...\FirstSync once per tracker lifetime.
  - DeviceInfoCollector posts the signal alongside the esp_config_detected event.

Fix 7 (tracker guard — primary cut):
  - EspAndHelloTracker swallows the esp_exiting forward when SkipUser=false AND no
    AccountSetup activity yet. Injectable probes as test seams.

Fix 10 (deadline safety net):
  - HandleEspPhaseChangedV1(AccountSetup) cancels HelloSafety whenever the stage is
    bouncing back from AwaitingHello.

Fix 8 (reducer guards — belt-and-suspenders):
  - New ShouldTransitionToAwaitingHello helper: true only when AccountSetup entered OR
    SkipUser explicitly true.
  - HandleEspPhaseChangedV1(FinalizingSetup) + HandleEspExitingV1 gate the AwaitingHello
    promotion and HelloSafety arm behind the helper; observability facts still recorded
    on the suppressed path.

Tests (all green):
  - DecisionCore xUnit: 131 pass (+10 new — EspConfigDetectedHandlerTests 9,
    HelloSafetyBounceCancelTests 2, ClassicAwaitingHelloGuardTests 8).
  - Agent.V2.Core xUnit: 582 pass (+6 new — EspAndHelloTrackerFinalizingGuardTests).
  - Functions xUnit: 727 pass (regression check).
  - Solution build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:45:25 +02:00
Oliver Kieselbach 258f18d3dd v2 single-rail 7.1: codex pass-2 — preserve JSON null values in TypedPayload round-trip
Medium — SignalSerializer.Deserialize coerced JSON null values inside
TypedPayload to string.Empty, breaking wire/replay parity for the new
single-rail path. The comment promised "cast null tokens to null objects"
but the code did the opposite.

Realistic producers like DeviceInfoCollector place nullable fields (e.g.
displayVersion returned by GetOsDisplayVersion() when the registry key
is absent) directly into EnrollmentEvent.Data. Live Newtonsoft emits
{"displayVersion":null}; under the pre-fix deserializer, replay turned
that into {"displayVersion":""} on the next outbound Emit.

Fix: preserve JSON null as C# null when restoring TypedPayload dict
entries. Dictionary<string, object> accepts null values at runtime and
Newtonsoft re-serializes them as JSON null — wire-parity restored.
Other JToken types (JValue non-null, JArray, JObject) continue to pass
through unchanged.

Regression test: SignalSerializer_roundtrip_null_value_in_TypedPayload_stays_null_not_empty_string
  - on-disk JSON contains "displayVersion":null (not "")
  - restored dict carries C# null (not "")
  - re-serialized restored dict still emits JSON null (wire-parity)
  - sibling non-null values untouched

Tests: 567 V2.Core + 112 DecisionCore green (+1 new regression test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:26:58 +02:00
Oliver Kieselbach 92d300d4c2 v2 single-rail 7: codex-review feedback — 4 findings fixed
Addresses all 4 findings from the Codex review on PR #48. 564 V2.Core +
111 DecisionCore tests green (up from 559 + 109; 7 new regression tests).

Finding 1 (P1) — terminate_session drain deadlock
  TelemetryUploadOrchestrator raised ServerResponseReceived synchronously
  while holding _drainGuard. The terminate callback then invoked
  orchestrator.Stop() on the main thread, which tried a terminal
  DrainAllAsync and blocked on the same guard — timing out before the
  final agent_shutting_down event could be uploaded.
  Fix: buffer signal-bearing UploadResults during drain, release the
  guard, then raise events. Also added a defensive re-entry short-circuit
  in onTerminateRequested (skips if shutdown.IsSet) for the pathological
  case of a nested drain producing a different ActionId.
  Files: TelemetryUploadOrchestrator.cs, Program.cs
  Test: Handler_may_invoke_reentrant_drain_without_deadlock

Finding 2 (P1) — structured Event.Data was degenerating to type-name strings
  InformationalEventPost.StringifyData fell through to kv.Value.ToString()
  for nested containers, so e.g. network_adapters.adapters became the
  literal string "System.Collections.Generic.List\`1[...]" on the wire.
  Fix (redesigned after review): typed-sidecar through the bus, mirroring
  the existing DecisionEffect.ClassifierSnapshot pattern.
    DecisionSignal.TypedPayload (object?)   ← new
    DecisionEffect.TypedPayload (object?)   ← new
    ISignalIngressSink.Post(..., typedPayload)
    EventTimelineEmitter.Emit(..., typedPayload) uses it as Data verbatim
  Live path does ZERO serialization for structured event data — the
  Dictionary<string,object> flows object-identity-preserving from
  collector to TelemetryEventEmitter, where JsonConvert.SerializeObject
  runs once at the wire boundary. Persistence writes TypedPayload to
  JSONL once at the disk boundary (SignalSerializer); replay restores
  it as JObject/JArray tokens that re-serialize identically.
  Files: DecisionSignal.cs, DecisionEffect.cs, DecisionEngine.Shared.cs,
    SignalSerializer.cs, ISignalIngressSink.cs, SignalIngress.cs,
    EnrollmentOrchestrator.cs (LazyIngressSinkRelay), IEventTimelineEmitter.cs,
    EventTimelineEmitter.cs, EffectRunner.cs, InformationalEventPost.cs,
    + FakeSignalIngressSink, FakeEventTimelineEmitter test doubles
  Tests: TypedPayload_dictionary_becomes_EnrollmentEvent_Data_verbatim_on_wire,
    TypedPayload_missing_falls_back_to_string_parameter_reconstruction,
    Emit_EnrollmentEvent_overload_forwards_Data_as_typed_payload_by_reference,
    Emit_EnrollmentEvent_overload_passes_DateTime_as_live_object_through_typed_payload,
    SignalSerializer_roundtrip_preserves_TypedPayload_structure,
    SignalSerializer_roundtrip_null_TypedPayload_stays_null

Finding 3 (P2) — ImmediateUpload=false silently flipped to true
  Helper wrote the ImmediateUpload payload key only when true; the emitter
  defaulted "missing key → true". Net effect: a caller passing
  immediateUpload:false (e.g. performance_snapshot, agent_metrics_snapshot)
  had their intent silently overridden; every snapshot was flushed
  immediately.
  Fix: helper now writes the key both-ways ("true" or "false"). Emitter
  default stays true because reducer-synthesised terminal events
  (whiteglove_complete, enrollment_complete, enrollment_failed) construct
  effect parameters with just eventType and rely on that default for
  immediate flush — documented inline.
  Files: InformationalEventPost.cs, EventTimelineEmitter.cs (doc-only)
  Tests: ImmediateUpload_false_writes_explicit_false_key_to_payload,
    Emit_optional_payload_keys_are_set_only_when_caller_provides_them (updated)

Finding 4 (P2) — PeriodicCollectorLifecycleHost saw only its own posts
  IdleActivityObservingIngressSink wrapped the ingress per-host, so
  _lastRealEventTimeUtc was only updated by signals routed through this
  host's own _post. Every other component (Program.cs lifecycle, IME,
  ESP, DeviceInfo, Analyzer, Gather, StallProbe, Network, AAD, Desktop,
  etc.) constructs its own InformationalEventPost against the raw shared
  ingress and bypassed the wrapper — and periodic events posted by the
  host itself were correctly filtered out. Result: the idle clock never
  advanced past session start; collectors stopped after idle timeout
  regardless of live enrollment activity.
  Fix: moved observation into SignalIngress itself as an event
  SignalPosted, fired after every successful enqueue (fast path + slow
  path). PeriodicCollectorLifecycleHost subscribes via the concrete
  SignalIngress type at Start, unsubscribes at Stop. The observer now
  sees ALL signal kinds (ESP, IME, Classifier, DeadlineFired, etc.), not
  just InformationalEvents. Deleted the obsolete wrapper class.
  Files: SignalIngress.cs, DefaultComponentFactory.cs
  Tests: SignalPosted_fires_for_every_successful_post_regardless_of_kind,
    SignalPosted_handler_exception_does_not_break_ingress,
    SignalPosted_is_raised_even_when_channel_took_slow_path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:58:14 +02:00
Oliver Kieselbach 7c29b607dc v2 single-rail 6.1: guard DeviceSetupProvisioningComplete terminal on Classic path
HandleDeviceSetupProvisioningCompleteV1 completed unconditionally — in session
e259c121-dc13-46d6-8e96-118f1da9845e (2026-04-22, Classic UserDriven-v1) a late
DeviceSetupProvisioningComplete with payload deviceSetupResolved="unknown" fired
AFTER AccountSetup had started and DesktopArrived had been observed, but BEFORE
HelloResolved — terminating the session at Stage=Completed before Hello even ran.

Guard: when state.AccountSetupEnteredUtc != null, treat the signal as informational
only (step index + ordinal advance, stage unchanged, no completion effect). On the
Classic path, terminal completion still requires the Hello+Desktop AND-gate
(HandleHelloResolvedV1 / HandleDesktopArrivedV1). The original SelfDeploying /
Device-Only terminal semantics are preserved for AccountSetupEnteredUtc == null.

The redundant AccountSetupEnteredUtc == null check around the EnrollmentType
upgrade is now implicit and removed.

Regression coverage:
- New fixture tests/fixtures/enrollment-sessions/userdriven-late-provisioning-complete-v1.jsonl
  reproduces the 6-signal sequence from the reference session.
- New test ClassicScenarioTests.LateProvisioningComplete_afterAccountSetup_doesNotCompletePrematurely
  asserts terminal trigger == HelloResolved, DeviceSetupProvisioningComplete transition
  is taken but stage-unchanged, and DeviceOnlyDeployment hypothesis stays Unknown.

Tests: 109/109 DecisionCore + 559/559 V2.Core green; solution builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:09:14 +02:00
Oliver Kieselbach 877523594f v2 single-rail 5.10: enforce single-rail — internalize emitters + shrink baseline to 2 callers
Plan §5.10 — final PR of the single-rail refactor. Rail B is gone; Invariant 1
(single caller set) + Invariant 2 (compile-time enforcement) are now active.

Classes internalized:
- TelemetryEventEmitter (public sealed → internal sealed; Emit → internal)
- EventTimelineEmitter (public sealed → internal sealed)
- BackPressureEventObserver (public sealed → internal sealed)
Consumers of these three live in the same Telemetry.Events namespace (plus
InternalsVisibleTo for the test assembly). Any future attempt to construct or
reference them from outside the assembly becomes a compile error — the
single-rail invariant is now load-bearing on the type system, not review.

EnrollmentOrchestrator scrubbed:
- Deleted the `public TelemetryEventEmitter EventEmitter` property (no external
  consumer after PR #1–#9).
- Deleted the `BuildTelemetryEventSink()` Action<EnrollmentEvent> factory
  (last caller removed in step 14 of Start, where it wired collectors; those
  now post via ISignalIngressSink exclusively).
- Dropped the `_eventEmitter` field — the emitter is built as a local in Start
  and handed to EventTimelineEmitter + BackPressureEventObserver inline. After
  Start returns, nothing in the orchestrator references it. Architecture
  baseline test shrinks by one as a direct consequence.

IComponentFactory cleanup:
- Removed `Action<EnrollmentEvent> onEnrollmentEvent` parameter from
  CreateCollectorHosts (interface + DefaultComponentFactory + call-site in
  EnrollmentOrchestrator.Start).
- DefaultComponentFactory.ImeLogHost constructor drops the now-dead
  onEnrollmentEvent parameter (declared but never used — relic from the
  pre-migration wire).
- ICollectorHost + IComponentFactory doc comments updated to reflect the new
  "post-via-ingress" contract.

Gather-rule internals (GatherRuleExecutor, GatherRuleContext, CommandCollector,
LogParserCollector) keep their internal Action<EnrollmentEvent> signature —
they are the orthogonal standalone CLI mode (plan §9), not a kernel path; the
session-mode GatherRuleExecutorHost already bridges the Action to an
InformationalEventPost in PR #6.

Architecture baseline test:
- `TelemetryEventEmitterCallersBaselineTests` promoted from transitional
  baseline (3 dependents) to strict enforcement: exactly the two permitted
  callers EventTimelineEmitter + BackPressureEventObserver. A third entry now
  fails the build — the review signal for any reintroduced bypass is loud.
- Dropped the `Current_baseline_is_a_superset_of_the_PR10_permitted_callers`
  sanity test (redundant once the enforcement set IS the permitted set).

Test fixtures updated:
- EnrollmentOrchestratorEventBridgeTests rewritten — the "Action<EnrollmentEvent>
  bridge" contract the old tests exercised is gone, so the tests covering it
  (Emit-from-host-reaches-transport, emitter-exception-does-not-propagate etc.)
  were removed. Remaining coverage: Start without factory, Start with factory
  spawns + starts hosts, factory receives ingress + clock, Stop stops + disposes
  hosts.
- EnrollmentOrchestratorWhiteGlovePatternTests fake factory updated to the
  new IComponentFactory signature.
- EnrollmentOrchestratorTests + EnrollmentOrchestratorOnIngressReadyTests:
  removed the `sut.EventEmitter` / `orchestrator.EventEmitter` assertions —
  the property no longer exists.

Verification: dotnet build AutopilotMonitor.sln clean; V2.Core test suite
559 / 559 green (down from 562 — 3 obsolete bridge tests removed, 0 new
failures); DecisionCore 108 / 108 green.

Soll-Zustand per plan §8 now holds in code:
- Single-rail Emission: exactly 2 callers of TelemetryEventEmitter.Emit.
- Compile-time enforcement: emitter class + method are internal; any bypass
  attempt breaks the build instead of slipping through review.
- No Action<EnrollmentEvent> parameter on ICollectorHost / IComponentFactory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:35:14 +02:00