Commit Graph

219 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 b7f74dbd35 v2 single-rail 5.9a: ImeLogTrackerAdapter add InformationalEvent posts (B29 part 1)
Plan §5.9 / PR #9 — V1 Parity Issue #3: the IME log tracker callbacks now
dual-emit a specific DecisionSignalKind + an InformationalEvent so the Events
table regains the V1-compatible wire shape (esp_phase_changed,
ime_user_session_completed, app_install_started, app_install_completed,
app_install_failed, app_download_started, download_progress) while remaining
on the single-rail signal pipeline.

Changes:
- ImeLogTrackerAdapter: construct an InformationalEventPost from the existing
  ingress/clock; after every EspPhaseChanged / UserSessionCompleted /
  AppStateChanged post, also emit a matching InformationalEvent. EspPhase
  declarations carry the mapped EnrollmentPhase (DeviceSetup / AccountSetup /
  FinalizingSetup) per feedback_phase_strategy.
- AppState handler expanded from terminal-only to all state transitions with
  V1-compatible eventType mapping: Downloading transitions → app_download_started
  + byte updates → download_progress (Debug); Installing/InProgress →
  app_install_started; Installed/Skipped/Postponed → app_install_completed;
  Error → app_install_failed. Payload carries appId/appName/state/intent/
  targeted/runAs/progressPercent/bytesDownloaded/bytesTotal/isError/isCompleted
  + appVersion/appType/detectionResult/attempt, plus error* fields on Error.
- Shared.Constants.EventTypes: add EspPhaseChanged, ImeAgentVersion,
  ImeUserSessionCompleted, DoTelemetry, AllAppsCompleted.
- Adapter tests: refactor to filter the posted-signal list by kind + eventType
  so the dual-emission contract is verifiable. 14 new tests cover the full
  state-transition matrix + payload shape + error-field population; total
  adapter test count 37 (up from 17).

Verification: dotnet build AutopilotMonitor.sln clean; V2.Core test suite
green (552 / 552, up from 538).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:45:29 +02:00
Oliver Kieselbach 491c749688 v2 single-rail 5.3b: migrate EnrollmentTerminationHandler + agent_shutting_down event
Plan §5.3 + §6.2 — EnrollmentTerminationHandler now emits the six
peripheral lifecycle events (enrollment_summary_shown,
diagnostics_collecting/uploaded/upload_failed, reboot_triggered,
whiteglove_part1_complete) through InformationalEventPost instead of the
legacy Action<EnrollmentEvent> bypass.

New acknowledgement: `agent_shutting_down` fires with ImmediateUpload=true
just before CleanupService.ExecuteSelfDestruct. This gives the backend a
definitive "agent accepted termination" signal so it can stop re-queuing
terminate_session even when the cleanup script wins the race against the
final telemetry drain.

Program.cs: EnrollmentTerminationHandler construction moved into
orchestrator.Start's onIngressReady hook (alongside ServerActionDispatcher)
so the live lifecyclePost backs it. The Terminated-event subscription uses
a null-checking dispatch wrapper so the registration can sit before Start
returns without referencing the not-yet-constructed handler; by the time
the decision loop processes its first signal, the hook has run
synchronously and the handler is live.

Constants.EventTypes.AgentShuttingDown = "agent_shutting_down" added.

Tests: EnrollmentTerminationHandlerTests refactored — Rig now creates a
real InformationalEventPost backed by FakeSignalIngressSink; assertions
consult EmittedEventTypes / PayloadOf(eventType) helpers that read the
posted signal payloads. Three new tests cover
 - agent_shutting_down emitted on self-destruct path with correct payload
 - skipped when SelfDestructOnComplete=false
 - skipped on WhiteGlove Part-1 exit (no self-destruct)

V2.Core: 535 → 538, DecisionCore: 108, all green.
Architecture baseline unchanged — EnrollmentTerminationHandler now takes
InformationalEventPost, no structural reference to TelemetryEventEmitter.
2026-04-23 13:58:16 +02:00
Oliver Kieselbach d1b8be12de v2 single-rail 4.1b: HandleInformationalEventV1 reducer + tests
Wire the InformationalEvent signal through the decision engine:

- New dispatch entry (InformationalEvent, 1) → HandleInformationalEventV1.
- Handler lives in DecisionEngine.Shared.cs next to the other neutral
  observation handlers. Copies the signal payload 1:1 into a single
  EmitEventTimelineEntry effect; DecisionState is only touched by the
  standard StepIndex / LastAppliedSignalOrdinal bookkeeping.
- Missing or empty eventType / source produce a DeadEnd transition with
  reason informational_event_missing_{key} so malformed signals are
  visible in the transitions table rather than silently lost. Bookkeeping
  still advances on a DeadEnd so replay cannot re-apply the ordinal.
- dataJson payload key is reserved for future JSON-blob expansion;
  today's reducer forwards it verbatim (ends up as a flat Data string
  in the emitted event). Docstring clarifies the reserved status.
- 13 xUnit tests cover taken-transition + one effect, payload copy
  identity, state / hypothesis / deadline invariance, bookkeeping,
  all deadend branches, and schema-version fallthrough.

Single-rail pipe is now functionally complete end-to-end (signal post →
reducer → effect → EventTimelineEmitter → TelemetryEventEmitter). Next
subtasks wire up the V2.Core signal-post helper (4.3) and the caller-
side architecture test (4.4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:56:46 +02:00
Oliver Kieselbach 489f124c6d v2 single-rail 4.1a: add InformationalEvent signal kind + payload keys
Introduce the generic pass-through signal that carries an EnrollmentEvent
shape through the decision engine. Any peripheral collector / lifecycle
source needing the Events timeline will post this instead of calling
TelemetryEventEmitter directly, so the engine is the single ordering
source for the wire. Promotion to a decision-specific kind is a later,
localized change.

- DecisionSignalKind.InformationalEvent (new).
- SignalPayloadKeys: EventType + Source (mandatory), Message, Severity,
  ImmediateUpload, DataJson (optional).
- tests/fixtures/signal-kinds/informational-event-v1.json documents the
  wire shape with a realistic NTP-probe payload.

No reducer handler yet — that is the next subtask (4.1b); this commit is
purely the contract extension.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:47:02 +02:00
Oliver Kieselbach 23aa51a3f2 fix warnings and fix v2 agent hash compare 2026-04-22 20:09:42 +02:00
Oliver Kieselbach 2ecbefce73 backend route fix and v2 agent version check fix 2026-04-22 17:51:12 +02:00
Oliver Kieselbach 5b7f424d19 refactor(agent-v2): route whiteglove_resumed / system_reboot_detected / admin preemption through reducer effects
V2 parity cleanup - the three lifecycle events were emitted from Program.cs
directly while the corresponding reducer handlers (HandleWhiteGlovePart1To2Bridge,
HandleSystemRebootObservedV1) returned Array.Empty<DecisionEffect>(). Admin
preemption synthesised an EnrollmentTerminatedEventArgs and invoked
terminationHandler.Handle directly, bypassing the reducer entirely.

This commit unifies all three flows: the reducer is the single state owner, the
telemetry event appears as an EmitEventTimelineEntry effect of the state
transition, and the orchestrator's Terminated event fires automatically when
stage goes terminal - no more synthetic bypass.

- B1: HandleWhiteGlovePart1To2Bridge emits whiteglove_resumed timeline entry.
- B2: HandleSystemRebootObservedV1 emits system_reboot_detected with payload
  keys previousExitType/lastBootUtc.
- B3: new DecisionSignalKind.AdminPreemptionDetected + new SessionOutcome.AdminPreempted.
  Reducer HandleAdminPreemptionDetectedV1 transitions to Completed/Failed based on
  adminOutcome payload, emits enrollment_complete/_failed timeline entry with
  adminAction+source data keys. Program.cs posts the signal; terminationHandler
  still subscribes to orchestrator.Terminated (no direct Handle calls on the
  startup-preemption path).

Fixtures: tests/fixtures/signal-kinds/{system-reboot-observed,admin-preemption-detected}-v1.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:00:25 +02:00
Oliver Kieselbach fd7bf0e7ac feat(backend): M5.d.4 index-reconcile timer + release-gate — M5 complete
Closes the M5.d dual-write loop with a 2h safety-net timer and the
per-index-table PK/RK consistency release-gate tests required by
Plan §M5-Pflicht.

Components:
- ISignalRepository.QueryByTimestampAtOrAfterAsync +
  IDecisionTransitionRepository.QueryByTimestampAtOrAfterAsync: new
  cross-partition scans filtered on the Azure Tables system-managed
  `Timestamp` property. Bounded to maxResults=50000 with a warn-log
  on cap-reached so silent truncation can't masquerade as success.
- IndexReconcileTimer: TimerTrigger on "0 0 */2 * * *" (same cadence
  as MaintenanceFunction). Scans the last 4h (window deliberately 2×
  the cadence so every primary row gets re-checked at least twice
  before falling out of range), builds envelopes via the existing
  IndexReconcileEnvelopeFactory, and hands them to the producer —
  reusing the M5.d.2 flag-gate + queue-failure-swallow semantics
  instead of duplicating them.
- Testable core: RunReconcileAsync(cutoffUtc, CT) split out from the
  TimerTrigger entry so unit tests can drive with deterministic
  cutoffs without an Azure Functions host.
- AdminConfigurationService.GetConfigurationAsync is now virtual —
  enables Moq-based mocking in timer flag-gate tests. Low-risk prod
  change (no existing caller relies on final).

Release gate (Plan §M5-Pflicht line 1395): IndexDualWriteConsistencyTests
verifies per-index-table that the captured index row's back-ref
fields (TenantId, SessionId, StepIndex / SessionSignalOrdinal) plug
directly into the primary repos' key builders to reconstruct the
primary (PartitionKey, RowKey). The resubmit-idempotency test simulates
a timer-triggered replay of the same primaries and asserts all
duplicated rows collapse onto identical Index (PK, RK) pairs — which
is exactly what the real TableIndexRepository's UpsertReplace does
at the storage layer.

Left deferred (not blocking M5 close):
- SessionsByStage stale-row pruning: stage-X→Y transitions still leave
  an orphan row in {tenantId}_X. Either query-side dedupe (per
  SessionId, max-Ticks RK) or a follow-up pruner handles it.
- Actual EnableIndexDualWrite=true flip: stays default-false until
  post-deploy verification runs. Producer + timer both no-op in the
  meantime, so the change is zero-risk on merge.
- Cross-partition-scan perf: fine for current primary-table sizes;
  revisit if prod metrics after pilot show degradation.

Tests (+10 in Functions):
- 4 IndexReconcileTimerTests: flag-off short-circuits queries, flag-on
  with primary rows enqueues all envelopes, empty-window skips enqueue,
  ReconcileWindow=4h invariant.
- 6 IndexDualWriteConsistencyTests: 5 per-index-table PK/RK back-ref
  (SignalsByKind + all 4 transition-derived indices) + 1 resubmit
  idempotency ensuring all duplicated rows collapse onto the same
  index keys.

M5 status: M5.a + M5.b (+ 3 sub-commits) + M5.c (+ 3 sub-commits) +
M5.d (+ 4 sub-commits) all complete. M5.d loop:
  Primary commit (M5.a) → Envelope enqueue (M5.d.2, flag-gated)
  → Queue → Handler (M5.d.3) → Index-Tables (M5.d.1)
  + 2h safety-net Timer (M5.d.4) reconciles missed envelopes.

Full Functions suite: 692 green (prev 682 + 10 new). Full solution
build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:29:12 +02:00
Oliver Kieselbach c3ee0cad55 feat(backend): M5.d.2 index-reconcile queue producer + feature flag
Adds the async fan-out producer path from Plan §2.8 / §M5.d so primary
Signals + DecisionTransitions rows can be projected onto their index
tables without blocking the hot ingest loop.

Components:
- Constants.QueueNames.TelemetryIndexReconcile (azure-queue-name-safe,
  all lowercase + hyphens).
- IndexReconcileEnvelope (Shared DTO, flat shape with nullable fields
  per SourceKind="Signal"/"DecisionTransition"). Avoids polymorphic
  Newtonsoft deserialization — see M5.c.3 finding.
- IndexReconcileEnvelopeFactory pure static helper (FromSignal /
  FromDecisionTransition / BuildBatch) — state-less projection.
- IIndexReconcileProducer (Shared interface) contract: no-op when
  the feature flag is off; never rethrows underlying-queue failures.
- AzureQueueIndexReconcileProducer (Functions) — Base64-encoded JSON
  messages, lazy CreateIfNotExistsAsync guarded by Interlocked flag,
  Managed-Identity-or-connection-string construction mirroring
  BlobStorageService / TableStorageService.
- IngestTelemetryFunction ctor gains IIndexReconcileProducer; after
  primary Signals + DecisionTransitions commit it builds envelopes
  and enqueues them. Event path is untouched (events don't feed the
  index tables per §2.8).
- Program.cs registers the producer as a singleton.

Feature gate (AdminConfiguration.EnableIndexDualWrite, default false):
- New bool field in AdminConfiguration.
- TableConfigRepository Store + Map extension (per
  feedback_table_storage_serialization).
- Producer checks config each call via AdminConfigurationService's
  5-min memory cache. Flag off → primary behaviour is bit-exact.

Fault tolerance: queue-side exceptions (network, throttling, missing
queue before first CreateIfNotExists) are logged per envelope and
swallowed. The primary row is the source of truth; the 2h reconcile
timer (M5.d.4) is the safety-net recovery path.

Tests (+11 in Functions):
- 5 IndexReconcileEnvelopeFactoryTests: Signal/DecisionTransition
  projection, dead-end preservation, batch ordering, null/empty.
- 3 IndexReconcileEnvelopeSerializationTests: Newtonsoft round-trip
  (Signal, full DecisionTransition, minimal-envelope tolerance).
- 3 AdminConfigurationEnableIndexDualWriteTests: default-false on
  new config + CreateDefault, persists when set.

Deferred to later sub-milestones:
- M5.d.3: queue-triggered consumer that maps envelopes to 0–3 index
  rows using M5.d.1 TableIndexRepository.
- M5.d.4: 2h reconcile timer + release-gate dual-write verification
  tests, then flip EnableIndexDualWrite=true in prod admin config.

Full Functions suite: 672 green (prev 661 + 11 new). Full solution
build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:48:27 +02:00
Oliver Kieselbach 5eb09176b9 feat(backend): M5.d.1 index-table scaffolding for Plan §2.8 query matrix
Registers 5 secondary-index tables (SessionsByTerminal, SessionsByStage,
DeadEndsByReason, ClassifierVerdictsByIdLevel, SignalsByKind) in
Constants.TableNames.All[] so TableStorageService.InitializeTablesAsync
auto-creates them at startup.

Adds:
- 5 sealed DTO records in Shared/Models/IndexRecords.cs carrying only
  discriminator columns + back-refs (StepIndex / SessionSignalOrdinal)
  to the primary row — no PayloadJson chunking needed.
- IIndexTableRepository with 5 Store*Async methods (single-interface
  shape keeps boilerplate low; Cosmos swap remains trivial since each
  Store* is independent).
- IndexRowKeys pure static helper for all PK/RK formats per Plan §2.8:
  D6 StepIndex suffix, D10 SessionSignalOrdinal suffix, D19 Ticks
  padding for SessionsByStage (matches lex ordering to numeric time).
  Defensive Sanitize() for forbidden PK/RK chars (/\#?).
- TableIndexRepository with a generic group-by-PK + 100-op-chunk
  UpsertReplace write path mirroring TableSignalRepository.
- DI wiring in DataAccessServiceExtensions.

Scope: write-only. Query methods deferred until MCP/Web concretely need
them. No queue producer / reconcile handler yet — that lands in M5.d.2+.

Tests (+25 in Functions): 15 PK/RK format assertions in IndexRowKeysTests
+ 10 entity-projection round-trips (incl. null-tolerance) in
TableIndexRepositoryMappingTests.

Full Functions suite: 661 green (prev 636 + 25 new). Full solution build
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:29:35 +02:00
Oliver Kieselbach 44f0e44b72 feat(backend): M5.c.3 GET /api/sessions/{id}/reducer-verification — structural audit
Third and final V2 Inspector read endpoint. Admin-only (not tenant-exposed)
structural health check over a session's persisted SignalLog +
DecisionTransitions journal. Closes the M5 read-endpoint trio.

- Shared DTO ReducerVerificationReport + VerificationIssue: counts,
  ordinal / step-index contiguity, ReducerVersion drift, orphaned-reference
  count, human-readable issue stream for the Inspector's verification panel.
- ReducerVerifier.Verify — pure function, zero IO. Five detectors:
  empty-session (Info), signal ordinal gaps (Warning, capped at 20 issues +
  overflow summary to keep reports bounded), step-index gaps (Warning, same
  cap), orphaned transitions whose SignalOrdinalRef falls outside the loaded
  signal set (Warning, capped), and ReducerVersion drift (Info — known
  condition per plan §2.10, not an error).
- GetSessionReducerVerificationFunction — reads signals + transitions for
  the session, loads the live DecisionEngine.ReducerVersion, runs the
  verifier, returns the report. Global-Admin cross-tenant fallback via
  SessionsIndex when the effective tenant has no data. Truncation flag when
  either the 5000-signal or 5000-transition cap is hit.
- Endpoint gated by EndpointPolicy.GlobalAdminOnly — the plan explicitly
  scopes this to adminMode/ops access. The fail-closed catalog policy
  completeness test enforces the registration.

Scope boundary explicitly documented on the function: full engine replay
(DecisionEngine.Reduce over reconstructed DecisionSignals with per-step diff)
is NOT implemented in this commit. It would require polymorphic
deserialisation of SignalRecord.PayloadJson back into DecisionSignal with its
Evidence abstract type, which is a non-trivial refactor. The structural
checks land today catch the realistic release-gate failure modes (corruption,
drift, orphan refs) and give ops a usable tool. Full replay is a dedicated
follow-up once the Evidence deserialisation contract is pinned down.

9 new tests cover all five detectors plus happy path, shuffled input, and
the issue-cap overflow message. 636/636 backend green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:56:18 +02:00
Oliver Kieselbach 04270f1fb0 feat(backend): M5.c.2 GET /api/sessions/{id}/decision-graph — Inspector DAG
Second V2 Inspector read endpoint. Server-side projection from the persisted
DecisionTransitions journal into a renderable DAG so the UI consumes one
structured shape instead of rebuilding the graph on every page open, and so
"what counts as terminal" stays authoritative next to the backend copy of
that list.

- Shared DTO DecisionGraphProjection (Nodes + Edges + ReducerVersion).
  Nodes are de-duplicated stages with IsTerminal + a coarse TerminalOutcome
  label ("Succeeded" / "Failed" / "PausedForPart2"). Edges are one per
  transition, preserving dead-ends (Taken=false) for the UI to render in a
  distinct style. VisitCount counts *taken* transitions only — dead-ends
  don't advance the heat-map.
- IDecisionTransitionRepository.QueryBySessionAsync added alongside
  StoreBatchAsync. Mirror of the M5.c.1 Signals pattern: PartitionKey filter,
  explicit StepIndex sort as a redundancy guard (the D10 RowKey padding
  already matches numeric order).
- TableDecisionTransitionRepository.FromEntity — reverse mapping (entity →
  record) including TableStorageChunking.ReassembleProperty for oversized
  PayloadJson. Round-trip tested end-to-end.
- DecisionGraphBuilder.Build — pure projection function, no IO, no service
  deps. Unit-testable against canned transition lists; the test suite covers
  empty input, node de-duplication, terminal classification for all 4 stages,
  dead-end preservation, StepIndex ordering under shuffled input, and
  VisitCount semantics.
- GetSessionDecisionGraphFunction — same shape as GetSessionSignalsFunction
  (RequestContext resolution, Global-Admin cross-tenant fallback), plus a
  truncation flag when the 5000-step cap is hit.
- Endpoint registered in EndpointAccessPolicyCatalog under MemberRead +
  TenantScoping.QueryParam (sibling of sessions/{id}/events).

Scope boundary — the plan calls for terminal-node decoration with the richer
TerminationReason + TerminationOutcome from the M4.6.β enrollment_terminated
event. That data lives in the Events table, not in the transition journal;
joining it here would expand scope into cross-table reads. The current
projection exposes the coarse TerminalOutcome label (derived from the
transition's ToStage) which is enough for the M6 Inspector's initial DAG
render. Richer decoration is a dedicated follow-up.

Three sources of truth now carry the terminal-stage list: agent-side
DecisionCore.SessionStageExtensions.IsTerminal, backend-ingest
TelemetryPayloadParser.IsTerminalStage, and now DecisionGraphBuilder's copy.
Adding a new terminal stage = three places to update; flagged in the builder's
docstring so it doesn't get missed.

627/627 backend tests green (+13: 11 builder + 2 transition round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:50:14 +02:00
Oliver Kieselbach f1e89146cb feat(backend): M5.c.1 GET /api/sessions/{id}/signals — Inspector SignalLog read
First of three V2 Inspector read endpoints (Plan §M5). Returns the SignalLog
for a session — ordered by SessionSignalOrdinal, bounded by a caller-supplied
maxResults with a hard 5000 cap.

- ISignalRepository.QueryBySessionAsync added alongside the existing
  StoreBatchAsync. Uses Azure Tables filter on PartitionKey = {tenant}_{session};
  the D19(ordinal) RowKey padding means lex order already matches numeric
  order, so the explicit sort on return is a redundancy guard — cheap
  invariant check, not a real cost.
- TableSignalRepository.FromEntity — reverse mapping (entity → SignalRecord)
  that mirrors ToEntity, including TableStorageChunking.ReassembleProperty
  for oversized PayloadJson. Round-trip tested end-to-end.
- GetSessionSignalsFunction.Run — follows the same shape as
  GetSessionEventsFunction: RequestContext.TargetTenantId resolved by
  PolicyEnforcementMiddleware, Global-Admin cross-tenant fallback via
  SessionsIndex when the effective-tenant query comes up empty.
- ParseMaxResults extracted as a pure helper taking the raw query string
  (not HttpRequestData) so it's unit-testable without mocking the abstract
  Worker HttpRequestData class.
- Endpoint registered in EndpointAccessPolicyCatalog under MemberRead +
  TenantScoping.QueryParam (same policy as sessions/{id}/events). Without
  this the fail-closed catalog would reject the route with 403 — verified
  by the EndpointPolicyCatalogCompletenessTests.

9 new tests (614/614 backend green): ParseMaxResults defaults / garbage /
in-range / cap / negative / null query string (6), plus FromEntity
round-trip (simple payload + oversized-chunked payload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:37:27 +02:00
Oliver Kieselbach 260d9afd8d feat(backend): wire WhiteGloveSealingPatternIds through /api/agent/config
The V2-agent AgentConfigResponse schema has carried four fields the backend
was expected to populate (Plan §M5): LatestAgentSha256, LatestAgentExeSha256,
AllowAgentDowngrade, and WhiteGloveSealingPatternIds. The first three were
already wired through adminConfig; the last one was declared on the DTO but
never populated from storage — V2 agents always received an empty list,
effectively leaving the sealing-pattern emission path dead.

Adds the missing plumbing:

- AdminConfiguration.WhiteGloveSealingPatternIdsJson (string, JSON-serialised
  List<string>) + GetWhiteGloveSealingPatternIds() helper. Defaults to empty
  for null/whitespace/invalid JSON so the feature stays off by default
  (M3-compatible, no regression on tenants that haven't opted in).
  Global-only, no per-tenant override (plan §M5 M4.4.5.e decision).
- TableConfigRepository Store + Map entries for the new column
  (feedback_table_storage_serialization — new model properties MUST round-trip
  through the table mapping or the field silently drops on save/load).
- GetAgentConfigFunction populates WhiteGloveSealingPatternIds from
  adminConfig.GetWhiteGloveSealingPatternIds(). ConfigVersion bumped 25 → 26
  as a semantic-change marker.

5 new tests mirror the HarmlessEventIds pattern (null / whitespace / invalid
JSON / valid round-trip / explicit empty array). 605/605 backend tests green.

Agent-side (M4.4.4 / M4.4.5.e): the V2 agent's OnPatternMatched hook already
reads WhiteGloveSealingPatternIds from the cached config and re-emits
matching IME pattern hits as WhiteGloveSealingPatternDetected DecisionSignals.
With this commit the data path is end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:27:54 +02:00
Oliver Kieselbach 0936132a00 feat(backend): M5.b.3 payload size-cap guard for /api/agent/telemetry
Defense-in-depth: reject oversized request bodies before buffering them
in memory + deserialising. Closes the documented gap from M5.b — the V2
endpoint previously accepted unbounded bodies while legacy /api/agent/ingest
has capped NDJSON payloads since day one.

- Reuse existing TenantConfiguration.MaxNdjsonPayloadSizeMB (table-only,
  admin-set, default 5 MB). The property name is historical; scope is
  shape-agnostic — both NDJSON and JSON-array paths now share the knob.
  Doc comment updated to reflect that. No new config surface.
- New internal static helper IngestTelemetryFunction.ReadBodyWithSizeCapAsync
  reads the already-gzip-decompressed request stream with a strict byte cap.
  Short-circuits + returns (exceeded=true, body="") the instant the cap
  would be exceeded, so a malicious sender can't force a 100 MB allocation.
  Semantics match legacy NdjsonParser: strict greater-than, equal-to accepted.
- Cap exceeded → HTTP 413 Payload Too Large with IngestEventsResponse body
  (Success=false, Message describes limit). Agent's UploadResult parser
  classifies 413 as Permanent (not retried), which is correct — the same
  batch will fail again. The agent handles this by dropping the batch from
  the spool after the permanent-failure log.

4 new tests: under cap, exactly at cap, over cap (asserting the stream is
not fully drained — memory bound), empty stream. 600/600 backend tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:20:55 +02:00
Oliver Kieselbach f0fc3696f6 feat(backend): M5.b IngestTelemetry endpoint — POST /api/agent/telemetry
V2 ingest endpoint consuming a heterogeneous batch of TelemetryItemDtos
(Event + Signal + DecisionTransition in one JSON array). gzip request
bodies are decoded transparently by the existing UseRequestDecompression
middleware — new code only has to read a plain stream.

- TelemetryItemDto (Shared): wire-format DTO mirroring the agent's
  TelemetryItem JSON shape so the backend can deserialise without
  linking agent assemblies.
- IngestTelemetryFunction: security check (cert / rate / hardware
  whitelist) → device & version kill-switch short-circuits with
  DeviceBlocked=true body → body parse → tenant-header/body match →
  partition by Kind → persist via the appropriate repository → 200
  with AdminAction + Actions control-signal fields populated from
  ISessionRepository (matching the /api/agent/ingest parity needed by
  the M4.6.ε agent-side UploadResult parser).
- TelemetryPayloadParser: pure defensive parsing layer (wire → storage
  record). Malformed PayloadJson on a single item returns null; one
  poisonous item can't poison the batch. Projects index discriminators
  (IsTerminal, ClassifierVerdictId, ClassifierHypothesisLevel) off the
  DecisionTransition payload so the later queue-driven index fan-out
  reads them straight off the primary row.
- EndpointAccessPolicyCatalog: POST agent/telemetry registered under
  DeviceOrBootstrapAuth (same policy as agent/ingest). Without this
  the fail-closed catalog rejects the route with 403.

Scope boundary — Events get stored via StoreEventsBatchAsync only;
the rule engine, app-install aggregation, SignalR notification, and
vulnerability correlation remain on the legacy /api/agent/ingest path.
Sharing that pipeline is tracked as M5.b.2 (extract EventIngestProcessor
so both functions run the same downstream steps) — M7 rollout gates on
completing it. Documented inline in PersistItemsAsync.

28 new tests: defensive null-handling per kind, typed-column extraction
for Signal (with envelope fallback for missing SessionTraceOrdinal),
DecisionTransition dead-end + classifier-verdict nesting, IsTerminal
authoritative-list guard (Completed / Failed / WhiteGloveSealed /
WhiteGloveCompletedPart2 — mirrors DecisionCore SessionStageExtensions),
PartitionKey parser including malformed-shape rejection. The
EndpointPolicyCatalogCompletenessTests guard the route registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:33:36 +02:00
Oliver Kieselbach eeaa0643a2 feat(backend): M5.a primary tables — Signals + DecisionTransitions repositories
Groundwork for the V2 ingest path (/api/agent/telemetry). Adds the two
primary Decision-Engine tables projected from the agent-side SignalLog
and Journal so the Inspector + Reducer-Verifier can replay sessions.

- Constants.TableNames: Signals, DecisionTransitions (+ All[] init).
- Shared DTOs SignalRecord / DecisionTransitionRecord carry a flat
  projection plus the agent-serialised PayloadJson for full fidelity.
  DecisionTransitionRecord eagerly carries index discriminators
  (IsTerminal, ClassifierVerdictId, ClassifierHypothesisLevel) so the
  later index fan-out can read them straight off the primary row.
- ISignalRepository / IDecisionTransitionRepository: lean interfaces
  (StoreBatchAsync only — queries land with the read endpoints in M5.c).
- TableSignalRepository / TableDecisionTransitionRepository: idempotent
  UpsertReplace via entity-group-transactions, grouped per (tenant,
  session), chunked at the 100-op Azure limit. PayloadJson goes through
  TableStorageChunking so the 32K-char property ceiling can't bite on
  large Evidence + Payload blobs.
- Registered in DataAccessServiceExtensions alongside the existing 13
  Table repositories — Cosmos swap remains a single-file change.
- 11 pure mapping tests cover key builders (PK tenant_session,
  RK D19(ordinal) / D10(stepIndex), lex-ordering invariant), full
  entity projection, chunked PayloadJson handling, dead-end paths,
  and null-payload tolerance.

Index tables (SessionsByTerminal, SessionsByStage, DeadEndsByReason,
ClassifierVerdictsByIdLevel, SignalsByKind) land with the queue-driven
fan-out in M5.b; the primary schemas are stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:19:08 +02:00
Oliver Kieselbach 941e03cf32 refactor(agent-v2): rename telemetry endpoint to /api/agent/telemetry
Place the new V2 ingest route under the existing /api/agent/* namespace
(where all other agent-writes live: register-session, ingest, config,
upload-url) instead of the orphan /api/telemetry/* bucket. Agent writes
vs. admin/web reads is the thematic split used throughout the repo —
/api/sessions/* is reserved for read-side and admin action routes.

Constant renamed TelemetryBatch → IngestTelemetry to mirror the legacy
IngestEvents pattern; semantic successor, heterogeneous TelemetryItem[]
payload replacing per-type ingest once Legacy is decommissioned.

No wire-format or handler changes — URL string only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:56:18 +02:00
Oliver Kieselbach 17063be600 feat(agent-v2): M4.6.β terminal dispatch — FinalStatus, SummaryDialog, cleanup, server actions
Closes the second batch of peripheral-parity gaps so the V2 agent actually
terminates like Legacy does: write FinalStatus.json, launch the WPF summary
dialog in the user session, upload diagnostics (respecting mode/outcome),
write the enrollment-complete marker, run self-destruct, and signal shutdown.
All of this is driven off a single typed kernel event so nothing leaks back
into the DecisionEngine.

Terminal-stage detection (kernel hook)
- DecisionCore/State/SessionStageExtensions.IsTerminal() + IsPauseBeforePart2()
  — Completed, Failed, WhiteGloveSealed, WhiteGloveCompletedPart2 are terminal;
  WhiteGloveSealed is the "pause before Part 2" special case (session resumes
  on reboot, cleanup MUST be suppressed).
- DecisionStepProcessor ctor gains Action<DecisionState>? onTerminalStageReached;
  fires at most once per processor lifetime, swallows handler exceptions,
  suppressed when initial state was already terminal (crash-recovery race).
- EnrollmentOrchestrator.OnDecisionTerminalStage translates the hook into the
  public Terminated event with Reason=DecisionTerminalStage + Outcome mapped
  from stage (Completed/Part2/Sealed→Succeeded, Failed→Failed, else TimedOut).
  MaxLifetime watchdog is cancelled on the real terminal so the two paths can't
  race each other.

Peripheral termination (V2.Core/Termination/*)
- FinalStatus DTO — wire-format mirror of SummaryDialog's Models/FinalStatus
  (same JSON property names). Lives in V2.Core because the dialog is a
  separate net48 WPF exe we can't ProjectReference.
- FinalStatusBuilder — pure function: DecisionState + AppPackageStateList +
  TerminatedArgs + agent start time → FinalStatus. Maps outcome string
  ("succeeded" | "failed" | "timed_out" | "whiteglove_part1" | "whiteglove_part2"),
  derives signalsSeen from DecisionState facts, counts apps by AppTargeted
  phase (Device/User), tallies device vs user errors.
- SummaryDialogLauncher — writes final-status.json, copies dialog exe + deps
  + status file to a per-launch temp dir (%TEMP%\AutopilotMonitor-Summary\<guid>),
  composes the Legacy-compatible CLI (--status-file, --timeout, --cleanup,
  optional --branding-url), invokes UserSessionProcessLauncher.LaunchInUserSession
  with the configured retry window. Fire-and-forget, swallows all errors.
- EnrollmentTerminationHandler — the Terminated-event handler that sequences:
  build FinalStatus → write JSON + launch dialog → upload diagnostics
  (respects DiagnosticsUploadMode: Off/Always/OnFailure) → write
  enrollment-complete.marker (suppressed on WhiteGloveSealed) → run
  CleanupService.ExecuteSelfDestruct (suppressed on WhiteGloveSealed OR when
  SelfDestructOnComplete=false) → signal shutdown MRE. Idempotent; each step
  is best-effort and logs rather than throws — nothing is allowed to prevent
  agent shutdown.

DefaultComponentFactory
- Stores the ImeLogHost it creates and exposes its PackageStates to the
  termination pipeline (factory.ImePackageStates) so FinalStatusBuilder has
  the live package list without breaking host encapsulation.

Program.cs wiring
- Instantiates DiagnosticsPackageService, EnrollmentTerminationHandler, and
  ServerActionDispatcher with all callbacks live:
  • rotate_config → RemoteConfigService.FetchConfigAsync()
  • request_diagnostics → DiagnosticsPackageService.CreateAndUploadAsync()
  • terminate_session → EnrollmentTerminationHandler.Handle() synthesising a
    DecisionTerminalStage/Failed termination
- Terminated event routed to the handler; the handler signals the shutdown
  MRE so the main thread cleanly calls orchestrator.Stop().
- ServerActionDispatcher is alive but has no ingest-response consumer yet —
  V2 batch endpoint is M5 scope, we plumb the API contract here so M5 can
  drop in the response parser.

Tests (+47 total)
- DecisionCore.Tests: +15 SessionStageExtensionsTests (IsTerminal + IsPauseBeforePart2)
- V2.Core.Tests: +32 across terminal-stage hook, FinalStatusBuilder, handler
  • DecisionStepProcessorTerminalStageTests: fires once per stage, skips
    non-terminal, respects initial-terminal-state (recovery), swallows
    handler exceptions, works with null callback
  • FinalStatusBuilderTests: outcome mapping per stage, uptime, HelloOutcome
    fact path, signalsSeen composition, empty package list, device/user
    error tallying, JSON property-name contract
  • EnrollmentTerminationHandlerTests: writes FinalStatus.json + marker,
    runs/skips cleanup per config + WhiteGlove rule, idempotent shutdown
    signal, diagnostics mode matrix (Off/Always/OnFailure × Succeeded/Failed),
    suffix ("success" | "failure") per outcome

All 4 suites green: 88 DecisionCore + 353 V2.Core + 507 Agent.Core + 557 Functions
= 1505 tests green.

Remaining M4.6 scope: γ (Geo/NTP/Timezone/Emergency/Distress services wiring,
RegistryWatcher admin-action detector, DeliveryOptimizationCollector in
DefaultComponentFactory) + δ (--run-gather-rules + --run-ime-matching modes,
AnalyzerManager + 3 analyzers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:37:36 +02:00
Oliver Kieselbach 81b3e053a3 refactor(v2core): M4.4.5.e whiteglove sealing pattern IDs injection
Neue AgentConfigResponse.WhiteGloveSealingPatternIds-Property traegt
die Tenant-Config-Liste der Pattern-IDs, die vom V2-Agent als
WhiteGloveSealingPatternDetected-DecisionSignal re-emittiert werden.
Default leer = Feature off = M3-kompatibel.

IComponentFactory.CreateCollectorHosts nimmt zusaetzlich
whiteGloveSealingPatternIds entgegen. Orchestrator akzeptiert die Liste
per Ctor-Param und reicht sie an die Factory weiter, die sie an den
ImeLogTrackerAdapter-Ctor uebergibt (M4.4.4 hat den Hook vorbereitet).

EnrollmentOrchestratorEventBridgeTests FakeFactory-Signatur nachgezogen.

+3 WG-Pattern-Injection-Tests (265 → 268 gruen; null → empty coerce,
verbatim passthrough, empty collection erhalten).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:50:32 +02:00
Oliver Kieselbach 8294f7a8a2 refactor(v2): M4.4.4 classifier-tick from session start + hello-outcome forward + pattern-match hook
Three related fixes Plan §4.x M4.4.4 calls out:

1. ClassifierTick armed at SessionStarted (DecisionCore.Shared)
   Previously the WhiteGlove classifier deadline was armed reactively on the
   first WG-relevant signal (AttachWhiteGloveClassifierEffects, WhiteGlove.cs).
   Non-WG sessions and late-WG sessions never got a periodic re-evaluation,
   which is the M3 re-trigger-lücke. Fix: HandleSessionStartedV1 now schedules
   the ClassifierTick deadline up-front and emits the matching
   ScheduleDeadline effect. The existing reactive `hasTick` dedup makes the
   WG-path arm a no-op once the tick is already present. Classifier-runner
   anti-loop (snapshot-hash) keeps the tick cheap for non-WG paths until the
   session reaches a terminal stage and HandleClassifierTickDeadlineFired
   stops re-arming. Dead-end branch (SessionStarted on active state) is
   unchanged — no deadline, no effect, state preserved.

2. HelloOutcome now forwards from coordinator (EspAndHelloTrackerAdapter)
   ReadHelloOutcome() previously returned hardcoded "unknown", so
   HelloResolved signals always carried that value regardless of what the
   inner HelloTracker actually observed. The coordinator already exposes a
   forwarded HelloOutcome property (line 78, since M2) — only the adapter
   was missing the wiring. One-line fix: `_coordinator.HelloOutcome ?? "unknown"`.

3. ImeLogTracker.OnPatternMatched hook + WhiteGloveSealingPatternDetected
   emission (ImeLogTracker + ImeLogTrackerAdapter, closing the M4.3.4 tech debt)
   Added `Action<string> OnPatternMatched` to ImeLogTracker, invoked by
   HandlePatternMatch after LastMatchedPatternId is set (subscribers can read
   the ID synchronously). Adapter gains an optional ctor arg
   `IReadOnlyCollection<string> whiteGloveSealingPatternIds` (default empty,
   backwards-compatible with pre-M4.4.4 M3 behavior — no emission). When
   configured and a matching pattern fires, adapter posts
   WhiteGloveSealingPatternDetected once per session (fire-once). Polling-on-
   LastMatchedPatternId (alternative A from plan) was rejected; the hook is
   a real event surface. Orchestrator in M4.4.5 will inject the tenant-
   configured sealing-pattern-ID list.

Bonus: reduced EventSequenceCounterTests thread-safety stress from 6×200 to
4×50 to avoid parallel xUnit test contention on the Windows atomic-rename
path (Next() persists to disk per call). Test still catches any counter race
via the uniqueness assertion.

SmokeTests.DecisionEngine_Reduce_SessionStarted_advancesStep updated to assert
the new ClassifierTick effect/deadline instead of `Assert.Empty(step.Effects)`.

+18 tests (1350 -> 1368 green, 57 DecisionCore + 218 V2.Core before vs. 73 + 228
after): ClassifierTickFromSessionStartTests (5) · EspAndHelloTrackerAdapter-
HelloOutcomeTests (3) · ImeLogTrackerAdapterSealingPatternTests (7) · smoke +
harness updates. Bestehende Scenario-Tests grün (Classic/WG1/WG2/
SelfDeploying/Edge) — ClassifierTick-at-start ist additiv, kein Scoring-Impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:23:56 +02:00
Oliver Kieselbach fb98d43ae5 refactor(decisioncore): M4.4.3 reducer handlers for diagnostic signals
DeviceInfoCollected and AutopilotProfileRead previously fell through to
HandleUnhandledSignal, writing unhandled_signal_kind:* DeadEnd rows into the
journal on every emission — pure noise, no decision value, but distorting the
Inspector timeline. Plan §4.x M4.4.3 fix.

Both are diagnostic signals: they carry hardware inventory / Autopilot profile
payload used only by the Inspector and backend analyzers. They do NOT drive
stage or hypotheses. Fix is minimal: neutral Taken transitions via a new
shared helper RecordDiagnosticObservation — bookkeeping advances,
stage/hypotheses untouched, no effects, no DeadEndReason. Full payload stays
in the SignalLog for Inspector rendering (Plan §2.7c — Snapshot/Journal are
Cache, SignalLog is Master; no reason to duplicate data into DecisionState).

Version-gap protection: dispatcher entries are keyed on (Kind, v1). A future
v2 payload still dead-ends with unhandled_signal_kind:*:v2 — the L.6
forward-compat contract requires explicit handler upgrades, not silent
recording under the old handler.

AppInstallCompleted/AppInstallFailed intentionally stay unwired — no decision
consumer in v11 scope, and the collector isn't emitting them yet.

+11 tests (1339 -> 1350 green): Taken-without-DeadEnd, stage+hypotheses
unchanged, bookkeeping advancement, no effects, existing deadlines preserved,
v2-schema-version falls through to unhandled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:03:08 +02:00
Oliver Kieselbach bca53bd43d refactor(v2core): M4.4.2 backend telemetry uploader HTTP skeleton
Concrete IBackendTelemetryUploader implementation for the Plan §2.7a
POST /api/telemetry/batch endpoint. Skeleton only — the live backend
endpoint lands in M5; this milestone only needs the client-side contract
testable against HttpMessageHandler fakes.

Design:
- New class BackendTelemetryUploader in Transport/Telemetry/ (NOT added as a
  method on the M2-copied Legacy BackendApiClient, which is slated for
  removal in M4.5 / v11.1+). Lives next to IBackendTelemetryUploader;
  clean layer, no legacy cross-dependency.
- HttpClient injected (not self-constructed). mTLS client-cert wiring stays
  with the orchestrator-builder in M4.4.5 via
  HttpClientHandler.ClientCertificates; the uploader itself is cert-agnostic,
  which makes it trivially testable with a custom handler.
- Ctor deps are flat: baseUrl, tenantId, optional hardware/bootstrap/version
  headers. No AgentConfiguration coupling — orchestrator extracts values.
- No retry/back-off here; TelemetryUploadOrchestrator (M4.1) owns that.
- Body: JSON array of TelemetryItem via Newtonsoft (PascalCase; Kind as
  string via existing StringEnumConverter annotation). No gzip — evaluate
  in M5 once bandwidth profile is known.
- HTTP status -> UploadResult mapping:
    2xx                 -> Ok
    401 / 403           -> Permanent ("unauthorized: ...")
    408 / 429           -> Transient (rate limit / request timeout)
    other 4xx           -> Permanent (client bug; retry pointless)
    5xx                 -> Transient
    HttpRequestException-> Transient ("network: ...")
    TaskCanceledException (HttpClient internal timeout, not caller CTS)
                        -> Transient ("timeout: ...")
    OperationCanceledException with caller-CTS signaled -> rethrow
  Empty batch short-circuits with Ok (no HTTP call) to avoid wasted
  round-trips when a concurrent drain emptied the spool mid-upload.
- Security headers: X-Tenant-Id (pre-body auth check), hardware headers
  (X-Device-Manufacturer/Model/SerialNumber for whitelist + Autopilot
  validation), optional X-Bootstrap-Token (pre-MDM OOBE), X-Agent-Version
  for kill-switch + diagnostics.

New Constants.ApiEndpoints.TelemetryBatch = "/api/telemetry/batch" — shared
with M5 backend-side routing.

+26 tests (1313 -> 1339 green): URL + header shape, JSON body wire-format
(Kind as string, TelemetryItemId/PK/RK/PayloadJson round-trip), empty-batch
short-circuit, full HTTP status-code -> UploadResult matrix (2xx/401/403/
4xx/408/429/5xx), HttpRequestException + timeout mapping, caller
cancellation propagation, null-arg rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:54:07 +02:00
Oliver Kieselbach 4b3fd46f69 refactor(v2core): M4.2 effect runner (deadline/classifier/emit/snapshot wiring)
Plan §2.5 / §2.7b / L.17 — einziger Side-Channel vom pure Reducer zur Außenwelt.

New in DecisionCore:
- ClassifierVerdictLookup.LookupLastVerdictId(state, classifierId) — mapping
  classifier-id → hypothesis-slot. Shared between ClassifierAwareReplayHarness
  (refactored) and the production EffectRunner (DRY, L.15).

New in V2.Core.Orchestration:
- IClassifierRegistry + ClassifierRegistry (id→IClassifier, dup-detect)
- ISignalIngressSink — abstraction for posting synthetic signals (real impl M4.4)
- IEventTimelineEmitter — abstraction for EmitEventTimelineEntry (real impl M4.4)
- EffectFailure + EffectRunResult — orchestrator-consumable dispatch result
- IEffectRunner + EffectRunner
  - ScheduleDeadline/CancelDeadline → critical class: any exception sets
    SessionMustAbort=true with reason=timer_infrastructure_failure, stops
    processing subsequent effects
  - RunClassifier → optional class: anti-loop via ClassifierVerdictLookup +
    IClassifierSnapshot.ComputeInputHash(); exception → Inconclusive verdict
    posted via ingress, no abort
  - EmitEventTimelineEntry / PersistSnapshot → transient class: retry
    100/400/1600ms via IClock.Delay, exhaust → failure recorded but no abort
    (§2.7b explicit „State unverändert, weiter im Flow")

New tests (+18, 74 total in V2.Core.Tests):
- Schedule/Cancel forwarding + abort on scheduler exception + stop-subsequent
- Emit retry-then-success + retry exhaust (no abort, failure recorded) +
  failure does not block subsequent effects
- Persist retry-then-success
- Classifier happy (verdict signal posted correctly) + anti-loop skip +
  exception → Inconclusive verdict + unknown id → failure + missing snapshot
- Batch-dispatch-order test, empty-batch test

Testsuite: 1177 → 1195. V2-isolation clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:15:19 +02:00
Oliver Kieselbach d76da2ad75 refactor(v2core): M4.0 persistence writers + deadline scheduler skeleton
- TransitionSerializer + StateSerializer in DecisionCore.Serialization
  (Newtonsoft-ctor-binding, shared between agent writers + backend verifier)
- V2.Core.Persistence: SignalLogWriter, JournalWriter (JSONL append-only,
  WriteThrough+Flush(true) per append — plan L.12 sofort-flush);
  SnapshotPersistence (SHA256-envelope, tempfile+rename, .quarantine/)
- V2.Core.Orchestration: IDeadlineScheduler + DeadlineScheduler
  (IClock-basiert; past-due fires on ThreadPool; same-name replace;
  RehydrateFromSnapshot; plan §2.6 / L.7 Timer-Monopol)
- V2.Core.Tests (new net48 xUnit project): 28 tests across persistence
  (roundtrip, monotonicity, tail-corruption tolerance, checksum-mismatch,
  quarantine) + scheduler (past-due, future, cancel, replace, rehydrate,
  dispose)
- V2-isolation preserved (no reference to Agent.Core)

Testsuite: 1121 → 1149 (+28). Foundation for M4.1–M4.5 (Transport, EffectRunner, SignalAdapters, Orchestrator-Wiring, transitional-Cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:25:36 +02:00
Oliver Kieselbach 75c98d131f refactor(decisioncore): edge scenarios — hybrid reboot, ESP failure, recovery (M3.5)
Final M3 sub-milestone. Closes the plan's §4 M3 scenario matrix:

Handlers (DecisionEngine.Edge.cs):
 - EspResumed              mid-ESP reboot. Stage preserved; records
                           SystemRebootUtc on first observation (hash
                           stable on repeats).
 - EspTerminalFailure      Stage=Failed + Outcome=EnrollmentFailed,
                           deadlines cleared, enrollment_failed event
                           emitted with reason carried from payload.
                           Definitive — no classifier re-run.
 - SystemRebootObserved    records SystemRebootUtc so the WhiteGlove
                           sealing classifier credits the +15 reboot
                           weight on the next RunClassifier effect.

Scenario fixtures (committed-anonymized):
 - hybrid-reboot-v1.jsonl        ESP -> mid-phase reboot -> EspResumed ->
                                 AccountSetup -> Hello -> Desktop ->
                                 Completed. No WhiteGloveSealed path.
 - esp-terminal-failure-v1.jsonl ESP -> AccountSetup -> terminal failure
                                 -> Failed / reason=
                                 esp_terminal_failure_account_setup.

Tests (EdgeScenarioTests, 5 new):
 - Hybrid reboot preserves stage, records reboot fact, completes
   normally without triggering WhiteGlove.
 - ESP terminal failure transitions straight to Failed.
 - Restart-recovery determinism: replaying the same JSONL twice yields
   identical FinalStepHash, Stage, Outcome and transition count.
 - Prefix-replay check: truncated log ends in the expected intermediate
   stage (simulates crash between signals).
 - Global sanity: every committed fixture (all 12) ends in a terminal
   stage — regression guard for any future reducer change.

Suite totals: 57 DecisionCore (was 52) / 507 Legacy / 557 Functions =
1121 tests green. All 14+ scenarios from plan §R6 Szenario-Matrix pass
through the reducer with the expected terminal outcome.

This closes M3 (DecisionEngine-Reducer + Classifier-Kernel). M4 wires the
SignalAdapter + EffectRunner + TelemetryTransport in the V2 agent runtime.

Plan: plans/REFACTOR_AGENT_V2.md §4 M3 sub-milestone M3.5 — M3 complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:26:13 +02:00
Oliver Kieselbach 8e4faf4251 refactor(decisioncore): WhiteGlove Part 2 + Part2CompletionClassifier + 2 scenarios (M3.4)
Plan §4 M3.4 + plan §2.3 WhiteGlove Part-2 state machine: post-reboot user
sign-in is now a first-class path with a dedicated classifier, a 24h safety
deadline, and an explicit Sealed -> AwaitingUserSignIn -> CompletedPart2 /
Failed progression.

Classifier (WhiteGlovePart2CompletionClassifier : IClassifier):
 - Scoring rules NEW (no Legacy equivalent): each of the four Part-2 user
   signals (+25) — UserAadSignInComplete, HelloResolvedPart2,
   DesktopArrivedPart2, AccountSetupCompletedPart2. All four together hit
   the Confirmed threshold (100); missing one drops to Weak (75).
 - WhiteGlovePart2CompletionSnapshot implements the new IClassifierSnapshot
   interface; existing WhiteGloveSealingSnapshot was retrofit to the same
   contract so the harness anti-loop is uniform.

Handlers (DecisionEngine.WhiteGlovePart2.cs):
 - SessionRecovered (Shared.cs) now bridges a WhiteGloveSealed state into
   WhiteGloveAwaitingUserSignIn and arms the 24h WhiteGlovePart2Safety
   deadline on the recovered signal's occurredAt.
 - Four Part-2 signal handlers (UserAadSignInComplete, HelloResolvedPart2,
   DesktopArrivedPart2, AccountSetupCompletedPart2) record facts + emit a
   Part-2 RunClassifier effect; facts arriving outside the AwaitingUserSignIn
   stage are recorded but no classifier runs (prevents stray Part-2 verdicts
   on non-sealed sessions).
 - WhiteGlovePart2Safety deadline handler terminates with Failed /
   enrollment_failed reason=part2_user_absent.
 - ClassifierVerdictIssued router extended to dispatch on classifier id —
   Part-2 verdicts route to ApplyWhiteGlovePart2Verdict; Confirmed triggers
   Stage=WhiteGloveCompletedPart2 + Outcome=WhiteGlovePart2Complete +
   whiteglove_part2_complete event.

DecisionState: four new SignalFact<DateTime>? fields for the Part-2 signals
(full builder parity).

ClassifierAwareReplayHarness: anti-loop lookup now routes per classifier id
(WhiteGloveSealing <-> WhiteGloveSealing.LastClassifierVerdictId;
Part2Completion <-> WhiteGlovePart2Completion.LastClassifierVerdictId).
Snapshot hashing works through the IClassifierSnapshot interface instead
of a WhiteGloveSealingSnapshot type check.

Scenario fixtures (committed-anonymized):
 - whiteglove-part2-happy-v1.jsonl  seal Part 1, reboot, 4 Part-2 signals
                                    within minutes -> Confirmed + Part2Complete.
 - whiteglove-part2-stuck-v1.jsonl  seal Part 1, reboot, 24h passes, safety
                                    deadline fires -> Failed / part2_user_absent.

Tests (3 new in WhiteGlovePart2ScenarioTests):
 - Happy terminal state + hypotheses + all four facts + no residual deadlines
 - Stuck terminal state + Part-1 Confirmed remains, Part-2 Rejected with reason
 - Bridge check: after SessionRecovered(Sealed), Part-2 safety is active

Suite totals: 52 DecisionCore (was 49) / 507 Legacy / 557 Functions.

Plan: plans/REFACTOR_AGENT_V2.md §4 M3 sub-milestone M3.4 + §2.3 Part-2 stages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:21:53 +02:00
Oliver Kieselbach f75a8d4375 refactor(decisioncore): WhiteGlove Part 1 handlers + sealing classifier + 4 scenarios (M3.3)
Plan §4 M3.3: the first kernel-service classifier goes live (plan §2.4 L.8),
the effect-based classifier-run contract is wired end-to-end, and the anti-loop
via InputHash is proven by a dedicated fixture.

Classifier (WhiteGloveSealingClassifier : IClassifier):
 - Scoring ported 1:1 from Legacy WhiteGloveClassifier (same weights, same
   thresholds, same asymmetric-conservative rule: only Confirmed drives the
   WhiteGloveSealed stage).
 - Positive weights: ShellCoreWhiteGloveSuccess=80, SealingPattern=40,
   DeviceOnlyDeployment=15, SystemReboot=15.
 - Hard excluders (-100 each): AadJoinedWithUser, DesktopArrived, HelloResolved.
   Soft exclude: AccountSetupActivity=-40.
 - WhiteGloveSealingSnapshot is the classifier input type and computes a
   deterministic SHA256-short InputHash over WG-relevant fields only — so
   the anti-loop triggers on meaningful fact changes, not on unrelated state.

Handlers (DecisionEngine.WhiteGlove.cs):
 - WhiteGloveShellCoreSuccess       records fact; emits RunClassifier effect;
                                    arms ClassifierTick if not already.
 - WhiteGloveSealingPatternDetected mirror handler for the IME-pattern path.
 - ClassifierVerdictIssued          applies the verdict to WhiteGloveSealing
                                    hypothesis; on Confirmed transitions to
                                    WhiteGloveSealed stage +
                                    Outcome=WhiteGlovePart1Sealed and emits
                                    the whiteglove_complete event.
 - HandleClassifierTickDeadlineFired (shared router)  re-emits RunClassifier
                                    and re-arms itself unless terminal.

ClassifierAwareReplayHarness (tests-only):
 - Loops per-step effects: any RunClassifier effect is resolved synchronously
   via the registered IClassifier; the verdict is wrapped in a
   ClassifierVerdictIssued signal and fed back into Reduce. Anti-loop skips
   the call when snapshot hash == state.WhiteGloveSealing.LastClassifierVerdictId.
 - Exposes ClassifierRunStats for regression tests.

DecisionState extension: two new SignalFact<bool> fields capture the WG
sightings (ShellCoreWhiteGloveSuccessSeen, WhiteGloveSealingPatternSeen). The
classifier reads them via WhiteGloveSealingSnapshot.

Scenario fixtures (4, committed-anonymized):
 - whiteglove-inline-v1.jsonl            Confirmed on ShellCore alone (80).
 - whiteglove-signal-correlated-v1.jsonl Weak after pattern, ClassifierTick
                                         anti-loop-skipped, Confirmed after
                                         ShellCore escalates the score.
 - whiteglove-false-positive-v1.jsonl    SealingPattern + late-AADJ hard-
                                         excludes -> Rejected; session still
                                         completes via Classic Hello+Desktop.
 - whiteglove-antiloop-v1.jsonl          Single pattern + two ticks; runs=1,
                                         skipped_by_antiloop=2.

Tests: 4 scenario tests in WhiteGlovePart1ScenarioTests, all passing.
DecisionCore.Tests: 49 (was 45). Legacy 507 + Functions 557 unchanged.

ReducerFailSafeTests + HarnessTests updated to use AppInstallCompleted as
the still-unhandled sentinel (WhiteGloveShellCoreSuccess is now wired).

Plan: plans/REFACTOR_AGENT_V2.md §4 M3 sub-milestone M3.3 + §2.4 classifier kernel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:15:06 +02:00
Oliver Kieselbach 0ebfa2fc3c refactor(decisioncore): SelfDeploying + Device-Only handlers + 2 scenarios (M3.2)
SelfDeploying-v1 vertical slice: DeviceSetupProvisioningComplete terminal
handler, DeviceOnlyEspDetection deadline wiring, and the
DeviceOnlyDeployment hypothesis progression UserPresent / DeviceOnly /
Ambiguous expressed via Hypothesis.Reason (plan §2.3).

Handlers (DecisionEngine.SelfDeploying.cs):
 - DeviceSetupProvisioningComplete  terminal. Cancels all deadlines,
                                    finalizes DeviceOnlyDeployment based on
                                    user-presence facts (AadJoinedWithUser,
                                    HelloResolvedUtc, DesktopArrivedUtc),
                                    raises EnrollmentType to Strong when
                                    no AccountSetup was observed before.
 - DeviceOnlyEspDetectionFired      hypothesis-only update to
                                    DeviceOnlyDeployment=Strong(DeviceOnly).
                                    Stage unchanged; completion still
                                    requires explicit provisioning signal.

Classic.cs extended:
 - First EspPhaseChanged(DeviceSetup) arms the DeviceOnlyEspDetection
   deadline (5 min window, plan §2.7).
 - EspPhaseChanged(AccountSetup) cancels it — UserDriven-v1 path.

Shared DeadlineFired router routes the deadline to the SelfDeploying body.

Scenario fixtures (tests/fixtures/enrollment-sessions/):
 - selfdeploying-happy-v1.jsonl            — 3 signals, SkipUserStatusPage
                                             path. Terminal Completed with
                                             DeviceOnly hypothesis confirmed
                                             and no user-presence facts.
 - selfdeploying-esp-exit-unknown-v1.jsonl — 4 signals, deadline fires at
                                             T+5m and sets DeviceOnly before
                                             provisioning-complete arrives.

Tests (SelfDeployingScenarioTests, 4 new):
 - SelfDeployingHappy terminal state + hypotheses
 - SelfDeployingHappy deadline cleanup
 - Device-Only ESP-exit-unknown deadline -> provisioning flow
 - AccountSetup on the Classic path cancels DeviceOnlyEspDetection
   (regression guard — UserDriven should never classify as DeviceOnly)

InternalsVisibleTo added to DecisionCore for DecisionCore.Tests so the
internal DeviceOnlyReasons tokens stay hidden from consumers while tests
assert on them directly.

Suite totals: 45 DecisionCore (was 41) / 507 Legacy / 557 Functions.

Plan: plans/REFACTOR_AGENT_V2.md §4 M3 sub-milestone M3.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:04:52 +02:00
Oliver Kieselbach 0210d42c7f refactor(decisioncore): Classic UserDriven-v1 handlers + 3 scenarios (M3.1)
First vertical slice of real reducer behavior: six handlers for the Classic
UserDriven-v1 enrollment flow plus the shared DeadlineFired router, with three
committed-anonymized scenario fixtures driving the proof.

Handlers (DecisionEngine.Classic.cs):
 - EspPhaseChanged       stage progression DeviceSetup -> AccountSetup;
                         populates CurrentEnrollmentPhase + DeviceSetupEnteredUtc /
                         AccountSetupEnteredUtc / FinalizingEnteredUtc facts;
                         raises EnrollmentType hypothesis to Weak on AccountSetup.
 - EspExiting            records EspFinalExitUtc; schedules HelloSafety (300 s);
                         transitions to AwaitingHello and emits ScheduleDeadline.
 - HelloResolved         records HelloResolvedUtc + HelloOutcome; cancels
                         HelloSafety; completes session if Desktop already arrived.
 - DesktopArrived        mirror of HelloResolved; completion when Hello already
                         resolved.
 - ImeUserSessionCompleted  raises EnrollmentType hypothesis to Strong; records
                            matched pattern id.
 - AadUserJoinedLate     HYPOTHESIS-ONLY (project memory
                         feedback_aad_joined_late_not_completion). Records
                         AadJoinedWithUser fact + annotates EnrollmentType reason.
                         Stage unchanged, no terminal event. Regression guard test
                         verifies From/To stage equality.

Shared router (DecisionEngine.Shared.cs):
 - DeadlineFired dispatches on Payload[deadline]. HelloSafety timeout synthesizes
   HelloOutcome=Timeout and drives the same completion path as HelloResolved.
   Unknown deadline names cancel the deadline and record a neutral transition —
   forward-compat with M3.3 (ClassifierTick) / M3.4 (WhiteGlovePart2Safety).

Constants:
 - DeadlineNames enumerates the 7 pflicht-deadlines from plan §2.6.
 - SignalPayloadKeys standardizes payload keys used by collectors and the
   deadline scheduler.

Scenario harness + fixtures (tests/fixtures/enrollment-sessions/):
 - ScenarioTestBase locates repo root and wraps FixtureLoader + ReplayHarness.
 - userdriven-happy-v1.jsonl            — 7 signals, terminal Completed /
                                          EnrollmentComplete / HelloOutcome=Success.
 - userdriven-hello-timeout-v1.jsonl    — Desktop arrives before Hello, deadline
                                          fires at T+5m -> HelloOutcome=Timeout,
                                          Completed.
 - late-aadj-v1.jsonl                   — Late-AADJ mid-flight does not complete
                                          prematurely; session still completes
                                          normally via Hello + Desktop.

Tests: 4 scenario tests in ClassicScenarioTests + determinism check.
Total suite: 41 DecisionCore.Tests (was 37 at M3.0) / 507 Legacy unchanged /
557 Functions unchanged.

Plan: plans/REFACTOR_AGENT_V2.md §4 M3 sub-milestone M3.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:59:30 +02:00
Oliver Kieselbach b6a949bc02 refactor(decisioncore): engine foundation — dispatch, fail-safe, clock, serialization (M3.0)
M3.0 establishes the reducer infrastructure the scenario sub-milestones
(M3.1–M3.5) will build on. No domain-specific reducer behavior yet; only
kernel plumbing + a live SessionStarted / SessionRecovered / SessionAborted
lifecycle path.

Kernel (plan §2.5 / L.2 / L.3 / L.16):
 - DecisionEngine dispatches on (Kind, KindSchemaVersion) to partial-class
   handlers. Unhandled kinds produce a DeadEnd transition with a versioned
   reason string — never a throw, never a mutation.
 - Exception fail-safe (L.16): any handler exception is caught in the
   kernel, StepIndex + LastAppliedSignalOrdinal still advance (so the
   signal counts as processed and there is no retry loop), the transition
   records DeadEndReason="reducer_exception" + SHA256-short StackTraceHash.
   DecisionState is returned untouched beyond bookkeeping.
 - Shared helpers for Taken / DeadEnd transition construction keep
   partial-class handlers short and consistent.

State (plan §2.3 / L.3):
 - Immutable DecisionState gets a mutable DecisionStateBuilder companion —
   reducer handlers use state.ToBuilder().WithStage(...).Build() to
   express "copy with changes" without retyping 20+ ctor arguments.
 - Fluent helpers AddDeadline (replace-if-same-name), CancelDeadline,
   WithCurrentEnrollmentPhase carry the most common reducer operations.

Time (plan §2.6 / L.7):
 - IClock interface + SystemClock (wall-clock wrapper). The only way any
   decision-relevant code can observe time going forward. VirtualClock
   lives in the test assembly — never in shipping builds.

Serialization (plan §2.8 / §2.15 / L.14):
 - Newtonsoft.Json added to DecisionCore (both net48 + net8.0 consume the
   same DTOs with the same converters).
 - UnknownFallbackEnumConverter<T> maps unrecognized enum strings to a
   caller-provided fallback instead of throwing — lets an older backend
   read a newer DecisionCore's rows without crashing; the verifier will
   flag them as drift_tolerated in §2.10.
 - DecisionCoreJsonSettings registers fallback converters for every
   DecisionCore enum (DecisionSignalKind, EvidenceKind, HypothesisLevel,
   SessionStage, SessionOutcome, DecisionEffectKind, EnrollmentPhase).
 - SignalSerializer round-trips DecisionSignal JSONL with enforced
   Evidence.Identifier and Evidence.Kind — a line without Evidence throws
   just like constructor validation.

Lifecycle (DecisionEngine.Shared.cs):
 - SessionStarted: anchors the first journal transition; rejects mid-flight
   replay (StepIndex != 0) with a DeadEnd instead of silently resetting
   hypotheses.
 - SessionRecovered: neutral bookkeeping in M3.0 — WhiteGlove Part 1 → 2
   transition lands in M3.4 DecisionEngine.WhiteGlovePart2.cs.
 - SessionAborted: terminal, Outcome=Aborted, all deadlines cleared —
   orchestrator admin-action path (plan §2.7 audit).

Test coverage (37 total):
 - VirtualClockTests (6)     — monotonic clock, Delay instant-return
 - DecisionStateBuilderTests (5) — immutable semantics, deadline replace/cancel
 - SerializationTests (6)     — round-trip, UnknownFallback both string + numeric
 - ReducerFailSafeTests (6)   — null-arg, replay-on-active, unhandled kind,
                                SessionAborted clears deadlines, ReducerVersion
 - SmokeTests (7)             — smoke, updated: Reduce(SessionStarted) is a
                                real transition, not a throw
 - HarnessTests (6)           — empty-stream determinism, single-signal replay,
                                unknown-kind DeadEnd at harness level
 - VirtualClock helper (instance) covered via Harness integration

Suite totals unchanged elsewhere: 507 Legacy + 557 Functions. Full solution
builds 0 warnings, 0 errors.

Plan: plans/REFACTOR_AGENT_V2.md §4 M3 (sub-milestone M3.0 Foundation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:51:45 +02:00
Oliver Kieselbach 830a27bb70 refactor(decisioncore): add DecisionCore shared library scaffolding (M1)
New netstandard2.0 project src/Shared/AutopilotMonitor.DecisionCore/ with
14 scaffolding types across Signals/, State/, Classifiers/, Engine/.
Agent.Core and Functions reference the library without call-sites; the
full solution builds 0 errors, 0 warnings. DecisionEngine.Reduce is an
intentional NotImplementedException stub — M3 fills in the partial-class
reducer.

Invariants wired into the scaffolding:
 - Immutable DTOs (sealed + get-only + With-methods) per L.3
 - Pflicht-Evidence validation in Evidence ctor per L.5
 - KindSchemaVersion enforcement per L.6
 - AssemblyVersion 2.0.0.0 as ReducerVersion source per §2.10

Architecture principles L.1–L.18 extracted to
.claude/architecture-principles.md as persistent Claude-session context.

Plan: plans/REFACTOR_AGENT_V2.md §4 M1. Zero runtime risk.

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